Echoboard

License: CC0 Docker Anonymous first No tracking WCAG 2.2 AAA

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.

--- ## 🌍 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. ## 🗳️ 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 ## 🚀 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. ## 🔒 Identity and privacy Echoboard treats identity as a spectrum, not a binary: **Anonymous** (default) - a random token stored in a browser cookie. Zero friction, zero data collection. If cookies get cleared, the identity is gone. **Recovery code** (optional) - a six-word phrase the user saves somewhere. Lets them get back to their anonymous identity if cookies are lost. Single-use, expires after 90 days. **Passkey** (optional) - WebAuthn biometric or device PIN. The strongest option - works across devices, no password to remember or leak. The private key never leaves the user's device. At every tier, the user's display name is encrypted at rest. Blind indexes allow lookups without exposing the plaintext. There is no email field, no phone number field, no "real name" field. The person decides how much of themselves to share. ## 🧩 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.