Skip to content

Latest commit

Β 

History

130 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Threadline β€” A Real-Time Collaboration Platform

Next.js React PWA Web%20Audio%20API TypeScript Node.js Express MongoDB MongoDB%20Atlas Redis Cloudflare Workers Durable Objects Wrangler Vercel WebRTC STUN%2FTURN WebSocket OAuth2%20%2F%20OIDC JWT Zod Swagger%20%2F%20OpenAPI ReDoc Sentry Argon2 Helmet Pino Framer Motion GSAP Phosphor%20Icons Google%20Fonts Docker Kubernetes Kustomize GitHub Actions GHCR Trivy Vitest Playwright Supertest ESLint Prettier Husky lint-staged ShellCheck Conventional%20Commits TypeDoc Dev%20Containers GNU%20Make GitHub%20Pages Schema.org%20JSON-LD llms.txt npm%20workspaces

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.

Table of contents

Overview

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.

Why Threadline exists

  • 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.md exist 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.

Live deployment

What's included

  • 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-password redirect 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_DSN configured) β€” 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 RTCPeerConnection and 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.

Technology stack

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

How the three services fit together

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
Loading
  • The browser talks to apps/web over HTTPS with a session cookie or PAT.
  • apps/web proxies 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/realtime has 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.md and docs/architecture.md.

Trust model

  • 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_SECRET and the INTERNAL_INGEST_SECRET/PERSISTENCE_SECRET pair. 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.

Onboarding and workspace roles

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 its owner, 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 a member membership. 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 delegated canManageMembers attribute) 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-gated GET /v1/orgs/:orgId/invite endpoint returns it. Viewing or regenerating it is available to owners/admins always, and to plain members only when the organization has opted in via allowMemberInvites.
  • A non-member gets an identical 403 whether 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.

Interface sound

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.

Email delivery

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 Accepted for 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.emailVerifiedAt and the OIDC email_verified claim remain, because the claim is part of the OIDC contract and reporting it as false is 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/redeem takes an email and one code, sets a new password, and revokes every session for the account. The link-based password-reset/request/confirm pair 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.

Engineering principles

  • 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 Repository interface, two implementations. MemoryRepository backs local dev and the real HTTP-level test suite with zero database connection; MongoRepository backs production. Route handlers in apps/api/src/application.ts only ever call the interface β€” this is what lets createApp() 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.ts refuses 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 } = doc looks 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 explicit publicOrganization() 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.

Performance and scaling characteristics

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.

Real incidents found operating this

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 /login measurably ate into /register's budget.
  • Durable events (chat, joins, document edits) never persisting at all, because apps/realtime/wrangler.toml was missing its [vars] block entirely, so the persistence webhook URL was undefined and delivery was silently never attempted.
  • A room-ticket signing mismatch between apps/api and apps/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_ORIGIN pointed at localhost:3000 in a production deployment, silently rejecting every real cross-origin request as a CSRF violation.
  • OIDC_ISSUER set to a URL that included a path, which crashed the entire API at boot β€” every route, not just the OIDC ones β€” because parseOrigin() 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 joinCode at all and the index build failed on duplicate nulls β€” fixed with a one-off backfill, not a rollback.
  • Deploying an unrelated feature branch that happened to be based on an older main silently 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.

Testing and quality gates

  • Every automated suite runs against the actual runtime it targets rather than a mock of it: apps/api's HTTP-level integration suite (supertest against a real createApp() and a real MemoryRepository, 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 small apps/web layer 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/web still 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.ts measures 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:links separately 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 containers job 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 made npm run docker:up impossible while CI stayed green (operations.md).
  • Three git hooks, tiered by cost. pre-commit is fast β€” lint-staged plus a guard for committed secrets, .env files, merge conflict markers, debugger, and focused tests. commit-msg validates the conventional-commit format with the same script CI uses, so local and remote enforcement cannot drift. pre-push runs 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 check
    make check runs 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.

Observability

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.ts runs as the literal first import of src/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.ts for server/edge, instrumentation-client.ts for the browser), plus next.config.ts wrapped with withSentryConfig for optional source-map upload (skipped, not failed, without an org/auth token β€” see Environment variables).
  • Enable it by setting SENTRY_DSN (API) and NEXT_PUBLIC_SENTRY_DSN (web) as environment variables in each deployment target and redeploying β€” no code changes required.

What the interface looks like

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.

Threadline landing page
Landing page

Threadline workspace dashboard
Workspace dashboard

Threadline rooms directory
Rooms List Page

A Threadline room before joining the call, showing the pre-join screen
Room - Before Joining the Call

A Threadline room during a live call, with the video stage and call controls
Room - During the Call

Threadline calendar
Organization Calendar -- Scheduling Page

Threadline organization activity feed
Activity feed β€” durable events across every visible room

Threadline personal profile page
Personal Profile Page

Threadline members
Workspace Members List Page

Threadline account settings
Account settings

Threadline login
Login Page

Threadline register
Register Page

Threadline Swagger UI
API Documentation

Threadline ReDoc
API Documentation (ReDoc)

Note

Full surface-by-surface set (notes, code editor, file transfer, timeline, membership, every settings page, 404): docs/frontend.md.

Project structure

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.

Running it locally

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 dev

make 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_URI set, apps/api uses an in-memory repository β€” zero database setup needed to run locally, but every restart of the API process (including tsx watch restarts on save) clears it.
  • Stop everything with Ctrl+C.
  • Run one service alone: npm run dev:api:local, npm run dev:realtime:local, or npm run dev:web:local.
  • Prefer containers? npm run docker:up starts 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.

Environment variables

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_SECRET must be identical on apps/api and apps/realtime. PERSISTENCE_SECRET (Worker) must be identical to INTERNAL_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.

Commands

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.md holds the conventions they follow.

Deploying it yourself

  • 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/web and apps/api can both run on Vercel, as this project's own deployment does, or apps/api can run on any always-on Node 22 host (Render, Docker, Kubernetes) β€” the same createApp() boots identically either way.
  • apps/realtime deploys to Cloudflare Workers with wrangler deploy; its two shared secrets are set with wrangler secret put and 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.

FAQ

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.

Documentation index

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

License

MIT. See LICENSE for details.

About

🧡 A room-centered workspace for live engineering collaboration - meet now, sketch, pair-program, and chat in real time via WebRTC & WebSockets & Cloudflare Durable Objects, then return to a durable, searchable record. Built with Next.js, Express, MongoDB, Redis, TypeScript, and a full session/PAT/OIDC auth stack for secure, persistent team rooms.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages