Control Plane Internals
How cloudflare-control/ is put together, and the rules for changing it.
Files
cloudflare-control/
├── migrations/ D1 SQL migrations 0001..0019
├── src/
│ ├── index.ts Router, REST endpoints, scheduled(), queue()
│ ├── org_hub.ts OrgHub: one Durable Object per organization
│ ├── hub.ts The only way the Worker reaches a hub
│ ├── guard.ts Tenant resolution, authorization, CSRF origin guard
│ ├── escape.ts HTML / attribute / JSON escaping, safe URLs
│ ├── db.ts D1 queries, SCHEMA_SQL, tenant seeding
│ ├── auth.ts Web Crypto PBKDF2, tokens, nonces, password policy
│ ├── boot_report.ts Workstation boot outcomes -> client_devices, Errors & Warnings
│ ├── bug_reports.ts Opt-in redacted GitHub bug reports, Workers AI triage
│ ├── d1_adapter.ts node:sqlite mock for local tests
│ ├── ui.ts Organization admin console router: selects page, wraps shell
│ ├── ui_admin_shared.ts Shared context panel actions & client scripts
│ ├── ui_admin_workstations.ts Workstations fleet, groups & commands
│ ├── ui_admin_apps_web.ts Apps & Web: broadcast, portal & allowlist
│ ├── ui_admin_staff.ts Staff accounts, roles & permissions
│ ├── ui_admin_settings.ts Settings: 5 tab panes (Errors & Warnings), scrollable audit
│ ├── ui_tokens.ts Design system tokens (colors, radii, easing)
│ ├── ui_layout.ts Shared multi-level shell, headers & styles
│ ├── ui_landing.ts Public SaaS landing page
│ ├── ui_org_home.ts Organization homepage at subdomain root (/)
│ ├── ui_portal.ts User Portal at /home
│ ├── ui_super.ts Super Admin console (/super)
│ ├── ui_legal.ts Legal compliance pages (/privacy, /terms, /terms/bug-reports)
│ └── types.ts Strict TypeScript interfaces
├── test/worker.test.ts Multi-tenant integration and security suite
├── .dev.vars.example Local secrets template
├── tsconfig.json src/ against @cloudflare/workers-types alone
├── tsconfig.test.json test/ against @types/node
└── wrangler.jsonc Routes, D1 binding, hourly cron, AI binding
Zero runtime npm dependencies. Everything in package.json is a devDependency: wrangler, typescript, tsx, and two type packages. Never add a routing library, an auth framework, or an ORM — cold start must stay under 10 ms, and the supply-chain surface is deliberately empty.
index.ts — the router
One fetch() handler containing a flat sequence of guarded blocks:
if (path === "/api/command" && method === "POST") {
const denied = requireTenantAdmin(session, currentTenant, jsonHeaders);
if (denied) return denied;
…
}
No framework, no middleware stack, no decorators. Adding a route means adding a block — and the block is not complete without its guard.
Order of operations
bootstrap(env)— fail-closed checks on secrets and schema.resolveTenant()— the organization, fromHost.rejectCrossSiteMutation()— for cookie-authenticated mutations under/api/.- The route's guard.
- The handler.
buildHtmlHeaders(nonce, …)for HTML responses.
OrgHub — one Durable Object per organization
Worker isolates are per-colocation and short-lived, so live state cannot sit in module memory, and writing every heartbeat to D1 (one database for every organization) does not scale. Each organization instead has one OrgHub (src/org_hub.ts, idFromName(tenant.id)):
- Workstations hold a WebSocket to it (
/api/devices/ws), and so does each open Workstations page (/api/console/ws). Older agents post/api/telemetry, which the Worker forwards to the hub. - It keeps who is connected and what each one shows, and the command queue (its own SQLite:
commands,deliveries,revoked,meta; commands expire after 60 s). - It pushes configuration (
notifyConfigChanged()after every admin change) and commands the moment they exist, and asks a workstation for screen frames only while a console is showing it. - It writes back to
client_deviceson connect, disconnect, a change (batched by a 20 s alarm) and every 5 minutes, and keepstenants.online_workstationscurrent. A quiet workstation costs D1 nothing. - It hibernates between messages: sockets are accepted with tags, per-socket state lives in attachments, timers are alarms, and the ping is answered at the edge without waking it.
The Worker reaches a hub only through src/hub.ts; each call names the organization, and a hub refuses any other (409). Tests run hubs in-process through src/local_do.ts.
scheduled()
Invoked hourly by the cron in wrangler.jsonc. Purges expired sessions and stale rate-limit rows, deletes workstation_issues older than 90 days (WORKSTATION_ISSUE_RETENTION_DAYS), and moves audit entries older than 180 days to the AUDIT_ARCHIVE R2 bucket as NDJSON. Then processBugReports() (src/bug_reports.ts) triages pending automatic bug reports, when AI, GITHUB_ISSUES_TOKEN and GITHUB_ISSUES_REPO are all set: it redacts each problem, links a known signature to its issue, asks the Workers AI model @cf/openai/gpt-oss-120b whether a new one matches an open report (one comment on that issue) or drafts a new issue, makes at most 5 GitHub writes a run, and reads back the status of up to 10 filed issues. A GitHub or model failure leaves the rest for the next run. Expired commands are the hubs' own business: each deletes its own when it next runs.
queue()
Consumes the labkiosk-audit queue: writeAuditLog() sends entries there, and the consumer writes each batch in one statement with INSERT OR IGNORE on the entry id, so a redelivered batch is written once.
guard.ts — the security boundary
| Export | Purpose |
|---|---|
hostname(request) | The Host header, normalised |
isDevHost(request) | Is this localhost, 127.0.0.1, host.docker.internal, …? |
isHostUnder(host, base) | Is host a subdomain of base? |
hostSubdomain(request, base) | The slug, or null for reserved and platform hosts |
isReservedSlug(slug) | Membership of RESERVED_SLUGS |
resolveTenant(options) | The authoritative tenant for this request |
rejectCrossSiteMutation(…) | 403 for an untrusted Origin on a cookie-authenticated mutation |
requireSuperAdmin(session, headers) | |
requireTenantAdmin(session, tenant, headers) | |
requireDevice(request, db, headers) | Validates the bearer token |
jsonError(message, status, headers) | Consistent error shape |
DEV_HOSTS covers localhost, 127.0.0.1, 0.0.0.0, [::1], host.docker.internal, and host.containers.internal.
RESERVED_SLUGS: www, super, labkiosk, api, admin, portal, status, mail, app, kiosk, root.
escape.ts — output safety
| Export | Use |
|---|---|
escapeHtml(value) | Any interpolation into HTML text |
escapeAttr(value) | Alias of escapeHtml; use it in attributes for intent |
escapeJson(value) | Required for anything inlined into a <script> block. Also escapes U+2028 / U+2029, which are valid JSON but terminate a JS line |
cleanSubdomain(raw) | Normalises a requested slug |
cleanCustomDomain(raw) | Validates an FQDN |
safeHttpUrl(raw) | http(s) only; prepends https:// to a scheme-less domain, so canvas.example.com is accepted |
auth.ts — cryptography
const PBKDF2_ITERATIONS = 100000;
const KEY_LENGTH = 256;
| Export | Purpose |
|---|---|
hashPassword(password, salt?) | PBKDF2-HMAC-SHA256, 32-byte salt, 256 bits, hex |
verifyPassword(…) | Constant-time comparison |
timingSafeEqual(a, b) | |
sha256Hex(input) | Session and device token hashing |
generateSessionToken() / generateDeviceToken() | 32 random bytes, hex |
generateEnrollmentKey() | The organization's KEY-XXXX-… |
generateNonce() | Per-response CSP nonce |
validatePasswordStrength(password) | Returns a message, or null if acceptable |
isPlausibleEmail(value) | |
parseCookies / createSessionCookie / clearSessionCookie | HttpOnly; Secure; SameSite=Lax |
Only token hashes reach the database. A session or device token exists in plaintext solely in the client that was issued it.
db.ts — data access
Every function touching devices, commands, sessions, or portal apps takes a tenantId and filters on it. That is not a convention — it is the mechanism that makes the platform multi-tenant.
Notable helpers:
| Function | Notes |
|---|---|
buildEffectiveWhitelist(db, tenantId) | Unions tenant_whitelist with every portal_sites.domain; the hub caches it and reloads it on a configuration change |
upsertDeviceRegistry(db, rows) | The hub's batched write-back to client_devices; never touches the group or broadcast columns |
listDeviceBroadcasts(db, tenantId) | Per-workstation broadcasts, for the hub's configuration |
setTenantOnlineCount(db, tenantId, n) | tenants.online_workstations |
rateLimitWait / recordRateLimitHit | Public-endpoint throttling |
writeAuditLog(db, {…}) | Through the audit queue when bound, directly otherwise |
archiveOldAuditLogs(db, bucket, now) | The 180-day retention, from scheduled() |
assertSchemaCurrent(db) | Refuses to serve an un-migrated database |
SCHEMA_SQL lives here and must mirror migrations/ exactly. → Database Schema
ui*.ts — server-rendered consoles
Each exports a render*Html(nonce, …) that returns a complete document. Three rules, all asserted by the test suite:
- **Every
<script>carriesnonce="${escapeAttr(nonce)}".** A script tag without it silently does not run. - No inline event handlers. Use
data-actionattributes with a delegated listener, oraddEventListener. Anonclick=produces "Refused to execute inline event handler" and a button that does nothing. - Every dynamic value is escaped, server-side through
escape.tsand client-side by building nodes and assigningtextContent.
Pass ids through dataset, never by concatenating a value into an onclick= attribute.
d1_adapter.ts — the test database
Implements the D1 interface over Node 22's native node:sqlite. No npm dependency, and no Miniflare needed for unit tests. Enabled with ALLOW_LOCAL_DB=1; without that flag a missing D1 binding is a hard failure rather than silent data loss.
It splits SQL on ;, normalises \r\n to \n, and runs each statement through db.prepare(stmt).run() — because Miniflare's db.exec() mis-parses multiline SQL with CRLF line endings and produces D1_EXEC_ERROR: incomplete input.
Adding a route: the checklist
- Read
types.tsbefore changing any API contract. - Read
guard.tsandescape.tsbefore writing the handler. - Add the block to
index.ts, with its guard. Cookie-authenticated mutations already pass the CSRF check; do not add state-changing routes outside/api/. - If it touches the schema: a new numbered migration and the mirror in
SCHEMA_SQL. - If it renders HTML: nonce on every script, no
on*=, everything escaped. - If it is consensus state: it goes in D1, never a module-level variable.
- Add negative tests — anonymous
401, cross-tenant403/404, cross-site CSRF rejection, input validation and escaping. pnpm --prefix cloudflare-control run typecheck && pnpm --prefix cloudflare-control test