Skip to main content

Architecture

Workforce Workbench is a TypeScript monorepo with three workspaces: a pure domain model, an HTTP API and a React single-page app. In production, one Cloudflare Worker serves both the built SPA and the API, behind Cloudflare Access, backed by Neon Postgres.

Browser (React SPA) Cloudflare Neon Postgres
┌──────────────────────────────┐ HTTPS ┌──────────────────────────┐ ┌──────────────────┐
│ staff-list parsing (Worker) │──────────▶ │ Cloudflare Access (SSO) │ │ RLS-protected │
│ manager-email → id resolution│ │ │ signed JWT │ │ tables, audit, │
│ @workbench/core scoring │ │ ▼ │ │ encrypted │
│ exports: HTML / PPTX / XLSX │ ◀───────── │ Worker: static SPA + │───▶│ identities, │
└──────────────────────────────┘ JSON │ Hono API (/api/*) │ │ versioned ref. │
│ nightly retention cron │ │ data and prompts │
└──────────────────────────┘ └──────────────────┘
│
└──────▶ Gemini (titles and aggregates only)

Cloudflare Access sits in front of the whole hostname, so nothing (not even the SPA's JavaScript) is served to someone who hasn't signed in with Davies SSO. The Worker serves static assets through Workers Static Assets and only runs code for /api/* (run_worker_first in wrangler.jsonc).

Workspaces​

PackagePathRoleDepends on
@workbench/corepackages/coreThe domain: reference data types, schema and compiler; taxonomy, exposure anchoring, salaries, scenarios, provenance, org graph, Copilot case and rollout, dataset summaries, benchmarks, the finance-workbook model, prompts, request schemas. Pure functions, no I/O.zod
@workbench/apiapps/apiHTTP API (Hono), persistence (Drizzle), access control, AI calls, retention. Runs as a Cloudflare Worker, a Node dev server, and in-process under Vitest.core, Hono, Drizzle, jose, PGlite, pg, Neon
@workbench/webapps/webThe React 19 app (Vite, Tailwind 4, TanStack Query, React Router 7) and every export (ExcelJS, pptxgenjs, recharts).core

Workspaces are wired with npm workspaces ("workspaces": ["packages/*", "apps/*"]). @workbench/core is consumed as TypeScript source ("main": "./src/index.ts"), so there's no build step between packages.

The docs site isn't a workspace

This documentation lives in docusaurus/ with its own node_modules and package.json. It isn't part of the npm workspaces, so npm test and npm run typecheck at the root don't touch it.

One model, two runtimes​

The server and the browser score employees with the same scoreDataset from core:

  • the server scores to finalise a dataset and caches its DatasetSummary (the report, rollups, scenarios, org structure and benchmark aggregates) in datasets.summary;
  • the browser fetches the raw rows and title classifications and re-scores them to drive interactive views: explorer filters, assumption previews, the Copilot case, rollout plans and every export.

Both get identical numbers because they run identical code against the same reference data version. Each dataset is pinned to a version (datasets.reference_version); the web app loads and compiles that version for the dataset's pages. The finance-workbook model is tested to reproduce the platform's scenarios exactly.

Request lifecycle​

  1. Cloudflare Access authenticates the user and adds Cf-Access-Jwt-Assertion (and the CF_Authorization cookie).
  2. The API's first middleware (app.ts) calls the configured authenticator (auth.ts): it verifies the JWT against the team's JWKS, issuer and audience. Development sign-in (x-dev-user) exists only when configuration allows it.
  3. resolveUser (services/users.ts) provisions a first-time user from an allowed email domain (administrators from BOOTSTRAP_ADMIN_EMAILS) and rejects deactivated or unknown users.
  4. Route handlers run their queries through asUser(c, fn). That opens a transaction, runs SET LOCAL ROLE workbench_app and set_config('app.user_id', …, true), and executes fn. Row-level security applies to everything the handler does, even if application code forgets a filter.
  5. Errors map to JSON (errors.ts): ApiError keeps its status and code; zod errors become 400 validation_error with the issues; Postgres 42501 (RLS refused) becomes 403, 23505 and 23503 become 409; anything else is a generic 500 with no internals, and is logged as its class, Postgres code and route only (never the message, which for a failed query includes its parameters).

Every /api/* response carries Cache-Control: no-store and Hono's secure headers. GET /api/health is unauthenticated and returns the version and environment.

A few service paths deliberately use the owner connection (asService): user provisioning, reading published prompts, classification-cache writes, benchmark aggregation, AI metering reads (the month's spend and the caller's "Ask the data" count), the nightly and manual purge, and seeding. Each is small, reviewed and returns only what its caller is entitled to. See Authentication and authorisation.

Dependency injection​

createApp() is runtime-agnostic. Every entry point builds an AppDeps and passes it as env.deps:

interface AppDeps {
db: Database; // asUser / asService / exec / close
config: AppConfig; // parsed once from env
authenticate: Authenticator;
ai: AiClient | null; // null when AI is off
now: () => Date; // injectable clock (tests)
}
Entry pointFileDatabaseNotes
Cloudflare Workerapps/api/src/worker.tsNeon serverless Pool (WebSocket), one per requestConfig parsed once per env object; scheduled runs retention nightly at 03:00 UTC. Never migrates.
Node dev serverapps/api/src/node.tsPGlite under DATA_DIR (default .data/pglite), or node-postgres when DATABASE_URL is setLoads .env, migrates and seeds on start, runs retention hourly.
Testsapps/api/test/harness.tsIn-memory PGlite with the real migrations and RLSScripted FakeAi, dev sign-in.

Database drivers​

Database (src/db/database.ts) wraps a Drizzle instance with asUser and asService:

  • Neon serverless Pool in the Worker, because the RLS role switch needs interactive transactions, which Neon's HTTP driver doesn't support;
  • node-postgres for Node against any Postgres (and for npm run db:migrate);
  • PGlite (embedded Postgres, roles and RLS included) for local development and every API test, so tests exercise the real policies rather than mocks.

Migrations are plain SQL in apps/api/migrations, applied in order and recorded in schema_migrations. See Data model.

Key decisions​

DecisionWhy
Single-organisation model with engagement membership, not multi-tenancyThe Workbench serves one consultancy working for many clients. The boundary that matters is "am I staffed on this engagement" plus "may I see names".
RLS as the enforcement layerAccess rules live beside the data and are tested at the database level, so a missing WHERE can't leak another engagement.
Parse and resolve identities in the browserManager emails never leave the consultant's machine. Names and emails travel only when explicitly stored, and are then encrypted per engagement.
Summaries cached per datasetReports, comparisons, benchmarks and purged datasets keep working without row-level data.
Reference data and prompts as versioned dataConstants and prompts change without a deployment; published versions are immutable so any figure can be reproduced; datasets are pinned.
Exports generated in the browserThe server never renders documents; heavy libraries load only on demand; nothing identifying leaves unless a PII-authorised user asks.
One Worker for SPA and APIOne deployment and one Access application; same-origin requests, so no CORS surface.
Top navigationFour global destinations don't justify a side rail (see docs/DESIGN.md).