Files
echoboard/README.md

26 KiB


 ECHOBOARD

feedback for everyone - accounts for no one

A self-hosted feedback board where people actually own their voice.
No accounts required. No email harvesting. No surveillance.
Just people telling you what they think - on their terms.


License: CC0   Docker   Anonymous first   Zero tracking   WCAG 2.2 AAA   No email required   Passkey auth   Plugin system


🌍 What is Echoboard?

Echoboard is a feedback platform you host yourself. People visit your board, submit feature requests or bug reports, vote on what matters to them, and leave comments - all without creating an account, handing over an email address, or agreeing to be tracked.

It's built on a simple idea: feedback should be accessible to everyone, not just people willing to hand over personal data to yet another platform. The barrier to participation is zero. Show up, speak, leave. Your voice counts the same whether you're anonymous or not.

If someone wants more permanence - to keep their posts across devices or browsers - they can optionally register a passkey. No password, no email. Just a biometric or device PIN. Their identity belongs to them, stored on their own device, not in your database.

🔒 Identity, privacy, and why we don't want your email

Most platforms demand an email address before you're allowed to speak. That email becomes a leash - used for marketing, sold to data brokers, leaked in breaches, and weaponized for account recovery attacks. Echoboard doesn't ask for it because Echoboard doesn't need it. Nobody needs it. It was never about verification - it was about control.

Identity in Echoboard works as a spectrum. You choose how much of yourself to reveal, and you can change your mind at any time:

Just show up - the moment you visit, a random token is stored in your browser cookie. That's your identity. No form, no click-through, no consent banner for data you never gave. You can vote, comment, and submit posts immediately. If you clear your cookies, that identity is gone - and that's fine. The feedback you left behind still stands on its own. Your words matter more than your name.

Save a recovery phrase - if you want a safety net without committing to anything permanent, you can generate a six-word recovery phrase from the settings page. Write it on a sticky note, put it in a password manager, tattoo it on your arm - your call. If cookies get wiped, you type those six words and you're back. The phrase is hashed with bcrypt and looked up via a blind index - even we can't read it. It's single-use and expires after 90 days, so there's no permanent token sitting in a database waiting to be stolen.

Register a passkey - for people who want real persistence across browsers and devices. Passkeys use WebAuthn - your phone's fingerprint reader, your laptop's face unlock, or a hardware security key. The private key never leaves your device. We store a public key that can verify you, but can't impersonate you. No password to forget, phish, or leak. No email to harvest.

At every level, display names are encrypted at rest with AES-256-GCM. Lookups happen through blind indexes (HMAC-SHA256) so the database never stores plaintext names alongside user records. There is no email column in the users table. There is no phone number column. There is no "real name" column. These fields don't exist because we made a deliberate choice not to build the infrastructure of surveillance, even if we promise not to use it. The safest data is data that was never collected.

The people who use your feedback board are helping you build better things. The least you can do is not make them pay for that privilege with their personal information.

🗳️ How people use it

As a visitor, you land on a board and immediately see what others have submitted. You can vote on things you care about, leave comments, file your own feature requests or bug reports - all without signing up for anything. A browser cookie ties your activity together for the session. Close the tab and come back later, you're still you (as long as cookies persist).

If you want persistence, you can register a passkey from the settings page. This lets you keep your identity across browsers and devices. You get a username, a profile, and the ability to upload an avatar. Or if your browser doesn't support passkeys, you can generate a recovery phrase - six words you write down somewhere safe that let you get back to your account if cookies get cleared.

As an admin or team member, you manage boards, respond to feedback, track statuses, merge duplicates, and keep things organized. The admin dashboard gives you full control over every aspect of the platform without touching a terminal.

Why self-host?

Because your community's feedback shouldn't live on someone else's server, feeding someone else's business model. When you self-host Echoboard:

  • The data lives on your infrastructure, under your control
  • No third party reads, mines, or monetizes the feedback
  • No vendor lock-in - the code is CC0, do whatever you want with it
  • No "free tier" that mysteriously degrades when you need it most
  • No dark patterns nudging people toward paid plans
  • Your users interact with YOUR instance, not a platform that treats them as product

