Threadline is a room-centered collaboration workspace for engineering teams. A room is both a live session (video, audio, screen share, whiteboard, chat, shared editor) and a durable record of what happened in it β nothing is thrown away when the call ends. The whole system is three independently deployable services, each with a single job, none of them trusting the others' enforcement β that split, and what it costs and buys, is the actual subject of this repository.
- Overview
- Why Threadline exists
- Live deployment
- What's included
- Technology stack
- How the three services fit together
- Trust model
- Onboarding and workspace roles
- Interface sound
- Email delivery
- Engineering principles
- Performance and scaling characteristics
- Real incidents found operating this
- Testing and quality gates
- Observability
- What the interface looks like
- Project structure
- Running it locally
- Environment variables
- Commands
- Deploying it yourself
- FAQ
- Documentation index
- License
Threadline provides a single, unified workspace for a live call and its durable record, with three independent services each owning exactly one responsibility. The system is designed to demonstrate how to structure a realtime product across a serverless web tier, a serverless API tier, and a genuinely stateful coordination tier, with independent authorization checks at every boundary rather than a single shared trust domain.
- What a room is: a live WebRTC call (video, audio, screen share) plus a shared whiteboard, shared notes, a shared code editor, direct peer-to-peer file transfer, and chat β all synced in real time across every connected participant.
- What persists: chat messages, document edits, whiteboard updates, and who joined/left are written into a durable, permission-filtered timeline you can revisit after the call ends. Live-only signals (cursor position, WebRTC offers/answers/ICE candidates, individual whiteboard strokes) are deliberately never persisted β they're too high-frequency to be a meaningful record.
- What it's built to demonstrate: a production-quality realtime collaboration app split across three independent, mostly-serverless runtimes, each owning exactly one responsibility, with independent authorization checks at every boundary rather than a single shared trust domain.
- Three services, three jobs:
apps/webβ Next.js UI and the browser-side WebRTC mesh, deployed to Vercel.apps/apiβ Express API owning identity, attribute-based access control, rooms, calendar, and the durable event record, backed by MongoDB Atlas.apps/realtimeβ one Cloudflare Durable Object per room, owning live presence and WebRTC signaling relay.
- Who this is for: engineers evaluating how to structure a realtime product across a serverless web tier, a serverless API tier, and a genuinely stateful coordination tier, and wanting to see the actual trade-offs (not just the happy path) written down.
- The product problem it solves: most teams run a live call in one tool (a video conferencing app) and keep the record of what happened in a completely different one (a wiki page, a chat thread, a shared doc someone remembers to update afterward). Threadline treats the room itself as the unit that owns both β the live session and its durable history are the same object, not two things a human has to reconcile by hand.
- The engineering problem it's built to explore: a single conventional server handles "many stateless requests" well and "one coordinator per active room, globally consistent, cheap when idle" poorly. Threadline exists to work through that specific mismatch honestly β one plane (
apps/realtime) is intentionally not stateless, and the rest of the system is designed around that fact rather than around pretending everything can be a normal REST service. - Why it's a real, running deployment rather than a diagram: every claim in this repository's documentation β the trust boundaries, the failure modes, the incidents β is backed by a system you can actually open, register an account on, and break in the same ways it was broken during development. Real incidents found operating this and
docs/operations.mdexist because this was operated, not just designed. - What it deliberately does not try to be: a broadcast/streaming platform (no SFU, no one-to-many fan-out β see Architectural trade-offs and current limitations), a general-purpose project management tool, or a fully managed multi-tenant SaaS with billing. It is scoped to small, focused working sessions for a single organization at a time.
- Web app: threadline-rtc.vercel.app β create a real account and try it.
- Swagger UI: threadline-app-api.vercel.app/api-docs β interactive, try-it-out against the live API.
- ReDoc: threadline-app-api.vercel.app/api-docs/redoc β three-pane reference.
- Realtime: threadline-realtime.threadline-dn.workers.dev β the Cloudflare Worker that hosts
RoomDurableObject. There's nothing to browse here: it's a WebSocket/signaling endpoint the web app connects to with a signed room ticket, not a page meant to be opened directly. - What's actually running there: the same code in this repository, deployed with
apps/webandapps/apiboth on Vercel (as two separate projects) andapps/realtimeon Cloudflare Workers β the exact topology diagrammed in Browser-facing topology and detailed indocs/deployment.md.
- Web app (
apps/web):- Registration, login, and password recovery. Signing up no longer locks an account into any one workspace β see Onboarding and workspace roles. There is no email-verification flow, deliberately β see Email delivery.
- A full-screen onboarding step, shown whenever an account has zero workspaces, offering two card-based paths: create a new workspace (becoming its owner) or join an existing one via invite code.
- A real workspace switcher in the sidebar β accounts can belong to more than one workspace, switch between them from a dropdown, and the last-used workspace is remembered (
localStorage) and restored on the next visit. - Organization dashboard: recent rooms, recent activity, a room-creation modal.
- A dedicated rooms directory listing every room the caller can see.
- A live room view with five panels β chat, shared notes, a drawable whiteboard, direct peer-to-peer file transfer, and a durable event timeline β plus a separate shared code editor mode and camera/mic/screen-share controls.
- Keyboard shortcuts for the call controls β M microphone, V camera, S screen share β ignored while typing so they can't fire from a chat message, and listed behind a keyboard button in the call bar rather than left for people to discover by accident. Leaving a call deliberately has no shortcut. Hidden entirely on touch devices, where there is no key to press.
- The landing page and the authentication pages respect an existing session: signed in, the calls to action open the workspace instead of offering sign-up, and
/login,/register,/forgot-password, and/reset-passwordredirect rather than inviting a second sign-in over a live session. - An organization-wide calendar for scheduling sessions, and an org-wide activity feed aggregating durable events across every visible room.
- Organization membership management: a shareable, regenerable invite code (owner/admin-controlled, optionally delegable to members), per-member role changes (owner/admin/member) with a last-admin self-demotion guard, and per-room membership management (granting explicit access to restricted rooms).
- Loading skeletons across every list-driven page (rooms, members, activity, calendar, sessions/tokens/clients) so a still-loading list is never mistaken for a genuinely empty one.
- A dedicated profile page reached from the topbar avatar: identity summary, editable display name and username, and every workspace the account belongs to with its role. A rename updates the workspace chrome immediately rather than waiting for a reload.
- Account settings: appearance/theme, interface sounds, active browser sessions (list and revoke), personal access tokens (create, scope, revoke), and first-party OIDC clients.
- Interface sound feedback for joining, leaving, muting, camera, screen share, chat, and peer presence β synthesised with the Web Audio API rather than shipped as audio files, and switchable off in settings. See Interface sound.
- A custom, branded 404 page rather than a framework default.
- API (
apps/api):- Identity and session management, with three independent authentication surfaces: browser session cookies, personal access tokens, and first-party OIDC.
- Organizations, rooms, room membership, and a calendar resource, all protected by attribute-based access control re-derived on every request.
- Self-service workspace creation and invite-code-based joining (
POST /v1/orgs,POST /v1/join), decoupled from registration β see Onboarding and workspace roles. - Personal access token issuance, scoping, listing, and revocation.
- A first-party OpenID Connect provider implementing Authorization Code + PKCE β no implicit grant, no password grant, no public third-party client registration.
- An internal ingest endpoint that accepts durable events forwarded by the Durable Object, independently re-checking authorization for the event's acting user rather than trusting the forwarding secret alone.
- Error and performance monitoring via Sentry, inert by default (no-ops with no
SENTRY_DSNconfigured) β see Observability. - Fully documented as an OpenAPI 3.1 specification, served live at
/api-docs(Swagger UI) and/api-docs/redoc(ReDoc).
- Realtime (
apps/realtime):- One Cloudflare Durable Object per room, created on demand and addressed deterministically from the room's ID.
- Presence tracking and WebRTC signaling relay (SDP offers/answers, ICE candidates) over hibernatable WebSockets, so an idle-but-connected room costs no ongoing compute.
- SQLite-backed storage for the retry queue behind the durable-event hand-off.
- Retried, alarm-scheduled delivery of durable events back to the API β a failed hand-off doesn't drop the event, it queues and tries again.
- WebRTC client (
apps/web/lib/peer-mesh.ts):- A hand-rolled full-mesh client β one
RTCPeerConnectionand one data channel per remote participant, with no third-party WebRTC SDK. - Camera and microphone streaming, screen sharing, and peer-to-peer chunked file transfer over the data channel.
- No media server anywhere in the stack β once signaling completes, audio/video/screen/file bytes never touch a Threadline-operated server again.
- A hand-rolled full-mesh client β one
| Technology | Role in this project |
|---|---|
| Next.js (App Router) | apps/web framework β client components for every authenticated page, no server-side data fetching for authenticated content |
| React | UI library underneath apps/web |
| TypeScript | Strict typing across all three workspaces (apps/web, apps/api, apps/realtime) |
| Node.js | Runtime for apps/api; also the local dev runtime for tooling |
| Express | REST framework for apps/api, wrapped in a pure createApp() factory so the same code boots identically in tests, Docker, Kubernetes, and Vercel |
| MongoDB (Atlas) | Durable datastore in production, behind the Repository interface β never imported directly by route handlers |
| Redis | Optional ephemeral store for rate-limit windows and session lastUsedAt bookkeeping, behind a Cache port kept deliberately separate from Repository β unset REDIS_URL and both fall back to MongoDB, see ADR-0009 |
| Cloudflare Workers | Hosts apps/realtime's fetch handler, which routes WebSocket upgrades to the right Durable Object |
| Durable Objects | One authoritative, in-memory instance per room for presence and signaling β the one piece of state that can't just be "more instances of a stateless server" |
| Vercel | Hosts apps/web, and in this project's own live deployment, apps/api as well |
| WebRTC | Peer-to-peer audio, video, screen share, and file transfer, mesh topology, no media server |
| WebSocket | Transport for presence and signaling, using Cloudflare's hibernatable WebSocket API |
| OAuth2 / OIDC | Threadline's first-party identity provider, Authorization Code + PKCE only |
| JWT | Signs room tickets (HS256, 120-second single-purpose tokens) and OIDC access tokens (RS256, 15-minute expiry) |
| Zod | Request validation throughout apps/api |
| Swagger / OpenAPI | Live, interactive API documentation generated from apps/api/src/openapi.ts |
| Sentry | Error and performance monitoring for apps/api and apps/web, inert by default β see Observability |
| Argon2 | Password hashing (argon2id) for stored credentials in apps/api |
| Helmet | Security response headers on every apps/api route |
| Pino | Structured, redacting HTTP request logging for apps/api (pino-http), auth/cookie headers stripped before anything is logged |
| Framer Motion | Modal, transition, and onboarding-flow animation across apps/web |
| GSAP | Landing-page scroll and motion effects in apps/web |
| Docker | Local Compose stack (web, API, MongoDB, Wrangler's local Worker emulation) and production container images |
| Kubernetes | Self-hosted production alternative to Vercel/Render for the stateless web and API tier β see docs/containers-and-kubernetes.md |
| GitHub Actions | Multi-stage CI/CD pipeline: format check, lint, typecheck, test, build, and container/Kubernetes validation on every PR, plus image builds published to GHCR on main |
| GHCR | Container registry for published web/api/realtime images, built on main after every check passes |
| Trivy | Vulnerability scan of every built container image in CI, before publish |
| Vitest | Test runner for both apps/api (Node environment, supertest) and apps/realtime (real Workers runtime via @cloudflare/vitest-pool-workers) |
| Playwright | Browser automation used throughout development for live, two-independent-browser-context manual verification β see Testing and quality gates |
| ESLint | Lint gate across all three workspaces, zero warnings allowed |
| Prettier | Formatting gate, enforced in CI and via a pre-commit hook |
graph LR
Browser["Browser"] -->|"session cookie / PAT"| Web["apps/web<br/>Next.js on Vercel"]
Web --> API["apps/api<br/>Express"]
Browser -->|"signed room ticket<br/>WebSocket"| RT["apps/realtime<br/>Cloudflare Durable Objects"]
API --> DB[("MongoDB Atlas")]
API -.->|"optional: rate-limit windows,<br/>session touches β falls back to Mongo"| Redis[("Redis")]
RT -->|"durable event webhook"| API
Browser <-.->|"WebRTC: audio, video,<br/>screen share, files"| Browser
style DB fill:#123524,stroke:#52e0a2,color:#fff
style Redis fill:#2b2140,stroke:#8a63ff,color:#fff
- The browser talks to
apps/webover HTTPS with a session cookie or PAT. apps/webproxies the API through a same-origin rewrite, so the session cookie stays first-party even though the API is a separate Vercel project.- The browser opens a WebSocket directly to the Cloudflare Worker, authenticated by a short-lived, single-purpose signed ticket β not the session cookie.
- The Durable Object never talks to MongoDB. It only hands events to the API over an authenticated webhook, retrying if that fails.
- Redis is the only dashed arrow for a reason: it holds nothing that cannot be recomputed. Every operation behind it falls back to MongoDB when it is unset, unreachable, or slow, and
apps/realtimehas no Redis dependency at all. - WebRTC media (audio/video/screen/files) flows peer-to-peer once signaling completes β never through a server.
- Full breakdown:
ARCHITECTURE.mdanddocs/architecture.md.
- No plane trusts another plane's enforcement β every one independently re-verifies who's allowed to do what, using its own credential, on every request.
- The Durable Object verifies its own signed room ticket (signature and room ID) before accepting a WebSocket upgrade.
- The API re-checks attribute-based access control on every request, including events forwarded by the Durable Object over its internal webhook β a valid shared ingest secret proves the request came from the trusted Worker, not that the acting user embedded in the event may actually write to that room.
- Session cookies, personal access tokens, and OIDC tokens are three genuinely different credential types with different lifetimes and capabilities β a PAT, even one scoped
admin:*, is explicitly barred from session-only routes such as creating another PAT or listing browser sessions. - Two secrets are shared across two different platforms (Vercel and Cloudflare) with nothing in the code enforcing they match:
ROOM_TICKET_SECRETand theINTERNAL_INGEST_SECRET/PERSISTENCE_SECRETpair. Getting either wrong fails silently at runtime β every ticket rejected, or every durable event silently never persisted β not at deploy time. - Full secrets inventory, with a diagram of exactly which secret crosses which boundary and what it authorizes:
docs/security.md.
Registration does not create or join a workspace. A brand-new account has zero organizations until it explicitly does one of two things, both reachable from a full-screen onboarding step (mandatory when an account has no workspace, optional and reachable from the sidebar switcher afterward, for adding another one):
- Create a workspace (
POST /v1/orgs) β the caller becomes itsowner, and a fresh, unique, regenerable join code is generated for it. - Join a workspace (
POST /v1/join) β redeems another workspace's join code and creates amembermembership. Rate limited the same as a password check (10/15min/IP), since it's a caller-supplied secret checked against every organization in the system.
Three roles exist per membership β owner, admin, member β enforced the same way as every other permission in the system: re-derived from the database on every request, never cached or inferred from an ID.
- Only an owner may grant
admin. An admin (or a member with the delegatedcanManageMembersattribute) can manage members and change roles, but cannot escalate anyone to admin. - An admin can self-demote to member only if another admin already exists in the organization; otherwise the API rejects it (
400 last_admin) rather than leaving the workspace with no one able to manage it. This guard only applies to a caller changing their own role β an owner-directed demotion of someone else is exempt, since the owner remains a fallback administrator regardless. - The join code is a genuine secret, never included in any general-purpose response (
GET /v1/auth/me,GET /v1/orgs) β only the dedicated, permission-gatedGET /v1/orgs/:orgId/inviteendpoint returns it. Viewing or regenerating it is available to owners/admins always, and to plain members only when the organization has opted in viaallowMemberInvites. - A non-member gets an identical
403whether the organization they're asking about is real or doesn't exist β the invite endpoints deliberately never leak organization existence through a status-code difference.
Room and call access was already, and remains, gated on organization membership: effectiveRoomRole() returns nothing for a caller with no membership row at all, so an account with zero workspaces has no route into any room regardless of how it got there.
Full design and endpoint-by-endpoint detail: docs/api.md and docs/glossary.md.
Short cues mark the events you can't see happen: joining and leaving a room, muting, camera on/off, starting and stopping a screen share, sending and receiving a message, and someone else arriving or leaving.
They are synthesised at runtime through the Web Audio API, not shipped as audio files. A dozen cues as WAVs would be
a few hundred KB of binaries carrying their own licences, and each would need a network round trip before the first mute
click could be heard. As synthesis the whole palette is a table of frequencies in
apps/web/lib/sound.ts, it costs nothing until something happens, and a cue's character is
tuned by editing a number instead of re-cutting a file.
The palette is one interval set (D major pentatonic) so cues sound related rather than arbitrary, and every cue is
paired β whatever rises to turn something on falls to turn it back off. Levels were not guessed: the module was rendered
through an OfflineAudioContext and measured.
| Property | Measured |
|---|---|
Loudest cue (join) peak |
β14.8 dBFS, 378 ms |
| Realistic 3-cue overlap | β10.5 dBFS, no clipping |
| All 14 cues fired simultaneously | β0.4 dBFS, still no clipping |
| Max sample-to-sample jump | 0.0238 β every envelope is ramped, so no click |
| Sound switched off | no AudioContext is constructed at all |
Repeats of the same cue inside 90 ms are dropped, so a burst of arrivals does not machine-gun. Off is one click in Settings β Appearance β Interface sounds, and the preference persists per device.
Threadline has no built-in transactional email provider. Account recovery does not need one; everything else that would have sent mail is gone.
Anything that would send mail is handed to the webhook named in AUTH_DELIVERY_WEBHOOK β a service you supply. When
that variable is unset, the delivery callback is never constructed and no mail leaves the system.
Two consequences follow, both stated plainly rather than discovered later:
- There is no email-verification flow. It previously existed and silently did nothing: the request endpoint wrote a
token and answered
202 Acceptedfor mail that was never sent. Reporting success for work not done is worse than not offering the feature, so both endpoints and every piece of "Verified / Unverified / Resend link" UI were removed.Credential.emailVerifiedAtand the OIDCemail_verifiedclaim remain, because the claim is part of the OIDC contract and reporting it asfalseis accurate. - Password recovery does not depend on mail. Every account is issued eight single-use recovery codes at
registration, shown once and stored only as hashes.
POST /v1/auth/password-reset/redeemtakes an email and one code, sets a new password, and revokes every session for the account. The link-basedpassword-reset/request/confirmpair still exists for deployments that do configure a webhook, and still only completes there.
Recovery deliberately proves possession of a secret, not knowledge of account facts. publicUser hands a member's
email, username, and display name to everyone else in their workspace, so a "confirm these details to reset" flow would
let any colleague take over any account β including an owner's. A recovery code is ~59 bits of entropy that no one but
the account holder has ever seen.
- Independent re-verification, not shared trust. Every plane re-derives authorization from scratch on every request rather than caching a decision or trusting what an upstream plane already claims to have checked.
- One
Repositoryinterface, two implementations.MemoryRepositorybacks local dev and the real HTTP-level test suite with zero database connection;MongoRepositorybacks production. Route handlers inapps/api/src/application.tsonly ever call the interface β this is what letscreateApp()boot identically on a test runner, Docker, Kubernetes, or Vercel. Rationale: ADR-0003. - Attribute-based access control, computed fresh every time. Every permission decision is derived from the caller's organization role, explicitly delegated attributes, and β for rooms β the room's own visibility and classification, re-evaluated on every request rather than inferred from an ID or cached from a previous check.
- Fail closed on misconfiguration, at boot, not at request time.
apps/api/src/index.tsrefuses to start in production with an insecure or incomplete configuration β short secrets, non-HTTPS origins, a missing signing key β rather than starting with a silently weaker default. See Boot-time validation. - Secrets are single-purpose and never reused across trust boundaries. The value that authorizes a WebSocket connection is not the value that authorizes a durable-event webhook call, which is not the value that signs an OIDC access token. A leak of one does not compromise what the others protect.
- No media server, by design. WebRTC media takes the shortest path available β peer-to-peer β rather than routing through infrastructure Threadline would have to run, secure, and pay for per minute of call time. The cost of that choice (mesh bandwidth scales with participant count) is written down, not hidden: ADR-0002.
- Explicit field whitelists over blacklists when serializing anything from a database driver.
const { secretField, ...rest } = doclooks safe but silently includes whatever else the driver happened to attach to that object β the MongoDB driver mutates an inserted document by adding its own_id, which leaked into two responses this exact way before being replaced with an explicitpublicOrganization()whitelist. The identical, still-unfixed pattern elsewhere in the codebase is tracked, not hidden:docs/roadmap.md. - A unique index on a field added to an already-populated collection is a migration, not just a schema change. It needs a backfill pass before (or atomically with) rollout, or it can take the whole service down at boot β this one did, in production, for real. See the incident that taught this.
- Honesty over polish in the documentation itself. The incidents, known limitations, and roadmap gaps below are real and current, not a marketing summary β see Real incidents found operating this and
docs/roadmap.md.
Concrete numbers, not marketing β what actually happens as usage grows, and where the real ceilings are.
| Dimension | Behavior |
|---|---|
| WebRTC mesh bandwidth per participant | O(n β 1) upload connections for a room of n people β a 6-person room means 5 simultaneous outbound video/audio streams from each participant's browser. Fine at the small-team scale this product targets; a 20-person room would mean 19 outbound streams per participant, which most consumer upload bandwidth can't sustain. See ADR-0002 for the SFU alternative and why it wasn't chosen. |
| Durable Object idle cost | Zero ongoing compute for a room with no active WebSocket connections β Cloudflare hibernates the object between messages (state.acceptWebSocket()), so an idle-but-connected room costs nothing until the next message arrives. A brand-new room's Durable Object is created lazily, on the first request that names its ID. |
| API request latency | Serverless cold start on Vercel for apps/api (a few hundred ms on a cold instance, low single-digit ms once warm) plus one MongoDB Atlas round trip per request that touches the database β every ABAC check re-queries membership rather than caching it, which is a deliberate correctness trade-off (see Engineering principles), not an oversight. |
| Rate limits | Login/register/password-reset: 5β12 requests per window per hashed IP. POST /v1/join (a caller-supplied secret checked against every organization in the system): 10 per 15 minutes. Backed by shared state β an atomic Redis INCR where REDIS_URL is set, an atomic Mongo counter otherwise β never process-local memory, so the limit holds across every serverless instance handling that IP. See docs/security.md, and the row below for what the Redis path costs. |
| Horizontal scaling (Kubernetes) | The stateless web/API tier autoscales 2β10 replicas via HPA on CPU, with a PodDisruptionBudget and a soft topology-spread preference so replicas don't collapse onto one node. apps/realtime doesn't scale this way at all β it isn't stateless, and Cloudflare's Durable Object placement (one instance per room, globally) is the scaling model, not replica count. See docs/containers-and-kubernetes.md. |
| Room-event history | The in-memory timeline broadcast to connected clients keeps the most recent 200 events per room session; the durable RoomEvent collection in MongoDB is unbounded and is what the activity feed and timeline actually read from after a reload. |
Ephemeral cache (REDIS_URL) |
Removes a Mongo write from every rate-limited request and, via a 60-second claim, from every authenticated request's lastUsedAt bump. Measured cost on serverless: a cold Vercel instance serves its first requests before its Redis socket is ready and falls back to Mongo, so the two stores hold separate counts and the effective login limit measured against the live deployment was ~20 attempts rather than 12. Correct, bounded, and per-IP, but genuinely looser β the fix is an HTTP-based Redis with no connection state, not a tuning knob. Unset REDIS_URL and the limit is exactly 12 again. See ADR-0009. |
This deployment has broken for real, more than once. Every incident β what broke, why, how it was found, how it was fixed β is written up in full in docs/operations.md. Twelve so far, briefly:
- A WebRTC negotiation bug where two participants could join the same room and simply never connect, because neither side happened to offer first.
- A Durable Object hibernation quirk where a participant who'd just disconnected kept appearing "present" to everyone else, indefinitely, because the departing socket still counted itself present in the same broadcast that announced its own departure.
- A rate limiter silently sharing one counter across four different endpoints, because of how Express rebases request paths inside route mounts β hammering
/loginmeasurably ate into/register's budget. - Durable events (chat, joins, document edits) never persisting at all, because
apps/realtime/wrangler.tomlwas missing its[vars]block entirely, so the persistence webhook URL wasundefinedand delivery was silently never attempted. - A room-ticket signing mismatch between
apps/apiandapps/realtime, causing every WebSocket connection to be rejected with no visible error beyond "bad response from the server." - A persistence-secret mismatch on the same webhook, after the URL itself was fixed β the two independently configured platform secrets simply didn't match.
- A production web app deployed with zero environment variables, meaning registration and login were completely broken for every real user despite the build succeeding and the site returning
200. WEB_ORIGINpointed atlocalhost:3000in a production deployment, silently rejecting every real cross-origin request as a CSRF violation.OIDC_ISSUERset to a URL that included a path, which crashed the entire API at boot β every route, not just the OIDC ones β becauseparseOrigin()rejects any value that isn't a bare origin.- A seeded first-party OIDC client whose redirect URI never updated when the web app's domain changed, breaking login through that specific flow until the seed logic was made self-healing.
- Deploying the workspace/role rework's new unique index on organizations' join codes crashed every API request in production, because ~22 pre-existing organizations had no
joinCodeat all and the index build failed on duplicatenulls β fixed with a one-off backfill, not a rollback. - Deploying an unrelated feature branch that happened to be based on an older
mainsilently reverted the live API to a previous, still-superseded registration schema for several minutes, because the deploy source was the wrong branch rather than the one actually intended.
- Every automated suite runs against the actual runtime it targets rather than a mock of it:
apps/api's HTTP-level integration suite (supertestagainst a realcreateApp()and a realMemoryRepository, zero mocking of Express or the repository),apps/realtime's Durable Object suite (real hibernatable WebSocket handlers and SQLite storage inside an actual Workers runtime via@cloudflare/vitest-pool-workers), and a smallapps/weblayer covering the WebRTC mesh, the sound engine, and CSS-level layout guards run in real Chromium via Playwright (npm run test:browser, also gated in CI). apps/webstill has no component or page-level test suite. What exists there is unit and layout coverage, not rendering coverage: no page is mounted, no fetch-driven state is asserted. Every UI bug found in this project β the WebRTC mesh initiator bug, the stale-presence-after-disconnect race, the whiteboard off-tab stroke loss β was found through live manual testing against the running app, including genuine two-independent-browser-context sessions (two separate cookie jars, two separately registered real users). This remains the largest testing gap in the repository, written down honestly rather than glossed over:docs/testing.md.- Layout regressions are asserted numerically, not by screenshot.
control-centering.spec.tsmeasures how far a control's contents sit from the centre of its own content box and fails past half a pixel β the check that would have caught the off-centre tab labels and icon buttons, and one verified to fail when the old CSS is restored rather than merely passing against the new. - Documentation is verified mechanically, not by review alone. The TypeDoc build treats validation warnings as errors, so a
{@link}to a symbol that no longer exists or a public signature referencing an unexported type fails CI rather than shipping as dead text.npm run docs:linksseparately checks all 482 relative markdown links and their anchors β including that a heading actually produces the anchor a link claims. External URLs are deliberately not fetched, so a third-party site being briefly down cannot turn a pull request red. - Three gates run on every pull request, not one. The CI/CD pipeline (lint, typecheck, dependency audit, tests, build, container and Kubernetes validation), PR Hygiene (the title, every commit message, a non-empty description), and Documentation (TypeDoc built with strict validation, plus link checking).
- Container images are proven to run, not merely to build. The
containersjob starts the full Compose stack, waits for every healthcheck, and smoke-tests each service over HTTP β including the web tier's same-origin proxy actually reaching the API container. A build-only check answers "did this assemble?" when the question worth asking is "does this start?"; the difference was one latent defect that madenpm run docker:upimpossible while CI stayed green (operations.md). - Three git hooks, tiered by cost.
pre-commitis fast βlint-stagedplus a guard for committed secrets,.envfiles, merge conflict markers,debugger, and focused tests.commit-msgvalidates the conventional-commit format with the same script CI uses, so local and remote enforcement cannot drift.pre-pushruns typecheck and tests. All three are bypassable on purpose; a bypass belongs in the PR description. - The full local check, mirroring what gates a merge in CI:
make check # or: npm run checkmake checkruns every step even after one fails, then prints a summary β so "your formatting is wrong" never hides "your tests are broken". - Full test-suite structure, exactly what's covered, and every known coverage gap:
docs/testing.md.
Both apps/api (@sentry/node) and apps/web (@sentry/nextjs) are instrumented for error and performance monitoring, and both are fully inert with no configured DSN β every Sentry.* call safely no-ops, so nothing about running the app locally or in CI depends on having a Sentry account.
apps/api:src/instrument.tsruns as the literal first import ofsrc/index.ts, ahead of everything else, so Sentry can instrument what loads after it. The final Express error handler reports only its genuinely-unexpected branch β validation errors (z.ZodError) are expected user-input noise and are never sent.apps/web: standard App Router instrumentation (instrumentation.tsfor server/edge,instrumentation-client.tsfor the browser), plusnext.config.tswrapped withwithSentryConfigfor optional source-map upload (skipped, not failed, without an org/auth token β see Environment variables).- Enable it by setting
SENTRY_DSN(API) andNEXT_PUBLIC_SENTRY_DSN(web) as environment variables in each deployment target and redeploying β no code changes required.
All screenshots below are taken directly against the live deployment. The chat and whiteboard screenshots use two independently authenticated browser sessions connected to the same room at the same time β real, live two-person sync, not a mockup.
Room - Before Joining the Call
Organization Calendar -- Scheduling Page
Activity feed β durable events across every visible room
Note
Full surface-by-surface set (notes, code editor, file transfer, timeline, membership, every settings page, 404): docs/frontend.md.
Threadline/
βββ apps/
β βββ web/ Next.js App Router UI (Vercel)
β β βββ app/ routes: landing, auth screens, /app/** workspace
β β βββ components/ React client components
β β βββ lib/ apiFetch() HTTP client, PeerMesh WebRTC client
β β βββ public/ static assets
β βββ api/ Express REST API (Vercel / any Node 22 host)
β β βββ Dockerfile
β β βββ src/
β β βββ domain.ts entity types (User, Room, Session, PAT, ...)
β β βββ repository.ts Repository interface + Memory/Mongo implementations
β β βββ cache.ts Cache port + Memory/Redis implementations (optional, evictable)
β β βββ policy.ts ABAC decision logic
β β βββ application.ts createApp() factory, routes, middleware
β β βββ security.ts hashing, tokens, cookies
β β βββ instrument.ts Sentry.init(), imported first in src/index.ts
β β βββ openapi.ts OpenAPI 3.1 document
β βββ realtime/ Cloudflare Worker + Durable Object
β βββ Dockerfile local Wrangler emulation, not the Cloudflare deploy path
β βββ src/index.ts RoomDurableObject
βββ docs/ Deep-dive documentation
β βββ decisions/ Architecture Decision Records
β βββ screenshots/ Curated UI screenshots used across the docs
β βββ api-reference/ Generated TypeDoc symbol reference (gitignored)
βββ infra/
β βββ docker/ Fixtures consumed by apps/realtime/Dockerfile (dev-only secrets)
β βββ kubernetes/ Kustomize base + overlays
βββ scripts/ Repository tooling β bootstrap, check, doctor, clean, guards
β βββ lib/common.sh Shared bash helpers
βββ .devcontainer/ Dev container: full toolchain + a MongoDB sidecar
βββ .husky/ Git hooks β pre-commit, commit-msg, pre-push
βββ .claude/skills/ Task-specific workflows for coding agents
βββ .github/
β βββ workflows/ CI: pipeline, PR hygiene, docs, labels, stale
β βββ ISSUE_TEMPLATE/ Bug, feature, and documentation issue forms
β βββ PULL_REQUEST_TEMPLATE.md
β βββ CODEOWNERS Security-sensitive paths get an explicit owner
βββ compose.yaml Local Docker Compose stack (web + API + realtime + MongoDB + Redis)
βββ Makefile Task runner β `make help` lists everything
βββ typedoc.json Generated API reference configuration
βββ AGENTS.md Conventions for coding agents (authoritative)
βββ CLAUDE.md Claude Code specifics; defers to AGENTS.md
βββ SECURITY.md Private disclosure process, scope, safe harbor
βββ ARCHITECTURE.md Root-level architecture reference (this repo's single-file overview)
- Full monorepo layout, one level deeper, with what each file is responsible for:
docs/architecture.md.
Prerequisites: Node.js 22 or newer, and npm (this is an npm-workspaces monorepo β one npm install at the root installs all three workspaces).
make setup # checks your toolchain, installs, wires hooks, seeds env files
npm run devmake setup is idempotent and never overwrites an existing env file. The manual equivalent is npm install plus copying apps/web/.env.example and apps/realtime/.dev.vars.example into place. If anything misbehaves, npm run doctor diagnoses the environment β Node version, dependency consistency, git hooks, env files, and whether the four ports are free β and prints the remedy for each finding.
Open http://localhost:3000. npm run dev starts all three services, wired to talk to each other correctly:
| Service | URL | What's running there |
|---|---|---|
| Web | http://localhost:3000 |
Next.js UI, connected to the local API and Worker |
| API | http://localhost:4000 |
Express API, using an in-memory development database |
| Realtime | http://localhost:8787 |
Wrangler's local emulation of the Worker and its Durable Object runtime |
- The local Worker reads its dev secrets from
apps/realtime/.dev.vars(gitignored). - Without
MONGODB_URIset,apps/apiuses an in-memory repository β zero database setup needed to run locally, but every restart of the API process (includingtsx watchrestarts on save) clears it. - Stop everything with
Ctrl+C. - Run one service alone:
npm run dev:api:local,npm run dev:realtime:local, ornpm run dev:web:local. - Prefer containers?
npm run docker:upstarts web + API + a real local MongoDB + a local Redis + the local Durable Object runtime together, so state survives restarts. Full guide:docs/containers-and-kubernetes.md.
The three services need different configuration, summarized here β the full table with every variable, its purpose, and production requirements lives in docs/deployment.md.
| Service | Needs |
|---|---|
apps/web |
NEXT_PUBLIC_API_ORIGIN, NEXT_PUBLIC_REALTIME_ORIGIN (public, baked in at build time); optionally NEXT_PUBLIC_SITE_URL (canonical origin; defaults to the production host), NEXT_PUBLIC_SENTRY_DSN, SENTRY_ORG, SENTRY_AUTH_TOKEN (build-time only, source-map upload) |
apps/api |
MONGODB_URI, OIDC_ISSUER, WEB_ORIGIN, OIDC_PRIVATE_JWK, ROOM_TICKET_SECRET, INTERNAL_INGEST_SECRET, AUTH_DELIVERY_WEBHOOK, AUTH_DELIVERY_SECRET; optionally SENTRY_DSN, TURN_KEY_ID, TURN_KEY_API_TOKEN, REDIS_URL, REDIS_KEY_PREFIX |
apps/realtime |
ROOM_TICKET_SECRET, PERSISTENCE_WEBHOOK, PERSISTENCE_SECRET |
ROOM_TICKET_SECRETmust be identical onapps/apiandapps/realtime.PERSISTENCE_SECRET(Worker) must be identical toINTERNAL_INGEST_SECRET(API) β different names, same value. Nothing in the code enforces either match; getting one wrong is exactly what caused two of the real incidents above.- Every Sentry variable is optional and additive β omitting all of them leaves both SDKs inert (no-op), never a startup or build failure. See Observability.
- Never put MongoDB, Redis, OIDC, room-ticket, email-delivery, or TURN credentials in
NEXT_PUBLIC_*variables β those are shipped to every browser that loads the page. - Local defaults exist for everything except
MONGODB_URI, so local dev needs no secrets configured at all beyond copying.dev.vars.example. Production has no such fallback β see Boot-time validation.
| Command | Description |
|---|---|
npm run dev |
Web + API + realtime together, wired for local development |
npm test |
API's HTTP-level integration suite + realtime worker's Durable Object suite |
npm run typecheck |
tsc --noEmit across all three workspaces |
npm run lint |
ESLint, zero warnings allowed |
npm run format / format:check |
Prettier write / check |
npm run build |
Production build (apps/web only β API and Worker have no separate build step) |
npm run docker:up / docker:down |
Full Docker Compose stack, including a real local MongoDB |
npm run k8s:validate |
Renders both Kustomize overlays without a live cluster |
npm run check |
The full merge gate: format, lint, typecheck, test, build |
npm run doctor |
Diagnoses the local environment and prints the fix for anything wrong |
npm run docs |
Generates the TypeDoc symbol reference into docs/api-reference/ β the published copy is here |
npm run docs:links |
Verifies every relative markdown link and anchor still resolves |
npm run openapi |
Writes openapi.json from the same builder the live service serves |
npm run clean |
Removes build output and caches, reporting what it reclaimed |
make help prints every task grouped by purpose β the Makefile is a discoverable front door to the npm scripts above plus the multi-step workflows (make setup, make check, make ports, make ci).
- Before opening a PR:
make checkβ the same chain CI runs, with every step executed even after a failure so one broken step doesn't hide another. Details:docs/testing.md. - Contributing a change? Start with
CONTRIBUTING.md. - Working with a coding agent?
AGENTS.mdholds the conventions they follow.
- The API validates every mandatory production setting at boot β Atlas connection, HTTPS-only origins, a stable RSA signing key, separate room-ticket/ingest secrets, an authenticated email-delivery webhook. It refuses to boot half-configured.
- Generate the OIDC signing key once:
npm run generate:oidc-key --workspace=@threadline/api. Rotating it invalidates every OIDC token issued under the old key, so it isn't done casually. apps/webandapps/apican both run on Vercel, as this project's own deployment does, orapps/apican run on any always-on Node 22 host (Render, Docker, Kubernetes) β the samecreateApp()boots identically either way.apps/realtimedeploys to Cloudflare Workers withwrangler deploy; its two shared secrets are set withwrangler secret putand must match the corresponding API values exactly.- Zero-cost preview path (free tiers of Vercel + MongoDB Atlas + Cloudflare, no domain purchase): see
docs/deployment.md. - Self-hosting the stateless web/API tier on Kubernetes instead of Vercel/Render, while Cloudflare remains the production owner of room Durable Objects:
docs/containers-and-kubernetes.md. - Exactly which URLs this project's own live deployment runs at, and how that maps onto the general deployment guide:
docs/deployment.md.
Is this used by real teams, or is it a demo? It's a real, running deployment β not a multi-tenant SaaS with paying customers, but not a static demo either. Registering a real account on the live deployment creates a real workspace, backed by the same production database and the same code in this repository. "Production-ready" here means correctly designed and genuinely operated (real incidents, real trust boundaries, real test coverage where it exists), not "battle-tested at scale with a support team."
Why MongoDB instead of a relational database?
The data model β users, rooms, memberships, a growing durable event timeline β is document-shaped, and every read is already scoped by a single indexed ID (organization, room, or user), not a cross-table join. The Repository interface (ADR-0003) means this choice isn't load-bearing either way β swapping the datastore touches one file, not the route handlers.
Why a first-party OIDC provider instead of Auth0, Clerk, or NextAuth? Partly to build the actual flow (Authorization Code + PKCE, JWKS, token rotation) rather than configure someone else's, and partly because a third-party auth platform is one more service in the exact trust model this repository is about being honest regarding β see Trust model.
Why Cloudflare Durable Objects instead of Redis/Ably/Pusher for presence? Those solve "many stateless servers agree on shared state" by adding a coordination service Threadline would have to run, operate, and pay for. A Durable Object gives one authoritative, in-memory instance per room natively, with no separate service and no consistency protocol to write by hand. ADR-0001 has the full tradeoff against that alternative.
That answer is unchanged by apps/api optionally using Redis for rate-limit counters (ADR-0009) β a counter has no owner to elect, which is the entire reason presence and a counter get different answers. apps/realtime has no Redis dependency and cannot have one: workerd has no Node TCP socket.
Can I run this without Vercel or Cloudflare?
apps/web and apps/api can run anywhere Node 22 runs β Docker, Kubernetes, bare metal β see docs/containers-and-kubernetes.md. apps/realtime genuinely cannot: it's written against the Durable Objects API, which is Cloudflare-specific, and there's no portable equivalent without rewriting the presence/signaling layer against a different coordination primitive entirely.
What happens to an in-progress call if apps/api goes down?
Nothing, live β chat, presence, and WebRTC signaling all keep working, since none of that path touches the API. New room creation, login, and durable-event history reads fail. Full breakdown: Failure behavior.
How much does this cost to run? The zero-cost public preview path runs on free tiers of Vercel, MongoDB Atlas, and Cloudflare Workers, no domain purchase β real limits apply (cold starts, free-tier caps), but genuinely $0. This project's own live deployment runs this way.
Why is there no component test suite for the frontend?
There is some apps/web coverage β the WebRTC mesh, the sound engine, and CSS-level layout guards that run in real Chromium β but nothing that mounts a page or asserts fetch-driven state. That remains the largest testing gap in the repository, named rather than hidden: see Testing and quality gates for exactly what it covers and what catches bugs instead.
| Document | Covers |
|---|---|
ARCHITECTURE.md |
Root-level architecture reference β every plane, every trust boundary, every major flow |
docs/architecture.md |
System topology, monorepo layout, full ER diagram, request-lifecycle and event hand-off sequence diagrams |
docs/frontend.md |
apps/web route tree, WorkspaceGate, shell composition, theme system, HTTP client, component inventory |
docs/api.md |
REST endpoint reference, the three auth surfaces, ABAC policy, OIDC Authorization Code + PKCE flow |
docs/realtime.md |
Durable Object internals, WebSocket protocol, WebRTC mesh negotiation, screen sharing, known limitations |
docs/security.md |
Trust boundaries, secrets inventory, session/PAT/OIDC token lifecycle, rate limits, CSRF |
docs/testing.md |
Test suite structure, what's actually covered, known coverage gaps |
docs/glossary.md |
Alphabetical reference for every domain term used across these docs |
docs/troubleshooting.md |
Real problems hit building and operating this, with fixes |
docs/roadmap.md |
Known gaps, honestly β what's not done and why |
docs/decisions/ |
ADRs for the major decisions behind this design |
docs/deployment.md |
Production deployment across Vercel, Cloudflare, and Atlas; zero-cost preview setup |
docs/containers-and-kubernetes.md |
Docker Compose local stack and Kubernetes production deployment |
docs/operations.md |
Runbook: health checks, incident triage, full record of every real incident |
docs/releases.md |
How releases are cut, what each one contains, and how to verify one |
CONTRIBUTING.md |
PR process, coding conventions, what gates a merge |
SECURITY.md |
Private vulnerability disclosure, scope, response timeline, safe harbor |
AGENTS.md |
Conventions and invariants for coding agents β the authoritative version |
scripts/README.md |
What each repository script does, and the rules for adding one |
CODE_OF_CONDUCT.md |
Community standards, and where candid technical disagreement sits inside them |
| Generated API reference | Symbol-level TypeDoc for all three services, published from main. Rebuild locally with npm run docs |
MIT. See LICENSE for details.