Lab Kiosk OS & Edge SaaS

Database Schema

Lab Kiosk stores everything durable in Cloudflare D1 (SQLite at the edge). Live state -- who is connected, the command queue, screen frames -- belongs to each organization's OrgHub Durable Object, which writes back to client_devices here; audit entries older than 180 days move to R2.


The schema has two homes

This is the single most important rule when touching the database, and the test suite enforces it.

HomePurpose
cloudflare-control/migrations/000N_*.sqlWhat a deployed D1 database actually has. Applied sequentially by Wrangler.
SCHEMA_SQL in cloudflare-control/src/db.tsWhat the in-memory adapter builds for tests and local development.

Both must be changed together. test/worker.test.ts compares them and fails on drift with Columns of "x" differ between SCHEMA_SQL and migrations/.

Never edit an applied migration. Add a new numbered file — 0019_feature.sql — and mirror the change in SCHEMA_SQL.

# Local
npx wrangler d1 migrations apply labkiosk-db --local

# Production
npx wrangler d1 migrations apply labkiosk-db --remote

A worker with a D1 binding refuses to serve a database whose migrations have not been applied (assertSchemaCurrent()), and never creates tables at runtime.


Migration history

FileWhat it added
0001_initial_schema.sqlusers, tenants, sessions, portal_sites, client_devices, commands, audit_logs
0002_device_enrolment_and_scoped_whitelist.sqldevice_tokens, tenant_whitelist, command_deliveries, login_attempts; tenants.enrollment_key
0003_custom_domains.sqltenants.custom_domain, requested_custom_domain, custom_domain_status
0004_customization_and_presets.sqlbroadcast_presets; tenants.default_lock_message, portal_title, portal_subtitle, portal_description, portal_footer
0005_broadcast_state_and_remote_control.sqltenants.broadcast_url, broadcast_epoch; client_devices.vnc_password, remote_host
0006_ui_catalogs.sqlui_catalogs (platform-wide interface translations)
0007_tenant_users_and_subadmins.sqltenant_users (staff delegation); tenants.home_route, tunnel_domain
0008_school_homepage.sqltenants.homepage_headline, homepage_intro, homepage_blocks; moves home_route = '/portal' to /home
0009_workstation_groups.sqlworkstation_groups; client_devices.group_name
0010_workstation_broadcast.sqlclient_devices.broadcast_url, broadcast_epoch (a broadcast or reset addressed to selected workstations)
0011_organization_vocabulary.sqlRenames the stored roles and staff permission (school_admin→org_admin, teacher→operator, lab_assistant→assistant, teachers→staff); rebuilds the 12 tables that reference users cascade-safely
0012_unique_workstation_group_names.sqlUnique index on workstation_groups(tenant_id, name COLLATE NOCASE); merges existing duplicates into the oldest group first
0013_retire_demo_tenant.sqlDeletes the single demo organization and all its rows (data only); the worker now creates web-demo, local-demo and docker-demo at startup
0014_org_hub_live_state.sqlDrops commands, command_deliveries and client_devices.thumbnail (live state moved to OrgHub); adds tenants.online_workstations, custom_hostname_id, custom_hostname_status
0015_workstation_boot_reports.sqlclient_devices.image_version, update_state, update_error, update_state_at (boot outcomes from POST /api/devices/boot-report)
0016_workstation_issues.sqlworkstation_issues: errors and warnings workstations report (Settings → Errors & Warnings), deleted after 90 days
0017_bug_reports.sqltenants.bug_reports_enabled, workstation_issues.report_state and bug_signature, and the platform table bug_reports: opt-in automatic GitHub bug reports
0018_bug_report_triage.sqltenants.bug_reports_terms_version / _accepted_at, bug_reports.title, problem, status, pr_url, status_checked_at, workstation_issues.report_match
0019_drop_remote_tunnel_columns.sqlDrops tenants.tunnel_domain and client_devices.remote_host (Remote Control now runs through the console relay)
0020_signup_approvals_support.sqlRegistration review and the platform inbox: tenants.remote_control_status, organization_profiles, email_codes, conversations, conversation_messages
0021_two_factor.sqluser_two_factor (authenticator app, recovery codes), login_challenges, user_devices (new-browser alerts, trusted browsers)
0022_mail_deleted.sqlconversations.deleted_at: Mail's Deleted folder (delete moves there first)
0023_two_factor_email.sqlusers.two_factor_email: an organization account asked for an emailed sign-in code (a super admin is always asked)
0024_conversation_category.sqlconversations.category: what a conversation is about, which names its tracking ID's prefix (REG, RMT, SUP, SAL, BIL, LGL, GEN, LTR)
0025_release_notes.sqlrelease_notes: each GitHub release with its change list and a summary written for customers, for /download. A platform table, no tenant_id
0026_contact_email_code.sqlemail_codes rebuilt so its purpose also allows contact: the contact page proves its sender's address with a code, as registration does

