A same-origin DSA community forum built with TypeScript, Fastify, React, and PostgreSQL. The MVP supports anonymous browsing, DSA identity handoff, topics, replies, author editing with immutable revision history, rate limits, emergency moderation, private in-app notifications, and private follows with an activity feed.
- Node.js 22+
- pnpm 10.33
- Docker with Compose (for the local database and integration gate)
pnpm install
docker compose up --buildOpen http://localhost:8080. The example Compose configuration supports
anonymous browsing. Replace its placeholder Foundation URL and handoff key
before testing DSA sign-in.
Run make help for wrappers around common dependency, Compose, build, and
verification commands. Port overrides can be passed directly, for example
make stack-up APP_PORT=18080 POSTGRES_PORT=55438.
For hot-reload development, start PostgreSQL and both development servers with:
make devmake dev binds all services to loopback. Its preferred ports are PostgreSQL
5432, backend 8080, and Vite http://127.0.0.1:5173; if another app owns a
default, it selects the next free ports and prints the resulting URLs. If this
checkout's Compose stack is running, it stops those verified containers without
removing them or their data before starting the hot-reload environment. Explicit
POSTGRES_PORT, DEV_BACKEND_PORT, and DEV_WEB_PORT overrides fail closed if
occupied (for example, make dev DEV_WEB_PORT=15173). Ctrl-C stops both hot
servers but leaves this checkout's PostgreSQL 17 container and named volume for
reuse. Run make dev-stop to stop only that database container; it does not
remove the container or volume.
See .env.example for all variables.
APP_ORIGINis the exact canonical public origin. Production requires HTTPS.FOUNDATION_BASE_URLis the pinned HTTPS DSA identity service origin.HANDOFF_ENCRYPTION_KEYis exactly 32 random bytes encoded as base64.MODERATOR_DIDSis a required, comma-separated allowlist of valid DIDs.TRUSTED_PROXY_CIDRSis required in production and must list only the TLS proxies allowed to supply forwarding headers. Keep the application port private behind those proxies.- Development authentication is available only on a non-production loopback
origin. Enable it with
DEV_AUTH_ENABLED=trueand set one server-selectedDEV_AUTH_DID; that DID cannot be a moderator. - Member profiles have an optional display name, plain-text bio, and up to three canonical HTTPS links. Profile links are unverified user-generated destinations: the forum never fetches, resolves, previews, or otherwise endorses them.
The application enforces a single running backend instance with a PostgreSQL advisory lock because rate limits are process-local. A second instance fails startup, and loss of the lock connection stops the server. Distributed or multi-instance deployment requires replacing the limiter and singleton model.
pnpm lint
pnpm typecheck
pnpm test
pnpm build
pnpm test:postgres
pnpm test:e2etest:postgres builds twice, runs the built migrator against a fresh PostgreSQL
17 database, verifies the seed and migrations, executes PostgreSQL integration
tests, and tears down its Compose resources. It uses host port 55437 by
default; override with POSTGRES_PORT if needed.
test:e2e builds the runtime image in an isolated Compose project, starts the
full stack on ports 18080/15432 by default, and runs a Chromium plus axe
accessibility smoke test. It seeds three disposable hashed sessions directly in its
isolated PostgreSQL database, verifies reply/reaction inbox delivery and private
follow/activity and Following-list concealment and restoration, and fails on browser
console/page/request errors or browser requests outside the app origin. Install its browser once with
pnpm exec playwright install chromium. Override the ports with
E2E_APP_PORT and E2E_POSTGRES_PORT.
Both integration scripts use dedicated Compose project names and remove only their own disposable volumes.
- Anonymous category, topic, and cursor-paginated post browsing
- Anonymous full-text search over current visible topic titles and post bodies, grouped by topic and ranked with title matches boosted; search results use best-effort cursor pagination while edits or moderation can change rank or visibility
- Identity-only DSA PKCE handoff and revocable server-side sessions
- Transactional topic/first-post creation and replies
- Author-only title and post-body editing with public, cursor-paginated, append-only revision history
- Edits do not change topic activity ordering. Hidden content is not editable, and its revision history is available only to configured moderators.
- Plain-text titles and posts; no rich text or attachments
- Per-IP, session, and user write limits plus login limits
- Deployment-allowlisted moderation: hide, lock, pin, and suspend
- Append-only moderation audits and public activity that excludes hidden posts
- Fixed post reactions: Solidarity, Useful, and Insightful. A signed-in, non-suspended member may select each kind once on a visible post, including any combination of the three. Visible posts expose public counts and the current viewer's selections; signed-out and suspended viewers are read-only, and hidden content cannot be reacted to.
- Reaction PUT and DELETE operations are idempotent. Counts are derived from reaction rows (not cached); a newly selected reaction by another member creates one private notification and reactions do not affect ranking.
- Reactions have an independent fixed-window budget of 60 requests/minute per IP and 40 requests/minute per session and user. The single-instance/proxy constraints above apply to this in-process budget too.
- Public DID-linked profiles show the optional name, plain-text bio, and safe external
links; profile owners replace their profile using versioned stale-form protection.
Configured moderators can hide or restore profiles with an audit trail. Hidden
profiles return public
404. - Private notifications retain rows for exactly 90 days: cleanup deletes only rows whose creation time is strictly older than the 90-day boundary. A hidden topic or post disappears from its recipient's inbox and unread badge on the next authoritative page/count refresh; restoring it restores its prior notification and unread state. Retention-lag objective: one hourly scheduling interval plus active drain time.
- Notification actors are presented as
display name (DID)when a visible profile name exists, otherwise as their authoritative DID alone. - Following is private: only its owner can read outgoing Following data, relationship state, and derived activity. Following never notifies the followed member, and incoming followers remain undisclosed. Hidden followed profiles retain their edges but are excluded from Following rows and counts (as well as relationship state and feed activity) until restored.
Not included: deletion, avatars, private messages, category or role administration, email/push notifications, realtime updates, federation, external search, richer profile moderation, or upstream identity credentials. There is no public graph, recommendations, materialized counters, follower list/count, or incoming follower disclosure. Search does not index revision history, fetch or preview external content, provide saved searches, or notify users. Arbitrary emoji reactions, reaction ranking, and cached reaction counters remain deferred.
If all moderators become unavailable or suspended, update MODERATOR_DIDS with
a fresh trusted DID, restart the single backend instance, authenticate as that
identity, and restore the affected account. Configuration remains the
break-glass authority; there is no role-management UI in the MVP.
Back up PostgreSQL before deployment and before applying new migrations. Run only one application instance, deploy the new image before startup migration, and retain the previous image and database backup for rollback. Loss of the singleton lock intentionally forces a bounded nonzero shutdown.