The tools people use to communicate should belong to the people using them. Not to shareholders, not to VCs, not to ad networks. Echoboard is a small step in that direction.

📦 Features

For everyone

  • Submit feature requests and bug reports without any account
  • Vote on posts with configurable budgets (so no one person dominates)
  • Comment with markdown, @mentions, emoji reactions, and file attachments
  • Dark and light themes that follow system preference
  • Full-text search across all boards
  • Similar post detection (so you find existing requests before duplicating)
  • RSS feeds for every board
  • Push notifications for status changes and new posts
  • Recovery codes for people who can't use passkeys
  • Keyboard accessible and screen reader friendly throughout

For admins and teams

  • Dashboard with stats, post management, and bulk actions
  • Custom statuses per board (not just open/closed - whatever fits your workflow)
  • Roadmap and changelog pages with scheduled publishing
  • Team system - invite admins and moderators without them needing email accounts
  • Granular locking - lock post edits, individual comments, entire threads, or voting
  • Post merging for duplicates with vote consolidation
  • Edit history with rollback for both posts and comments
  • Embed widget you can drop into any external site
  • Webhooks for status changes, new posts, and comments
  • View counts on every post
  • Data export in CSV and JSON

Infrastructure

  • Runs in Docker with a single docker compose up -d
  • PostgreSQL for storage, no external services needed
  • Plugin system - upload zip plugins through the dashboard, no restart required
  • ALTCHA proof-of-work spam protection (no CAPTCHAs bothering your users)
  • Field-level encryption for personally identifiable data
  • HSTS, CSP, and all the security headers you'd expect
  • Automatic database migrations on startup

Accessibility

If a tool for collective voice isn't usable by everyone, it's not really for everyone. Echoboard targets WCAG 2.2 AAA - not as a checkbox, but because access is a prerequisite for participation.

  • Full keyboard navigation - every interactive element is reachable and operable without a mouse. Focus indicators are visible on all controls. No keyboard traps.
  • Screen reader support - semantic HTML throughout, ARIA landmarks with labels, live regions for dynamic content (toasts, vote counts, search results), proper roles on custom widgets (combobox, listbox, dialog, alertdialog).
  • No information conveyed by color alone - status badges have text labels, importance levels have names, lock states have icons and text, not just color changes.
  • Contrast ratios meet AAA (7:1) - both dark and light themes are designed to exceed the strictest contrast requirements for normal text.
  • Touch targets meet 44px minimum - buttons, links, dropdown options, and all interactive elements are large enough for comfortable touch interaction.
  • Reduced motion respected - all animations are disabled when the user's operating system says they prefer reduced motion.
  • Page titles update on navigation - every route sets a descriptive document title so screen reader users know where they are.
  • Skip navigation link - a hidden link at the top of every page lets keyboard users jump straight to the main content.
  • Error messages announced - form errors use role="alert" so screen readers announce them immediately without the user hunting for what went wrong.
  • Modals trap focus properly - dialog focus stays inside the modal until it's closed, then returns to the element that opened it.
  • Labels on everything - every form input has a visible label or accessible name. No placeholder-only inputs. Required fields are marked.
  • Breadcrumbs and heading hierarchy - pages have logical heading structure and breadcrumb navigation so users can orient themselves.

Accessibility isn't a feature for a minority. It's the baseline for software that respects the people using it.

🛡️ Security