Applied migrations are never edited or renamed: wrangler tracks them by file name, which is why 0008 keeps its original name.

**0011 rebuilds tables that other tables cascade from.** D1 cannot switch foreign keys off, and DROP TABLE deletes a table's rows first, firing ON DELETE CASCADE even with foreign-key checks deferred — a naive rebuild of users deletes every organization. 0011 copies every row into holding tables with no foreign keys, drops leaves first, recreates parents first and copies back with explicit column lists. Export the database before applying it in production (wrangler d1 export labkiosk-db --remote --output backup.sql).

Each migration exists because something concrete broke. Migration 0002 is worth reading in full: before it, anyone who guessed a subdomain could post screenshots and drain that organization's command queue, the allowlist was a mutable module global shared by every tenant, and broadcast commands re-executed on every three-second heartbeat.


Tables

users

Platform and organization administrators.

ColumnTypeNotes
idTEXT PK
emailTEXT UNIQUE
password_hashTEXTPBKDF2-HMAC-SHA256, 100 000 iterations, 256 bits, hex
saltTEXT32 random bytes, hex
roleTEXTsuper_admin | org_admin
nameTEXT
created_atINTEGERUnix seconds

tenants

One row per organization. This table has accumulated the most columns because it is where an organization's entire configuration lives.

ColumnTypeNotes
idTEXT PK
user_idTEXT → users.idOwning administrator, ON DELETE CASCADE
nameTEXTDisplay name; attacker-controlled, always escaped on render
subdomainTEXT UNIQUEThe slug in <slug>.labkiosk.example.edu
requested_subdomainTEXTPending change awaiting super-admin action
statusTEXTactive | pending | rejected | suspended
modeTEXTportal | single_url
default_urlTEXTTarget in single_url mode
admin_pinTEXTLegacy local PIN, default 1234
enrollment_keyTEXTEmpty by default — an empty key authenticates nothing
custom_domainTEXTApproved FQDN; unique index
requested_custom_domainTEXTAwaiting approval
custom_domain_statusTEXTnone | pending | approved | rejected
custom_hostname_idTEXTThe Cloudflare for SaaS custom hostname id, once created
custom_hostname_statusTEXTnone | pending | active | failed | local (no provisioning in local development)
online_workstationsINTEGERKept by the organization's OrgHub, so the super admin list needs no query per organization
bug_reports_enabledINTEGER1 when the organization opted in to automatic bug reports; default 0
bug_reports_terms_version / bug_reports_terms_accepted_atTEXT / INTEGERThe Automatic Bug Report Terms version accepted, and when; reports are sent only under the current version
default_lock_messageTEXTUsed when a lock command carries no message
portal_title / portal_subtitle / portal_description / portal_footerTEXTUser Portal copy
broadcast_urlTEXTActive synchronised page, or NULL
broadcast_epochINTEGERMonotonic marker; 0 when no broadcast is active
home_routeTEXT/ (organization homepage) or /home (User Portal); where workstations land
homepage_headline / homepage_intro / homepage_blocksTEXTOrganization homepage copy; blocks are JSON, sanitised on the way in and escaped on the way out
created_at / updated_atINTEGER

broadcast_url and broadcast_epoch live here rather than in worker memory because isolates are per-colocation and short-lived. When they were module-level state, workstations in different colos disagreed about the current page and a recycled isolate forgot the broadcast entirely. A broadcast sent to selected workstations is recorded on each of their client_devices rows instead; the heartbeat hands a workstation whichever of the two is newer.

sessions

