Architecture

On this page

The backend is a Bun and TypeScript monorepo in dembrane/platform: five apps built from packages in three layers, on PostgreSQL only. The frontend is one React and Vite codebase in dembrane/frontend.

This page is the map. The source of truth is dembrane/platform/README.md for the apps and the layer rule, and dembrane/platform/PACKAGES.md for every package.

The stack

Concern Library
HTTP Hono
Database Drizzle ORM with postgres.js
Jobs and schedules DBOS, on Postgres
Sign-in and sessions Better Auth
Language models The Vercel AI SDK with the Google Vertex provider
Agent access The MCP SDK

PostgreSQL is the only stateful service the platform itself needs. There is no Redis, no Directus service, no Python process and no separate agent service. Audio and files live in S3-compatible object storage.

The Bun version is pinned in dembrane/platform/.bun-version.

The five apps

apps/ holds the programs that deploy. Each has a src/main.ts that reads config, wires packages together and starts.

App What it does
api The HTTP server. It mounts every namespace’s routes and answers the dashboard, the portal and outside agents.
worker Runs queued jobs and schedules: the audio pipeline, analysis, reports, the assistant, emails, billing timers.
media The ffmpeg service. It probes, converts, splits and merges audio, one job per instance.
web Serves the built dashboard and participant portal, and forwards /api to the API.
migrate A one-shot job that runs before every rollout: schema migrations, the queue schema, database grants, the default verification topics.

Each app builds to one compiled binary (bun build --compile) on a distroless base image. The media image adds a static ffmpeg and ffprobe. The web image also holds the built frontend.

An app loads only the config sections it reads. The worker never gets the auth secret. The media app has no database, no bucket credentials and no secrets: callers hand it presigned URLs to read from and write to.

The three layers

A dependency points down only.

  1. Apps may import anything.
  2. Namespaces are product areas, such as projects, conversations, chats, reports and billing. Each owns its routes, jobs, storage and business rules. A namespace imports capabilities. It imports another namespace only when its package.json lists that namespace under dembrane.allow with a reason.
  3. Capabilities are building blocks with no business rules, such as config, db, auth, access, queue, storage and llm. A capability never imports a namespace or an app.

bun run packages check enforces the rule. It is part of bun run check and of CI.

PACKAGES.md lists every package with one line each, and every allowed import between namespaces with its reason. In release 3.1.0 that is 24 namespaces and 20 capabilities. The file is generated by bun run packages write from each package’s package.json and from the imports.

Two names are easy to confuse. account is the signed-in user’s own account: profile, onboarding, invites, email. accounts is customer accounts as the dembrane team runs them.

Inside a namespace the pattern is the same everywhere: routes.ts returns a Hono router, its handlers call service.ts, and the service calls storage.ts. A small namespace such as packages/webhooks/src is a good first read.

One frontend, two surfaces

dembrane/frontend builds once and serves two surfaces: the dashboard for hosts and the portal for participants.

How a request flows

The order is set in apps/api/src/app.ts.

  1. Correlation middleware gives the request an id and a logger.
  2. The caller’s address is resolved once, with the environment’s trusted proxies.
  3. Secure headers are added.
  4. CORS applies to /api/*. The allowed origins are the dashboard URL and the portal URL only, with credentials.
  5. /api/auth/* goes to the Better Auth handler.
  6. The session middleware reads the session (cookie or bearer token) once and sets the principal: appUserId, directusUserId, isStaff. It never rejects. A route that needs a user calls requireUser, which answers 401 with the code auth.session_expired.
  7. The namespace’s router handles the route. The service checks the caller with projectFor or workspaceFor from @dembrane/http, which take a policy name. See roles and policies in code.
  8. Every error becomes one JSON body: { detail, code, params, action }.

Route paths were kept from the previous API: /api/*, /api/v2/* and /api/v2/bff/*. The legacy-shape package keeps the response shapes and the number and time formats the old API produced.

Identity says who someone is. What they may do is decided in packages/access. Sign-in itself is covered in authentication. A participant has no account and no session. The participant API page covers how portal calls are authorised.

How a background job flows

  1. A namespace defines the job with defineJob(name, schema, options).
  2. A service enqueues it through a JobSink. It can pass its database transaction, so the job exists only if the transaction commits.
  3. The API holds an enqueue-only client. It never executes jobs.
  4. The worker registers a handler and runs the job. A plain job is one retried step. A workflow is a durable multi-step flow whose step results are checkpointed in Postgres.
  5. After a crash a workflow resumes at its first unfinished step, on any worker.

Background jobs and schedules has the detail. The processing pipeline is the largest user of workflows.

Live updates

Every process connects to Postgres directly. A transaction-mode connection pooler breaks the job queue and the live streams.

Health endpoints

Endpoint App Answers
/health, /api/health api Liveness, with the release.
/ready api Whether the database answers.
/ready/worker api Whether a worker heartbeat is recent.
/config.json api The config values marked public.
/health web, media Liveness.

Related pages

Comments