Letting people participate anonymously doesn't mean letting them run wild. Echoboard takes security seriously at every layer - protecting the people who give feedback, the people who manage it, and the data in between.

  • Spam protection without surveillance - instead of CAPTCHAs (which are inaccessible, annoying, and train someone else's models with your users' labor), Echoboard uses ALTCHA proof-of-work challenges. The browser solves a small puzzle before submitting. Invisible to people, expensive for bots. No third-party scripts, no tracking pixels, no "select all the traffic lights."
  • Encrypted at rest - display names and personal data are individually encrypted with AES-256-GCM. Even with database access, identities can't be read without the encryption key. Lookups work through blind indexes (HMAC-SHA256), so searches never expose plaintext.
  • Admin passwords hashed and timing-safe - bcrypt cost factor 12, timing-safe comparisons that don't leak whether an email exists, and exponential backoff on failed attempts making brute force impractical.
  • Passkeys are phishing-proof - public-key cryptography where the private key never leaves the user's device. Nothing to phish, nothing to leak, nothing to replay.
  • Recovery codes are single-use and hashed - the plaintext is shown once and never stored. The database holds a bcrypt hash and a blind index. Each code works exactly once and expires after 90 days.
  • Persistent token blocklisting - logging out, changing identity, or deleting an account immediately blocks session tokens in the database. A server restart doesn't revalidate revoked sessions.
  • Role-based access enforced server-side - super admins, admins, and moderators have different permissions checked on every request. A moderator can't escalate by calling API endpoints directly.
  • Single-use expiring invite links - team invites are hashed, expire after a configurable window, and claimed inside serializable transactions so two simultaneous requests can't both succeed.
  • Webhooks can't reach internal networks - destination URLs are checked against private IP blocklists, DNS is resolved before connecting to prevent rebinding, and connections go directly to the resolved IP with TLS verification.
  • Security headers everywhere - HSTS with two-year max-age, strict CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy set to no-referrer. The embed widget runs in a sandboxed iframe. Custom CSS is decoded and scanned before storage.
  • File uploads validated by content, not trust - images are checked against magic byte signatures, not just extensions or MIME types. Path traversal is blocked with realpath resolution. Orphaned files are cleaned up if database inserts fail.
  • Per-endpoint rate limiting - every endpoint is individually tuned, from 100/minute for browsing down to 3/15 minutes for recovery code attempts. Not a blunt global throttle.

Security isn't a feature you bolt on at the end. It's a set of choices you make from the beginning about what data to collect (as little as possible), how to store it (encrypted), how to verify identity (without passwords when possible), and how to fail (safely, loudly, and without leaking information). Echoboard makes those choices so you don't have to think about them.

🚀 Getting started

1. Clone and configure

git clone https://git.lashman.live/lashman/echoboard.git
cd echoboard
cp .env.example .env

Open .env in your editor and fill in:

Database password - set POSTGRES_PASSWORD to something random.

Encryption keys - generate five secrets, one for each of APP_MASTER_KEY, APP_BLIND_INDEX_KEY, TOKEN_SECRET, JWT_SECRET, and ALTCHA_HMAC_KEY:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Run that five times, paste each output into the corresponding variable.

Push notification keys - generate VAPID keys:

npx web-push generate-vapid-keys

Paste the public and private keys into VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.

Domain settings - set WEBAUTHN_RP_ID to your domain (e.g. feedback.example.com) and WEBAUTHN_ORIGIN to the full URL (e.g. https://feedback.example.com).

2. Start it

docker compose up -d

That's it. Two containers come up - the app and a PostgreSQL database. Migrations run automatically.

3. Create your admin account

Visit /admin in your browser. Since no admin exists yet, you'll see a setup screen where you pick an email and password. This screen disappears forever after the first admin is created.

4. Set up HTTPS

Passkeys require HTTPS to work. Put a reverse proxy in front of Echoboard. With nginx:

server {
    listen 443 ssl;
    server_name feedback.example.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        client_max_body_size 50m;
    }
}

🔄 Updating

git pull
docker compose up -d --build

Database migrations run automatically on startup. Your data, uploads, and plugins are preserved.

💾 Data and backups

All persistent data lives in directories next to the compose file:

Directory Contents
./data/postgres/ Database files
./uploads/ User avatars and file attachments
./plugins-installed/ Uploaded plugin files and assets

Back these up however you normally back things up. The database can also be dumped with docker compose exec db pg_dump -U echoboard echoboard > backup.sql.

👥 Team system

Echoboard supports multiple team members without anyone needing an email account.

Super admin - the person who set up the instance. Full access to everything. Can invite admins and moderators, manage branding, configure webhooks, install plugins. Cannot be removed.

Admin - can do most things the super admin can, except change branding or install plugins. Can invite moderators. Sees their own invitees in the team list.