ColumnTypeNotes
tokenTEXT PKSHA-256 of the issued session token
user_idTEXT → users.id
tenant_idTEXT → tenants.idNULL for a super admin
roleTEXT
expires_atINTEGERPurged hourly by scheduled()

portal_sites

User Portal cards.

ColumnTypeNotes
idTEXT PK
tenant_idTEXT → tenants.idEvery query filters on this
title / url / domainTEXTdomain is what feeds the effective allowlist
categoryTEXTDefault General
icon / thumbnail_urlTEXTOptional
order_indexINTEGERDisplay order
is_activeINTEGER0 or 1
created_atINTEGER

client_devices

The fleet registry. OrgHub writes it back on connect, disconnect, a change and every 5 minutes; who is online right now comes from the hub.

ColumnTypeNotes
idTEXT PKComposite tenant_id:client_id
tenant_idTEXT → tenants.id
client_idTEXTe.g. PC-01
client_numINTEGERWorkstation number
ipTEXTSource address of the last heartbeat
last_seenINTEGERDrives the online/offline indicator
is_lockedINTEGER
active_urlTEXT
vnc_passwordTEXTPer-boot ephemeral x11vnc secret
group_nameTEXTWorkstation group, matched by name to workstation_groups.name; NULL when ungrouped
broadcast_urlTEXTLast broadcast addressed to this workstation alone; NULL with a non-zero epoch records a reset to the portal
broadcast_epochINTEGEROrders the above against tenants.broadcast_epoch; the newer wins
created_at / updated_atINTEGER
image_versionTEXTSystem image the last boot report named (installed disks)
update_stateTEXTLast boot outcome reported: installed, failed, rolled-back, fallback or error
update_errorTEXTThe reason, for error
update_state_atINTEGERWhen the workstation recorded it; 0 = never. A report less than 60 s newer is ignored

workstation_issues

Errors and warnings workstations report, kept apart from audit_logs (which records what people did). Fed by POST /api/devices/boot-report; read by Settings → Errors & Warnings; the hourly cron deletes rows older than 90 days.

ColumnTypeNotes
idTEXT PK
tenant_idTEXT → tenants.idON DELETE CASCADE
client_idTEXTThe workstation, from its device token
severityTEXTerror or warning (CHECK)
kindTEXTupdate_failed, update_rolled_back, boot_error, boot_fallback
image_versionTEXTThe image the workstation was running
detailsTEXTOne readable line; an error's reason, cut to 300 characters
occurred_atINTEGERWhen the workstation recorded it (its clock)
created_atINTEGERWhen the Worker received it; indexed with tenant_id
report_stateTEXTnone, pending (recorded while the organization had opted in) or sent (CHECK)
bug_signatureTEXT→ bug_reports.signature once sent
report_matchTEXTnew (opened its issue) or existing (linked to one already filed) (CHECK)

bug_reports

One row per distinct redacted problem (signature); several signatures may share one GitHub issue when the reasoning model matched them. Shared by every organization that reports them, with no organization or workstation data (src/bug_reports.ts).

ColumnTypeNotes
signatureTEXT PKSHA-256 of the kind, image version and redacted problem text
kindTEXTAs in workstation_issues
image_versionTEXT
issue_number / issue_urlINTEGER / TEXTThe GitHub issue
created_atINTEGERWhen it was filed
title / problemTEXTThe issue title and redacted problem text the model compares new problems with
statusTEXTopen, in_progress, pr_open, resolved, closed (CHECK), read back from GitHub; indexed by issue_number
pr_urlTEXTThe pull request that references the issue
status_checked_atINTEGERWhen the status was last read

device_tokens

ColumnTypeNotes
idTEXT PK
token_hashTEXT UNIQUESHA-256 of the issued token. The token itself is never stored.
tenant_id / client_idTEXT
created_at / last_used_atINTEGER
revokedINTEGERSet by /api/clients/remove

tenant_whitelist

Per-organization permanent domain allowlist. UNIQUE (tenant_id, domain).

The effective allowlist a workstation receives is this table unioned with every portal_sites.domain and, when a broadcast is active, its host. That union is computed by buildEffectiveWhitelist(), cached by the organization's hub and pushed to workstations when it changes; it is not stored.

Commands (not in D1 since 0014)

