Selected work

Case study / Real-time architecture / authorization / data

Concord

How do you make live conversation feel immediate while keeping access tied to stored relationships?

A community chat application that keeps identity, membership, message history, and live updates on explicit server boundaries.

Project type
software
Contribution
Implemented server routes, data relationships, and live update flow
Context
Independent full-stack project
Illustrated Concord data model: a community contains members and channels, and channel messages relate to the member who sent them
Data-model illustration based on the public Prisma schema.

Key decision: Resolve the signed-in user on the server and query stored membership or conversation participation before reads, writes, SSE subscriptions, and LiveKit token creation. Channel message deletion also checks author or manager role.

Start here

Overview

The engineering problem

A browser can supply any channel or conversation ID. Sending a message, reading history, or opening a live stream therefore needs a fresh server-side check against the user's membership or participation.

Contribution

I brought Next.js routes, Clerk identity, Prisma relationships, and an SSE delivery path together around that access rule.

What exists now

Chat operations check stored membership or participation before accessing messages. History loads in cursor pages, and authorized open streams receive live updates from the in-process hub. The public tests cover focused helpers.

Inspect the work

Engineering detail

Constraints

  • Chat needs server-to-browser updates, while writes already use ordinary HTTP requests.
  • History requests must remain bounded as conversations grow.
  • UploadThing handles attachments. LiveKit rooms are optional and require separate credentials.

Architecture at a glance

The browser reads and writes through Next.js routes. Those routes resolve Clerk identity, check Prisma membership or conversation records, persist chat in PostgreSQL, and publish updates to an in-process hub. An authorized SSE route streams those updates back to subscribers. UploadThing and optional LiveKit sit beyond the application boundary.

Concord request and update paths

Browser requests enter Next.js routes, which check Clerk identity and database relationships before reading, writing, or subscribing. PostgreSQL stores chat history. An in-process event hub fans out SSE updates. UploadThing and optional LiveKit are external services.

Text explanation

  • Browser: Sends HTTP reads and writes, loads cursor pages, and listens through EventSource for chat updates.
  • Next.js routes: Handle mutations, history, SSE subscriptions, uploads, and media token requests.
  • Access checks: Resolve the Clerk user to a profile, then query membership, role, channel, or conversation relationships before sensitive operations.
  • SSE event hub: Holds channel and conversation listeners in process memory and pushes events to authorized open streams.
  • Clerk: External identity provider. The app still checks its own stored permissions.
  • PostgreSQL: Prisma-backed profiles, memberships, channels, conversations, and paginated messages.
  • Media services: UploadThing receives authenticated uploads. Optional LiveKit receives access tokens for permitted rooms.

Connections: Browser to Next.js routes (HTTP requests); Next.js routes to Browser (SSE updates); Next.js routes to Access checks (authorize each operation); Access checks to Clerk (identity); Access checks to PostgreSQL (relationship checks); Next.js routes to PostgreSQL (persist and page history); Next.js routes to SSE event hub (publish and subscribe); Next.js routes to Media services (upload and room access).

Boundaries: Client; Next.js application; Services + data.

Annotations: In-process fan-out.

Key decisions

Decision 01

Where should a chat request get its authority?

A client can alter server, channel, member, or conversation IDs. Clerk proves who signed in, but does not establish access to a particular record.

Selected
Resolve the signed-in user on the server and query stored membership or conversation participation before reads, writes, SSE subscriptions, and LiveKit token creation. Channel message deletion also checks author or manager role.
Alternatives
Trust client-supplied IDs after page navigation; Rely on authentication alone for route access
Trade-off
Routes repeat relationship queries, adding database work and some duplicated checks.
Consequence
Permission decisions sit beside the data operation. Changing a URL or request body alone does not grant access.

Decision 02

How should chat updates reach connected browsers?

Message writes already use POST and PATCH routes. Live updates only need to flow from server to browser.

Selected
Publish channel or conversation events to an in-process hub and deliver them through an authorized Server-Sent Events route. EventSource reconnects. The client polls history while disconnected.
Alternatives
Run a custom WebSocket server; Poll continuously as the primary delivery path
Trade-off
Open SSE streams need a persistent Node.js process, and the memory hub cannot share events across instances.
Consequence
The single-instance design is simple for this project's local and bounded scope. Horizontal scaling would need a shared broker behind the publish and subscribe interface.

Decision 03

How should conversations and history stay consistent as chat grows?

A user can belong to several communities, two users can open a direct conversation concurrently, and loading all prior messages at once would grow with history.

Selected
Model messages under server memberships and channels or conversations in Prisma. Store direct-message pairs in canonical order with a unique constraint. Return history in pages of 25 using message ID cursors.
Alternatives
Create conversations without a unique pair constraint; Load complete message histories in one request
Trade-off
The model needs explicit relations and cursor handling, and page order depends on stored creation times.
Consequence
Concurrent openings converge on one conversation record, and clients request older history only when needed. Soft-deleted messages retain their place in the sequence.

Reliability and quality

Focused tests
Vitest covers permission helpers, canonical conversation creation, validation, and event hub delivery. These are unit-level checks, not end-to-end authorization tests.
CI gates
GitHub Actions installs locked dependencies, audits production dependencies at high severity, then runs lint, TypeScript checks, tests, and a production build.

Trace the implementation

What next

A shared broker is the first architectural change for multiple application instances. End-to-end permission tests would add evidence at the route boundary.

Scope of the evidence

Limitations

  • The event hub is held in one Node.js process. Two instances would not share subscriptions or publications. A shared broker and an appropriate persistent hosting topology are required to scale it horizontally.
  • The SSE route checks permission when a stream opens. The current code does not actively end that stream if membership changes while it remains connected.
  • The repository does not document a public production deployment or measured throughput.
  • Request rate limiting and automated cleanup of replaced or deleted UploadThing objects are not implemented in the public repository.