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
make devOpen http://localhost:8080. The example Compose configuration supports
anonymous browsing. Replace its placeholder Foundation URL and handoff key
before testing DSA sign-in.
The checked-in PostgreSQL credentials and handoff key are development-only. Compose binds PostgreSQL and the backend host port to loopback only, and local production deployment is unsupported.
Run make help for common dependency, development, build, and verification
commands. The only supported local Compose release/replacement entrypoints are
make stack-release and make stack-retry; both route backend replacement through
the guarded protocol. Raw external docker compose is bypassable, unsupported as
release evidence, and is not intercepted. Port overrides can be passed directly, for example
make stack-release BACKUP=backups/before-release.dump APP_PORT=18080 POSTGRES_PORT=55438.
For hot-reload development, start PostgreSQL and both development servers with:
make devmake dev is separate from the release path and 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. It is the sole moderator authority source: each privileged repository rechecks the configured DID and suspension state from its already locked actor row. There is no role table, grant, mutable RBAC, or role-management UI.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.
- Authors may reversibly withdraw and restore a topic aggregate or their own
non-first reply; withdrawal is not deletion and original text remains stored.
A withdrawn topic is concealed unless a public nonwithdrawn third-party reply
retains its structure; retained titles/bodies are fixed tombstones. Moderators
can inspect moderator-hidden content where authorized but cannot restore author
withdrawal state. Public v1 response shapes are unchanged. There is no migration
014, hard deletion, category deletion, report UI, or moderator author-withdrawal UI. Migration013and the moderator category-administration UI are implemented. - Lifecycle PUT/DELETE attempts share independent process-local limits of 12 per IP, 8 per session, and 8 per user per 60 seconds after Origin, authentication, and suspension checks. They therefore require the single-backend lease described below.
GET /api/v1/moderation/operationsis a private, read-only, active configured moderator endpoint. It returns a strict minimized mixed legacy/community ledger withCache-Control: private, no-store; it has no detail endpoint. Suspended configured moderators are not active for this route.- Process-local limits are safe only while this one backend process holds its dedicated PostgreSQL advisory-lock session for its entire lifetime. A second backend is rejected; lock-session loss closes/exits the backend. Distributed or multi-instance deployment requires replacing both the limiter and singleton model.
- Supported guarded deployment is local Compose-only repository replacement, not production deployment support. Immediately before the first compatibility-raising write, create a named digest-bound backup matching that database; deploy only a verified immutable image. Once compatibility is raised, an old image is rejected. Ordinary rollback is a forward fix. Emergency use of an old image first restores that exact pre-write database backup. Migrations are forward-only; no ordinary DB/schema rollback is claimed. Production deployment remains unsupported until a production target exists.
- Configured active moderators administer the catalog at
/moderation/categories. Authority is configuration-only (MODERATOR_DIDS); category IDs and slugs are immutable, with no mutable RBAC. - The catalog uses
Cache-Control: public, max-age=0, must-revalidate. Archived categories remain listed and readable. Only new-topic creation is blocked; replies, edits, lifecycle actions, moderation, reactions, search, and direct links retain their existing policies. - Category administration deliberately excludes hard deletion, reassignment, slug rename, category merge, and role management. Process-local privileged budgets retain the single-backend requirement described above.
- Immutable category ID/slug and no-delete guarantees are application/API contracts under the trusted database runtime role. They do not claim to defend against a privileged SQL/DDL operator. Separating runtime and migration database roles is a future production prerequisite.
pnpm lint
pnpm typecheck
pnpm test
pnpm build
pnpm test:postgres
pnpm test:e2e
pnpm test:release-safetytest: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 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 cursors require an exact current anchor 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.
- Authors can withdraw/restore their topic aggregate or own non-first replies; hidden and withdrawn targets are not editable or reaction-eligible. Author withdrawal preserves stored text and revisions, while public retained structure exposes only the fixed tombstone text.
- 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, 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.
Before the first changed category write, make stack-release creates a matching
format-3, digest-bound backup for the exact immutable image. Once that write raises
the database floor to 13, a maximum-12 image is rejected and a maximum-13 image is
accepted. Recovery is forward-fix only unless the exact pre-category-write backup is
restored before emergency use of an old image. The guarded local-only Compose path has
no skip or force option and never recreates PostgreSQL, volumes, or unrelated services.
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.
Local release starts and backend replacements must use the guarded path; raw external
docker compose commands are unsupported, bypassable, cannot satisfy release evidence,
and are not intercepted. The initial guarded
command builds an exact image, stops and bootstraps backend, creates its own named
image-bound format-3 backup, validates and activates it, then starts backend. Retry
uses the exact artifact and image and never rebuilds. If an interruption leaves a
renamed dump without its manifest, retry reconstructs the canonical manifest only
when exactly one current committed target-13 candidate has that digest. It deletes a
dump only when its digest has zero binding rows. A target-12 retirement first
publishes a durable exact .retire.json intent marker; a committed supersession is
cleaned up only with that matching marker, while an unmarked or mismatched
supersession preserves every artifact for investigation. The guarded path checks the exact image labels, compiled
metadata, and live database floor after bootstrap and before activation:
make stack-release BACKUP=backups/before-release.dump
# only after a failed start or interrupted release:
make stack-retry BACKUP=backups/before-release.dumpThe guarded command replaces only backend; it never recreates PostgreSQL, volumes,
or unrelated services. Production remains unsupported until a production target exists.
After the first committed category write raises the database minimum to 13, a
maximum-12 image is rejected and a maximum-13 image is accepted: recover by a forward
fix, or restore the exact named pre-category-write backup before emergency old-image use. There
is no ordinary database or migration/schema rollback path. Run only one application instance; loss of the
singleton lock intentionally forces a bounded nonzero shutdown.