Queued commands and their per-workstation delivery receipts live in each organization's OrgHub, in its own SQLite (commands, deliveries). A command for "all" records one delivery per workstation, so a broadcast executes exactly once on each machine; commands expire after 60 s.

broadcast_presets

Operator-defined quick-launch shortcuts: id, tenant_id, title, url, created_at.

login_attempts

ColumnTypeNotes
identifierTEXT PKEmail or source address
failed_countINTEGER
last_failed_atINTEGER
locked_untilINTEGERExponential back-off; drives 429

ui_catalogs

Interface translations for the setup wizard and kiosk bar. **No tenant_id, on purpose**: the text is the same for every organization, and a workstation fetches it before it is enrolled. Written only by a super admin.

ColumnTypeNotes
tagTEXT PKBCP 47 language tag, e.g. hi-IN
name / directionTEXTDisplay name; ltr or rtl
bodyTEXTSanitised flat JSON map of key to string
entry_countINTEGER
updated_at / updated_byINTEGER / TEXT

tenant_users

Staff accounts delegated by an organization.

ColumnTypeNotes
idTEXT PK
tenant_id / user_idTEXTUnique together; both ON DELETE CASCADE
roleTEXTorg_admin | sub_admin | operator | assistant | content_manager (default operator)
permissionsTEXTJSON array of workstations, broadcast, portal, whitelist, staff, settings; * is never stored
created_atINTEGER

An org_admin row, or owning the organization (tenants.user_id), means full access. A delegate holding staff can grant only what they hold themselves and cannot appoint an org_admin.

workstation_groups

ColumnTypeNotes
idTEXT PK
tenant_idTEXT → tenants.idON DELETE CASCADE
nameTEXT1–50 characters, unique per organization case-insensitively (index idx_workstation_groups_tenant_name; membership is by name)
created_atINTEGER

audit_logs

ColumnTypeNotes
idTEXT PK
tenant_idTEXT → tenants.idON DELETE SET NULL — the log outlives the tenant
user_idTEXT → users.idON DELETE SET NULL
actionTEXTe.g. command.lock, settings.mode
detailsTEXTe.g. target=all url=https://…
created_atINTEGER

Indices

idx_tenants_subdomain              tenants(subdomain)
idx_tenants_status                 tenants(status)
idx_tenants_custom_domain          tenants(custom_domain)          -- UNIQUE
idx_tenants_custom_domain_status   tenants(custom_domain_status)
idx_sessions_user_id               sessions(user_id)
idx_sessions_expires_at            sessions(expires_at)
idx_portal_sites_tenant            portal_sites(tenant_id, order_index)
idx_client_devices_tenant          client_devices(tenant_id)
idx_device_tokens_tenant           device_tokens(tenant_id, client_id)
idx_tenant_whitelist_tenant        tenant_whitelist(tenant_id)
idx_broadcast_presets_tenant       broadcast_presets(tenant_id)
idx_audit_logs_tenant              audit_logs(tenant_id, created_at)
idx_workstation_issues_tenant      workstation_issues(tenant_id, created_at)
idx_bug_reports_issue              bug_reports(issue_number)   -- platform table, no tenant_id

Every index leads with tenant_id wherever the table is tenant-scoped, matching the query shape that db.ts always uses.


Adding a schema change

  1. Create migrations/0019_<description>.sql (the next number after 0018). Use ALTER TABLE for new columns; D1 has SQLite's limitations, so plan for additive changes.
  2. Mirror the change in SCHEMA_SQL in src/db.ts.
  3. Make sure any new query filters by tenant_id.
  4. Run pnpm --prefix cloudflare-control test. The drift test will tell you if the two homes disagree.
  5. Apply locally with --local, and in production with --remote — the deploy workflow does this automatically before wrangler deploy.

Local testing engine

src/d1_adapter.ts implements the D1 interface on top of Node 22's native node:sqlite, with no npm dependency. Tests run against it with ALLOW_LOCAL_DB=1; without that flag a missing D1 binding is a hard failure rather than silent data loss.

Windows note: Miniflare's db.exec() mis-parses multiline SQL with CRLF line endings, producing D1_EXEC_ERROR: incomplete input. The adapter splits statements on ;, normalises \r\n to \n, and runs each through db.prepare(stmt).run().

→ Control Plane Internals · Testing Guide

This page is wiki/Database-Schema.md in the repository.