Moderator - can respond to posts, change statuses, pin, merge, lock, and manage tags and categories. Cannot configure boards, webhooks, or invite others.

To add a team member: go to Team in the admin panel, click Invite, choose a role and expiry. Share the invite link. The person claims it, picks a display name, and secures their account with a passkey or recovery phrase. No email involved.

Each team member gets a display name and optional team title (like "Product Lead" or "Support") that shows alongside their responses in the public feed.

🧩 Plugin system

Plugins extend Echoboard without modifying the core code. They're zip files uploaded through the admin dashboard - no server restart, no command line.

What plugins can do

  • Add API routes - mounted under /api/v1/plugins/<pluginId>/your-path
  • React to events - get called when posts are created, statuses change, comments are added
  • Store data - a key-value store backed by the database, scoped per plugin
  • Read the database - full read/write access to all models via Prisma
  • Declare config fields - the admin sees a proper form (text, password, number, boolean, select) instead of raw JSON

Plugin structure

A plugin is a zip containing at minimum:

manifest.json
index.js

Optional:

assets/          (static files - fonts, images, CSS)

manifest.json

{
  "name": "my-plugin",
  "version": "1.0.0",
  "description": "What this plugin does",
  "author": "you",
  "entryPoint": "index.js",
  "configSchema": [
    {
      "key": "apiUrl",
      "type": "text",
      "label": "API endpoint",
      "placeholder": "https://api.example.com",
      "required": true
    },
    {
      "key": "apiKey",
      "type": "password",
      "label": "API key"
    },
    {
      "key": "syncInterval",
      "type": "number",
      "label": "Sync interval (minutes)"
    },
    {
      "key": "enabled",
      "type": "boolean",
      "label": "Enable automatic sync"
    },
    {
      "key": "region",
      "type": "select",
      "label": "Region",
      "options": [
        { "value": "us", "label": "United States" },
        { "value": "eu", "label": "Europe" }
      ]
    }
  ]
}

The name must be lowercase alphanumeric with dashes. configSchema is optional - if omitted, the admin sees a raw JSON editor for configuration.

index.js

The entry point must export a descriptor object (ESM export default):

export default {
  // API routes (optional)
  routes: [
    {
      method: "POST",
      path: "/sync",
      async handler(req, reply, ctx) {
        // req and reply are Fastify objects
        // ctx is the plugin context (see below)
        reply.send({ ok: true })
      }
    },
    {
      method: "GET",
      path: "/status",
      async handler(req, reply, ctx) {
        const lastRun = await ctx.store.get("lastRun")
        reply.send({ lastRun })
      }
    }
  ],

  // Event handlers (optional)
  events: {
    async post_created(data, ctx) {
      // data: { postId, title, type, boardId, boardSlug }
      ctx.logger.info({ postId: data.postId }, "new post")
    },
    async status_changed(data, ctx) {
      // data: { postId, title, boardId, from, to }
      if (data.to === "PLANNED") {
        // do something when a post moves to planned
      }
    },
    async comment_added(data, ctx) {
      // data: { commentId, postId, body }
    }
  },

  // Called when the plugin is enabled (optional)
  async onEnable(ctx) {
    ctx.logger.info("plugin enabled")
    // good place for initial sync, setup, etc.
  },

  // Called when the plugin is disabled (optional)
  async onDisable(ctx) {
    ctx.logger.info("plugin disabled")
    // cleanup intervals, connections, etc.
  }
}

Plugin context

Every handler receives a ctx object with:

Property Type Description
ctx.prisma PrismaClient Full database access - query any model
ctx.store PluginStore Key-value storage scoped to this plugin
ctx.config object The plugin's config (set by admin in dashboard)
ctx.logger Logger Structured logger (info, warn, error)
ctx.pluginId string The plugin's database ID

Plugin store

The store is a simple key-value interface backed by the database:

await ctx.store.set("lastSync", new Date().toISOString())
const lastSync = await ctx.store.get("lastSync")
await ctx.store.delete("lastSync")
const all = await ctx.store.list()  // [{ key, value }]

Values are stored as JSON - you can store strings, numbers, objects, arrays.

Available events

