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 ```bash 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`: ```bash 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: ```bash 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 ```bash 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: ```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 ```bash 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//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 ```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`): ```javascript 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: ```javascript 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//assets/ ``` 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 ```bash 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 ```bash 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.