Skip to main content

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 /v1 API 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_URL locally.

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:

  1. 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 /health builds nothing.

    // api/src/app.ts
    app.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());
    }
    });
  2. One connection per request. The container opens a single postgres client lazily on first DB/auth use and closes it in dispose() after the response (run via waitUntil). Workers forbid reusing a connection's I/O across requests, so the client is not memoised at module scope — see api/src/composition-root.ts.

  3. CORS. The SPA authenticates with a Bearer token (no cookies); origins in CORS_ORIGINS plus any localhost/127.0.0.1 origin are reflected.

  4. Tenant gate. /v1/* routes run requireTenant (verifies the session via the Identity port and pins c.var.tenant from the verified principal), then ipAllowlist and a per-API-key rate limit. The tenant is never taken from a request parameter.

  5. 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.

  6. Single error envelope. app.onError maps typed AppErrors (packages/core/src/errors.ts) to { error: { code, message } } with their status; Zod errors → 400; a malformed UUID id → 404; anything else → a generic 500 with 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.
  • /public Twilio webhooks (SMS/IVR), /r/:slug short-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 parsed HealthResponse contract.
  • GET /health/ready — calls ports.db.ping(); fails closed with 503 when Postgres is unreachable, so a probe pulls the instance rather than serving errors.

Next: Ports & adapters and Monorepo packages.