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
| Package | Path | Role | Depends on |
|---|---|---|---|
@workbench/core | packages/core | The 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/api | apps/api | HTTP 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/web | apps/web | The 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.
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) indatasets.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
- Cloudflare Access authenticates the user and adds
Cf-Access-Jwt-Assertion(and theCF_Authorizationcookie). - 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. resolveUser(services/users.ts) provisions a first-time user from an allowed email domain (administrators fromBOOTSTRAP_ADMIN_EMAILS) and rejects deactivated or unknown users.- Route handlers run their queries through
asUser(c, fn). That opens a transaction, runsSET LOCAL ROLE workbench_appandset_config('app.user_id', …, true), and executesfn. Row-level security applies to everything the handler does, even if application code forgets a filter. - Errors map to JSON (
errors.ts):ApiErrorkeeps its status and code; zod errors become400 validation_errorwith the issues; Postgres42501(RLS refused) becomes403,23505and23503become409; anything else is a generic500with 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 point | File | Database | Notes |
|---|---|---|---|
| Cloudflare Worker | apps/api/src/worker.ts | Neon serverless Pool (WebSocket), one per request | Config parsed once per env object; scheduled runs retention nightly at 03:00 UTC. Never migrates. |
| Node dev server | apps/api/src/node.ts | PGlite under DATA_DIR (default .data/pglite), or node-postgres when DATABASE_URL is set | Loads .env, migrates and seeds on start, runs retention hourly. |
| Tests | apps/api/test/harness.ts | In-memory PGlite with the real migrations and RLS | Scripted 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
| Decision | Why |
|---|---|
| Single-organisation model with engagement membership, not multi-tenancy | The 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 layer | Access 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 browser | Manager emails never leave the consultant's machine. Names and emails travel only when explicitly stored, and are then encrypted per engagement. |
| Summaries cached per dataset | Reports, comparisons, benchmarks and purged datasets keep working without row-level data. |
| Reference data and prompts as versioned data | Constants and prompts change without a deployment; published versions are immutable so any figure can be reproduced; datasets are pinned. |
| Exports generated in the browser | The server never renders documents; heavy libraries load only on demand; nothing identifying leaves unless a PII-authorised user asks. |
| One Worker for SPA and API | One deployment and one Access application; same-origin requests, so no CORS surface. |
| Top navigation | Four global destinations don't justify a side rail (see docs/DESIGN.md). |
Where to read next
- Local set-up to run it.
- Scoring pipeline for the model.
- Upload and classification for the data flow.
- Reference data and Prompts for the versioned configuration.