Event Fired when Payload
post_created A new post is submitted { postId, title, type, boardId, boardSlug }
status_changed An admin changes a post's status { postId, title, boardId, from, to }
comment_added A comment is posted { commentId, postId, body }

Static assets

Put files in an assets/ directory in your zip. They're served at:

/api/v1/admin/plugins/<pluginId>/assets/<filename>

Allowed file types: CSS, JS, PNG, JPG, GIF, SVG, WebP, WOFF, WOFF2, TTF, EOT, JSON.

Plugin ideas

  • Slack/Discord notifications - post to a channel when new feedback comes in or statuses change
  • Linear/Jira sync - create tickets from high-voted posts, sync status back
  • GitHub issues mirror - two-way sync between boards and GitHub repos
  • Email digest - weekly summary of new feedback and status changes
  • Auto-triage - move posts to categories based on keywords
  • Vote threshold actions - auto-promote posts to "planned" when they hit N votes
  • Gitea/Forgejo sync - create boards from repos (included as example plugin)
  • Analytics export - push feedback data to your analytics pipeline
  • Webhook relay - forward events to services that don't support webhooks natively

Security note

Plugins run with full server access. They can read and write any data in the database, make network requests, and execute arbitrary code. Only install plugins you trust, written by people you trust. This is the same trust model as WordPress plugins, Grafana plugins, or any other self-hosted extension system.

Only the super admin can install, enable, disable, or delete plugins.

🛠️ Development

Prerequisites

  • Node.js 20+
  • PostgreSQL 14+ (or use Docker)
  • npm

Setup

git clone https://git.lashman.live/lashman/echoboard.git
cd echoboard
npm install
cp .env.example .env

Edit .env:

  • Set WEBAUTHN_RP_ID=localhost
  • Set WEBAUTHN_ORIGIN=http://localhost:5173
  • Point DATABASE_URL at a local PostgreSQL instance
  • Generate and fill in the encryption keys (same process as production)

Running

npm run dev

This starts:

  • API server on port 3001 (Fastify + tsx watch)
  • Frontend dev server on port 5173 (Vite with HMR)

The Vite dev server proxies API requests to port 3001 automatically.

Project structure

echoboard/
  packages/
    api/                 Backend (Fastify + Prisma)
      src/
        cli/             Admin creation CLI
        cron/            Scheduled jobs
        lib/             Shared utilities
        middleware/       Auth, security
        plugins/         Plugin loader, registry, context
        routes/          API endpoints
          admin/         Admin-only endpoints
        services/        Encryption, push, webhooks
      prisma/
        schema.prisma    Database schema
        migrations/      SQL migrations
    web/                 Frontend (React + Vite)
      src/
        components/      Reusable UI components
        hooks/           React hooks (auth, admin, toast, etc.)
        i18n/            Translations
        lib/             API client, utilities
        pages/           Route pages
          admin/         Admin panel pages
      public/            Static assets (service worker, embed script)
  plugins/               Example plugins
  docker-compose.yml     Production deployment
  Dockerfile             Multi-stage build

Key architectural decisions

Anonymous-first identity - users get a random token cookie on first visit. No signup wall, no email collection. Passkeys are an optional upgrade, not a requirement.

Field-level encryption - display names and other PII are encrypted with AES-256-GCM before storage. Blind indexes (HMAC-SHA256) enable lookups without exposing plaintext.

ALTCHA proof-of-work - instead of CAPTCHAs (which are inaccessible, annoying, and feed surveillance companies), spam is deterred by making the client's browser solve a small computational puzzle. Invisible to users, hostile to bots.

Plugin descriptor pattern - plugins export a descriptor object instead of directly registering Fastify routes. This lets the system load and unload plugins at runtime without restarting the server.

Granular locking - instead of a single "lock" toggle, posts have three independent locks (post edits, thread/comments, voting). Comments can also be individually locked. This gives moderators precise control without over-restricting discussion.

📄 License

CC0-1.0

This work is dedicated to the public domain. You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission. No attribution required.

Because tools for collective communication shouldn't be enclosed behind intellectual property fences. Take it, use it, make it yours.