Overview & request lifecycle
The platform is a Cloudflare Workers (Hono) API in front of a single Neon Postgres
database, with a Vite/React SPA and a public survey runtime on top. The back end is
laid out as ports and adapters (hexagonal): domain and route code depend only on
TypeScript port interfaces in packages/ports, and the concrete providers (Neon,
Resend, Twilio, Gemini, Clerk, R2, Cloudflare Queues) are constructed in exactly
one place — the composition root.
The three planes
- Presentation — the admin SPA (
frontend/, client-rendered React) and the public survey runtime. Neither holds secrets; they talk to the Worker over JSON. - Worker API + background — a Hono Worker (
api/) exposing the/v1API plus public capture/webhook endpoints. Long work goes to Queues/Cron, not the request path. - Data — one Postgres database reached through Cloudflare Hyperdrive (pooling +
edge cache) in production, or directly via
DATABASE_URLlocally.
Request lifecycle
The Worker entrypoint is api/src/index.ts; it exports fetch (the Hono app),
scheduled (Cron jobs) and queue (the ingestion consumer). A request through the
Hono app (api/src/app.ts) flows:
-
Container middleware. A per-request provider container is built and attached:
c.set('ports', buildContainer(c.env)). Adapters inside are constructed lazily, so a route like/healthbuilds nothing.// api/src/app.tsapp.use('*', async (c, next) => {const ports = buildContainer(c.env);c.set('ports', ports);try {await next();} finally {if (ports.dispose) c.executionCtx.waitUntil(ports.dispose());}}); -
One connection per request. The container opens a single
postgresclient lazily on first DB/auth use and closes it indispose()after the response (run viawaitUntil). Workers forbid reusing a connection's I/O across requests, so the client is not memoised at module scope — seeapi/src/composition-root.ts. -
CORS. The SPA authenticates with a Bearer token (no cookies); origins in
CORS_ORIGINSplus anylocalhost/127.0.0.1origin are reflected. -
Tenant gate.
/v1/*routes runrequireTenant(verifies the session via the Identity port and pinsc.var.tenantfrom the verified principal), thenipAllowlistand a per-API-key rate limit. The tenant is never taken from a request parameter. -
Handler → service → repository. Route handlers are thin: they call a domain service, which composes repository and port calls. Tenant-scoped DB access goes through
ports.db.forTenant(ctx).transaction(...), which pins the RLS context (role switch + tenant GUC) before any query — see Multi-tenancy & RLS. -
Single error envelope.
app.onErrormaps typedAppErrors (packages/core/src/errors.ts) to{ error: { code, message } }with their status; Zod errors →400; a malformed UUID id →404; anything else → a generic500with the real error logged server-side, never echoed.
Public & background paths
Public endpoints carry no tenant context — the survey or token identifies the owning
tenant, which the Database adapter resolves internally before pinning RLS:
/public/surveys/*, respondent capture, opt-out, email open/click tracking, badges./publicTwilio webhooks (SMS/IVR),/r/:slugshort-link resolution,/public/signups(the feature flag gating first-time workspace creation).
Background work uses the other two Worker entrypoints: scheduled (Cron — e.g.
expiring stale partial responses, demo top-up) and queue (the ingestion consumer that
enriches captured responses asynchronously — sentiment, etc.). Capture writes the row
synchronously and enqueues the heavy work, so the respondent is never blocked.
Health endpoints
GET /health— static liveness, returns the parsedHealthResponsecontract.GET /health/ready— callsports.db.ping(); fails closed with503when Postgres is unreachable, so a probe pulls the instance rather than serving errors.
Next: Ports & adapters and Monorepo packages.