Classic web chat server. Rooms, DMs, presence, file sharing, moderation — the 2000s chat shape, built on a 2026 stack.
Built during an Agentic Development Hackathon (18–20 April 2026) by herding AI agents through a disciplined documentation-first ADLC.
git clone https://github.com/Exiliot/agora.git
cd agora
docker compose up --buildThen:
http://localhost:8080– the web apphttp://localhost:3000/health– api health check
That's the whole delivery contract. No cloud provider, no DNS, no certificates to wire up. If it builds on the reviewer's machine, it runs.
First docker compose up --build on a machine with a cold Docker image cache pays an extra 20–40 s pulling node:24-alpine, postgres:16-alpine and nginx:alpine before the application layers build.
The stack binds host ports 3000 (api) and 8080 (web). Both are common defaults (other Node APIs on 3000; Jenkins/Tomcat/assorted HTTP servers on 8080). If either port is already in use the affected container lands in a restart loop – docker compose ps will show it.
Override locally without editing the committed compose file by dropping a docker-compose.override.yml next to it (the override path is already in .gitignore):
# docker-compose.override.yml
services:
api:
ports:
- "3100:3000"
environment:
# Password-reset links are logged to stdout; this must match the host
# port the web container is reachable on, or the link will 404.
APP_BASE_URL: "http://localhost:8090"
web:
ports:
- "8090:8080"A couple of shapes the curl-level tripwires flagged during the delivery-contract smoke:
POST /api/friend-requeststakes{ "targetUsername": "..." }, notreceiverId/userId.POST /api/friend-requests/:id/acceptand/rejecttake no body. They tolerate-H 'Content-Type: application/json'with no-d, and also a literal{}.- Attachment uploads with
Content-Type: text/plainare normalised toapplication/octet-streamby the MIME allow-list – expected, not a bug.
docker-compose.ci.yml enables the dev-seed endpoints the Playwright large-history suite and the XMPP load test rely on. It is not layered into the default docker compose up so the committed demo never exposes the unauthenticated bulk-register surface. To run the e2e suite:
pnpm smokeWhich expands to docker compose -f docker-compose.yml -f docker-compose.ci.yml up --build -d && playwright test.
- Register an account (email + username + password) at
/register. - Create a public room from the sidebar; send a message.
- Open a second browser / Incognito window; register a second user.
- From the second user, browse
/public, join the same room, chat. Messages arrive in real-time on both sides over WebSockets. - On one user, go to
/contacts, search for the other user, send a friend request. - The other user accepts from
/contacts, then opens a DM via the "Message" button. DMs appear in the sidebar and support the same message features as rooms. - Create a private room on one side, send a room invitation by username, accept it on the other. Private rooms don't appear in the public catalogue.
- As owner/admin in a room, click "Manage room" in the right sidebar for members / bans / invitations / settings tabs. Ban a member; they're kicked in real-time.
- Go to
/sessionsto see active browser sessions; log out one and stay signed in on the other.
- Accounts: register, sign-in/out, password-reset (mock email, reset URL logged to stdout), password change, delete account with cascade.
- Sessions: server-side, DB-backed, individually revocable, sliding 14-day expiry.
- Rooms: create/browse/search public, invite-only private, join/leave, owner + admins with promote/demote, ban with read-only history for the banned user, delete with cascade.
- Messages: real-time over WebSockets, send/edit/delete, UTF-8 + multiline + emoji, reply threading, infinite-scroll history, unread counters. Message list is virtualised (
@tanstack/react-virtual) to hold 10k+ messages smoothly. - DMs: open from Contacts, friendship-gated, identical message feature set.
- Contacts: friend requests (send/accept/reject/cancel), unfriend, user-to-user ban with history preserved read-only.
- Presence: in-memory multi-tab state machine with online / AFK / offline states (AFK after 60s of no interaction across all tabs, offline when all tabs close).
- Attachments: upload via
POST /api/attachments(20 MB file / 3 MB image caps), content-addressed disk storage, ACL checked at download against current membership, orphan sweep every 15 min, cascade on room delete. - Moderation: room management modal with members, banned users, invitations, settings tabs.
- XMPP federation (stretch goal): optional two-server Prosody overlay with HTTP-auth bridge into agora's argon2id store, dialback s2s, and a 50-client load test. See below.
Optional Phase 2 overlay per ADR-0005. Spins two Prosody 0.12 instances beside the main stack; both delegate authentication to agora via HTTP so a single set of credentials works for web chat and XMPP clients.
# base stack + XMPP overlay
docker compose -f docker-compose.yml -f docker-compose.xmpp.yml up --build -d
# verify cross-server delivery (ST-XMPP-1 + ST-XMPP-2)
NODE_TLS_REJECT_UNAUTHORIZED=0 node tools/xmpp-federation-test.mjs
# 50-client federation load test (ST-XMPP-3)
NODE_TLS_REJECT_UNAUTHORIZED=0 node tools/xmpp-load-test.mjs 50Observed on the reference setup: 50/50 messages delivered across the s2s link, p50 = 10 ms, p95 = 13 ms. The load-test harness enforces ≥ 95% delivery at p95 ≤ 5000 ms and exits non-zero on miss.
NODE_TLS_REJECT_UNAUTHORIZED=0 is present because Prosody uses a self-signed cert inside the compose network. Direct-TLS c2s on port 5223 (xmpps://), s2s via XEP-0220 dialback. The journal trail lives in docs/journal/ — entries 09, 12, and 14 cover the spike, the SASL wall, and the fix respectively.
Node 24 · TypeScript (strict) · Fastify 5 · Postgres 16 · Drizzle ORM · React 19 · Vite · TanStack Query · Zustand · Tailwind · Biome · pnpm workspaces · Playwright · Prosody 0.12 (optional XMPP overlay).
- Server-side sessions, argon2id hashes, SHA-256 token hashing at rest, rotate-on-password-change, individually revocable. See ADR-0001.
@fastify/rate-limiton all/api/auth/*routes (10/min per IP anon, per-session onpassword-change).@fastify/helmeton the api; nginx carries the SPA-appropriate CSP +X-Content-Type-Options: nosniff+X-Frame-Options: DENY+Referrer-Policy: no-referrer+ a restrictivePermissions-Policy.- WebSocket
subscribeACL gated by the samecanAccessRoom/canAccessDm/userId === conn.userIdhelpers as the HTTP history routes — no client-initiated subscribe slips past membership checks. - WS same-origin check at upgrade; mismatches close 4403.
- Session secret is generated from 48 bytes of
randomByteson boot if the env var isn't provided, with a warning.docker-compose.ymldoes not commit one, so a fresh clone never ships with a known secret. - Postgres is not exposed on the host port. Api reaches db through the compose network; developers who want psql run
docker compose exec db psql -U app -d app. - Attachment mime allowlist + nosniff on downloads;
originalFilenametruncated to 255 bytes. - Accessibility baseline:
:focus-visible, sr-only class, Modal focus-trap + Escape, MessageListrole="log" aria-live="polite", composer labelled, primary nav carriesaria-current, sign-out is a real<button>, skip-to-content link present, AA-compliant contrast on all token pairings.
Everything is journalled. The 2026-04-18 wave entries (…-11-wave-1.md, …-13-wave-2.md, …-15-wave-3.md) cover the first audit cycle; the 2026-04-19 parallel-specialists pass (…-03-parallel-audits.md) plus the closeout (…-06-audit-closeout.md) carry the final state. Capacity estimate after perf-audit closeout: ~400–500 concurrent users on single-node docker-compose. WCAG 2.1 AA: pass with two documented equivalents.
- Product spec — canonical, with stable requirement IDs.
- Per-feature requirements
- Architecture, data model, WebSocket protocol.
- Design system — Claude Design (Opus 4.7) handoff bundle.
- ADRs — immutable decisions that shape everything.
- Journal — decisions, detours, lessons as they happened. Entries are numbered (
01,02, …) so they sort chronologically. - Audits — per-category rolling documents (product, security, performance, a11y, code-quality); the 2026-04-19 round closed or deferred every row with a named revisit trigger, earlier rounds kept inline for trace.
- Demo script — five-minute reviewer walkthrough.
- Retrospective — what the experiment actually was, the final scoreboard, what worked, what didn't.
This repo was built as an explicit experiment in agent-driven development: context first, code second. Every feature goes through brainstorm → requirements → plan → execute → test → verify → journal before a commit lands. See CLAUDE.md and AGENTS.md.
agora/
├── apps/
│ ├── api/ # Fastify backend
│ └── web/ # Vite + React frontend
├── packages/
│ └── shared/ # zod schemas shared between api and web
├── docs/ # spec, architecture, adrs, journal, design, audits
├── tests/e2e/ # Playwright delivery-contract smoke tests
├── tools/
│ ├── prosody/ # XMPP sidecar Dockerfile + config template
│ ├── xmpp-federation-test.mjs
│ └── xmpp-load-test.mjs
├── docker-compose.yml # base stack (db + api + web)
└── docker-compose.xmpp.yml # optional overlay (prosody-a + prosody-b)