api.prod-orm.io and klock.prod-orm.io.503 klock_v2_unavailable and v1 keeps working untouched. So the order iswrangler deploy outright.api/scripts/klock-keyset.ts. Run it from api/ on anbun run scripts/klock-keyset.ts <command>. It neverwrangler commands for you to run later, where you| Command | Makes | Flags |
|---|---|---|
root | An offline Ed25519 root. Prints {kid, x} to pin. | --kid root-2026 --out FILE |
keygen | An operational key: grant (KLOCK_GRANT_KEY), klock (KLOCK_SERVICE_KEY), or an RPC key (--service api: API_RPC_KEY; --service klock: KLOCK_RPC_KEY). Prints the public half. | --use grant|klock|rpc [--service api|klock] [--kid …] --out FILE |
sign | A keyset {seq, iat, expires, keys}, signed by the root and verified with verifyKeyset before it is printed | --root-key FILE --key PUB.json [--key PUB.json …] [--seq 1] [--days 89] [--out FILE] |
rotate | The next keyset, seq + 1 (rotating keys) | --current KEYSET --root-key FILE [--add PUB.json]… [--retire KID[=days]]… [--drop KID]… [--days 89] [--out FILE] |
verify | Checks a keyset against the given roots, or the pinned ones | --keyset FILE [--root KID:X]… [--min-seq N] |
--out with0600 and O_EXCL, so an existing file is never overwritten.--out - is refused, and a seed is never printed. A key file with group orchmod 600 it).<out>.pub.json. That is whatsign --key and rotate --add take.{kid, x}, awrangler secrets-store commands, the$ bun run scripts/klock-keyset.ts root --kid root-2026 --out root-2026.key
{"kid":"root-2026","x":"…"}
$ bun run scripts/klock-keyset.ts root --kid root-2026-b --out root-2026-b.key
{"kid":"root-2026-b","x":"…"}src/klock/keys.ts andouth/internal/klockv2/roots.go. Move each seed to a hardware token or a$ bun run scripts/klock-keyset.ts keygen --use grant --out grant.key
$ bun run scripts/klock-keyset.ts keygen --use klock --out klock.key
$ bun run scripts/klock-keyset.ts keygen --use rpc --service api --out api-rpc.key
$ bun run scripts/klock-keyset.ts keygen --use rpc --service klock --out klock-rpc.keyg-YYYYMM-xxxx and a klock key k-YYYYMM-xxxx--kid). For the klock key, stderr also prints theKLOCK_SERVICE_KID var. For each RPC key, it prints the var the otherAPI_RPC_PUB on the klock Worker for svc:api, andKLOCK_RPC_PUB on the API for svc:klock. RPC keys are not in the keyset.$ bun run scripts/klock-keyset.ts sign --root-key root-2026.key \
--key grant.key.pub.json --key klock.key.pub.json --seq 1 --out keyset-1.jwstyp: klock-keyset+jws and theINSERT INTO klock_keysets … SQL and the bun run d1 --command "…" line for$ bun run scripts/klock-keyset.ts verify --keyset keyset-1.jws --root root-2026:<x>--root, it verifies against the roots pinned in this buildPRORM_KLOCK_ROOTS in a non-production shell). --min-seq refuses averifyKeyset and Go'sklockv2.VerifyKeyset both accept the tool's keysets.src/klock/keys.ts):kid matches root-[a-z0-9-]{1,32}.kid is g-YYYYMM-<4 hex> for use: 'grant' andk-YYYYMM-<4 hex> for use: 'klock'; the prefix must match the use.seq starts at 1 and only grows:seq than they have seen.[!CAUTION] The root seed never touches a networked machine
Run the ceremony offline, move each root key file to a hardware token or a password
manager, and keep a second root in a different place. A leaked root lets
whoever holds it publish a keyset with their own grant key, which every
client would trust.
src/klock/keys.ts: add { kid, x } to KLOCK_ROOTS. It is empty today,-export const KLOCK_ROOTS: readonly KlockRoot[] = Object.freeze([]);
+export const KLOCK_ROOTS: readonly KlockRoot[] = Object.freeze([
+ { kid: 'root-2026', x: '<the primary root x>' },
+ { kid: 'root-2026-b', x: '<the backup root x>' },
+]);outh/internal/klockv2/roots.go: add the same entry to Roots.roots_test.go holds the two lists equal, so a mismatch fails the Go tests.PRORM_KLOCK_ROOTS, a{kid, x}. Production builds ignore it.| Name | Kind | Held by | Bound in | Read by |
|---|---|---|---|---|
KLOCK_GRANT_KEY | Secret: Ed25519 seed, b64url (or hex), 32 bytes | API | wrangler.jsonc secrets_store_secrets, commented out until the store holds it | controllers/klockGrants.ts; loaded by worker-entry.ts SECRETS |
API_RPC_KEY | Secret: Ed25519 seed for svc:api | API | wrangler.jsonc secrets_store_secrets, declared and commented out until the store holds it | controllers/klockGrants.ts (signs calls into KlockInternal); loaded by worker-entry.ts SECRETS |
KLOCK_RPC_PUB | Public key (b64url, 32 bytes) of svc:klock | API | wrangler.jsonc vars (empty) | controllers/klockGrants.ts: empty means ApiInternal refuses every call (klock_unconfigured), including compiledRules and recordFindings |
KLOCK_SERVICE_KEY | Secret: Ed25519 seed, use klock | klock Worker | wrangler.klock.jsonc secrets_store_secrets, commented out | klock/env.ts; signs sync answers |
KLOCK_SERVICE_KID | Var: the k-… kid of that key in the keyset | klock Worker | wrangler.klock.jsonc vars (empty) | klock/lib/trust.ts: empty means sync answers 503 (klock_key_unpublished) |
KLOCK_RPC_KEY | Secret: Ed25519 seed for svc:klock | klock Worker | wrangler.klock.jsonc secrets_store_secrets, commented out | signs calls to ApiInternal; its public half is the API's KLOCK_RPC_PUB |
API_RPC_PUB | Public key (b64url, 32 bytes) of svc:api | klock Worker | wrangler.klock.jsonc vars (empty) | klock/env.ts: empty means every KlockInternal call is refused |
SUPPORT_DROP_DEVICE_ID | Var: the staff device (dev_…) that support: transfers are sealed to | klock Worker | wrangler.klock.jsonc vars (empty) | klock/lib/transfer-policy.ts: empty means support: answers 503 klock_unconfigured (step 6) |
KLOCK_AUTHORITY_TOKEN | Secret: a second bearer for the Kerberos authority | the authority (api/kerberos) | the authority's environment; optional | admitted on POST [/{team}]/v1/csr only (step 10) |
keygen --use rpc --service api|klock, step 1).| Secret | Held by | Its public half is the var | On |
|---|---|---|---|
API_RPC_KEY (svc:api) | API | API_RPC_PUB | klock Worker |
KLOCK_RPC_KEY (svc:klock) | klock Worker | KLOCK_RPC_PUB | API |
$ wrangler secrets-store secret create <STORE_ID> --name KLOCK_GRANT_KEY --scopes workers --remotebun run secrets set <NAME> < <file> also works.controllers/klockGrants.ts):KLOCK_GRANT_KEY is set; the newest klock_keysets row verifies against theuse: 'grant' entry whose x is503 klock_v2_unavailable, andKLOCK_GRANT_KEY (or its binding) turns v2 signing off:503 klock_v2_unavailable and v1 is untouched. Grantsexp (at most 7 days); to end themkid (see rotation).api/package.json:"ts-prorm-orm": "^4.9.2"), and until that version exists it resolvespaths fallback in api/tsconfig.json:dist first, then ../src.4.9.2.package.json#exports has "./mcp/registry" (it does now):ts-prorm-orm/mcp/registry for the MCP forward.latest is untouched.api/, run pnpm install so pnpm-lock.yaml records it.../src fallbacks from api/tsconfig.json paths. Forts-prorm-orm/mcp/registry the order is source first today (the installedneeds, and the forward fails closed with503 on one), so swap it to dist first once the new release is installed,down (database/migrations/types.ts: a migration may only add). They aredatabase/migrations/index.ts.| Version | File | Adds |
|---|---|---|
0026 | klock-sessions.ts | sessions: kind, binding, device, team, capabilities, limits, step-up and check-in, rotation, freezing, revocation reason and actor, usage, TLS fingerprint. audit_log: level, category, device, session, actor. passwordless_sessions and transfer_jobs: the linked session row and protocol. Backfills each session's kind (assumed, cli-legacy or browser). |
0027 | access-rules.ts | The access_rules table, and the change-log triggers re-issued. Existing database_access.blockedIps are not copied. |
0028 | behaviour-profiles.ts | behaviour_profiles, behaviour_baselines, behaviour_findings. |
0029 | klock-keys.ts | klock_keysets and klock_revocations. |
0030 | team-ownership.ts | ownerKind and teamId on databases and data_stores, ownerKind on transfer_jobs, a billing account on teams, teamId on subscriptions and invoices, and an index on project_resources (targetKind, targetId). Existing multi-team attachments are reported, not changed. |
transfer_jobs (migration0022). 0026 adds columns only to tables that exist and lack them, so atransfer_jobs is skipped silently rather than failing.$ cd api
$ bun run migrate:d1 -- --status
$ bun run migrate:d1 -- --rehearsal
$ bun run migrate:d1 -- --dry-run
$ bun run migrate:d1prorm-rehearsal; the plantransfer_jobs is there before you start:$ bun run d1 --command "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transfer_jobs'"migrate:d1 runs the migrator through a Worker that is never deployedwrangler.migrate.jsonc), with --env-file /dev/null so the repository's.env is not used.signbun run d1 --command "…" (wrangler d1 execute prorm --remote).doc is stored and served exactly as signed; verifiers check the bytes andGET https://api.prod-orm.io/.well-known/klock-keys.json serves the highestseq.GET https://api.prod-orm.io/.well-known/klock-configuration advertises v2.$ curl -sD - -o /dev/null https://api.prod-orm.io/.well-known/klock-keys.json | grep -i klock-keyset-seq
klock-keyset-seq: 1
$ curl -s https://api.prod-orm.io/.well-known/klock-configuration | jq .v2
falsev2 is false here, as it should be: KLOCK_GRANT_KEY is bound in step 7.true on the first request after that deploy, provided the key'sgrant key.klock_keysets table, and in memory for anGET https://klock.prod-orm.io/v2/keys.[!CAUTION]
Never delete or lower aseq: clients that saw it refuse anything older.
seq without it.api/klock/ with its own config, api/wrangler.klock.jsonc.$ cd api
$ wrangler d1 create prorm-klockdatabase_id (it is all zeros today):"d1_databases": [
{
"binding": "DB",
"database_name": "prorm-klock",
"database_id": "<the id wrangler printed>",
"migrations_dir": "klock/migrations"
}
],| File | Does |
|---|---|
klock/migrations/0001_klock_init.sql | The device registry (klock_devices), the credential ledger, the sync log, pairings, the event chain, commands, the keyset cache and the ticket and RPC nonce tables |
klock/migrations/0002_klock_transfers.sql | Recreates klock_transfers (0001 declared it ahead of the protocol, and nothing ever wrote it), and adds klock_transfer_chunks and klock_transfer_nonces for file transfer |
$ wrangler d1 migrations list prorm-klock -c wrangler.klock.jsonc --remote
$ wrangler d1 migrations apply prorm-klock -c wrangler.klock.jsonc --remote0002 begins with DROP TABLE IF EXISTS klock_transfers. That loses/v2/transfers route answered 501 before it, so the table0001 needs only the second command.KLOCK_SERVICE_KEY and KLOCK_RPC_KEY in the Secrets Store, thensecrets_store_secrets block. Set the vars:"vars": {
"NODE_ENV": "production",
"API_ORIGIN": "https://api.prod-orm.io",
"KLOCK_SERVICE_KID": "k-202609-7c2e", // the klock key's kid in the keyset
"API_RPC_PUB": "<b64url public half of API_RPC_KEY>",
"SUPPORT_DROP_DEVICE_ID": "" // the staff drop device; see below
},xfer/<id>/<n>) inprorm-klock-relay, bound as RELAY. The binding is commentedwrangler.klock.jsonc, because binding a bucket that does not exist/v2/transfers route answers503 klock_unconfigured and the CLI says "coming soon". To turn it on,$ wrangler r2 bucket create prorm-klock-relay
$ wrangler r2 bucket lifecycle add prorm-klock-relay expire-24h --expire-days 1"r2_buckets": [
{ "binding": "RELAY", "bucket_name": "prorm-klock-relay" }
],prorm klock cp … support:), enrol a staff device,SUPPORT_DROP_DEVICE_ID. Its session needsadmin:support or admin:dev to open the drop. Left empty, support:503 klock_unconfigured; transfers between devices still work.$ wrangler deploy --dry-run -c wrangler.klock.jsonc
$ wrangler deploy -c wrangler.klock.jsonc
$ curl -s -o /dev/null -w '%{http_code}\n' https://klock.prod-orm.io/v2/keys
200klock.prod-orm.io as a custom domain. /v2/keys answers 503no_keyset) instead of 200 until the Worker can reach the API over RPC,API to the control plane's ApiInternalPairingRoom Durable Object (one perCONSOLE_ORIGIN if the consolehttps://app.prod-orm.io; it is the origin of the #/pair link aKLOCK in the API first (step 7), then roll the WorkercheckInBy and exp; theapi/wrangler.jsonc:services entry{ "binding": "KLOCK", "service": "prorm-klock", "entrypoint": "KlockInternal" }.KLOCK_GRANT_KEY and API_RPC_KEY entries, which aresecrets_store_secrets, once theKLOCK_RPC_PUB in vars (declared, empty)."services": [
{ "binding": "KLOCK", "service": "prorm-klock", "entrypoint": "KlockInternal" }
],
"secrets_store_secrets": [
// … the existing entries …
{ "binding": "KLOCK_GRANT_KEY", "store_id": "<store id>", "secret_name": "KLOCK_GRANT_KEY" },
{ "binding": "API_RPC_KEY", "store_id": "<store id>", "secret_name": "API_RPC_KEY" }
],
"vars": {
// … the existing vars …
"KLOCK_RPC_PUB": "<b64url public half of KLOCK_RPC_KEY>"
},[!WARNING] KLOCK_RPC_PUBmust be the public half of the klock Worker'sKLOCK_RPC_KEY, and the klock Worker'sAPI_RPC_PUBthe public half ofAPI_RPC_KEY. Crossing them, or leaving either empty, fails closed: every
RPC call is refused and device management answers503 klock_unconfigured.
$ curl -s https://api.prod-orm.io/.well-known/klock-configuration | jq .v2
true
$ prorm login
$ prorm klock sync
$ prorm devices listprorm login should sign in with v2 (it prints klock v2 interactive
session); if it falls back to a legacy token, v2 was still false when itklock sync proves the klock Worker, and devices list the RPC keys inDEVICE_GUARD Durable Object (single-use request nonces) is already boundv3)./security/devices…, the team and staff503 klock_unconfigured until both the KLOCK bindingAPI_RPC_KEY are in place; the CLI and console show that as "comingApiInternal.compiledRules andApiInternal.recordFindings, to evaluate access rules and behaviourKLOCK_RPC_PUB is wrong or empty, sync gives no grantgrantError: "policy_unavailable"), and enrolment and credential issue503 policy_unavailable. A healthy prorm klock sync therefore alsoworkers/mcp/wrangler.toml binds API to the controlprorm-control-plane), which it uses for /me/capabilities, the/internal/mcp/forward. API_TRANSPORT = "url"API_URL. Set the optionalMCP_CONFIRM_KEY (the HMAC key for the confirmation tokens it giveswrangler secret put MCP_CONFIRM_KEY. Never setPRORM_KLOCK_ROOTS there in production. Until a keyset is published it503 no_keyset, like the API and the klock service.503.api/middleware/capabilities.js:| Variable | Values | Default |
|---|---|---|
CAPABILITY_ENFORCEMENT | off, report, enforce (or an area list) | off |
CAPABILITY_ENFORCEMENT_AREAS | a comma list of area or area=mode for resource, team, admin, for example resource=enforce,team=report | empty |
CAPABILITY_E3 | 1 (also true, yes, on) | off |
off skips the ceiling checks entirely, so an existing account sees exactlyreport records access.would-deny in the audit log and lets the requestenforce answers 403 capability_missing with {needs, unlock}, orstep_up_required.area=mode in the list wins; a barearea takes CAPABILITY_ENFORCEMENT (or enforce when that is not aCAPABILITY_ENFORCEMENT when it is a mode,report when only a list is set, and off when nothing is set. The fullreport even under enforce untilCAPABILITY_E3=1. A legacy CLI admin token carries no admin:* by design,[!WARNING] Changed in wave S2 CAPABILITY_ENFORCEMENT=enforcewithCAPABILITY_ENFORCEMENT_AREAS=admin
used to mean "admin enforced, everything else off". Unlisted areas now take
the base mode, so that pair enforces every area. WriteCAPABILITY_ENFORCEMENT_AREAS=admin=enforceinstead, which leaves the rest
atreport. Check your production vars before you deploy this API.
CAPABILITY_ENFORCEMENT=report, no areas. Watch access.would-deny for$ bun run d1 --command "SELECT json_extract(detail, '$.area') AS area, json_extract(detail, '$.route') AS route, json_extract(detail, '$.needs') AS needs, json_extract(detail, '$.unlock') AS unlock, COUNT(*) AS hits FROM audit_log WHERE action = 'access.would-deny' GROUP BY area, route, needs, unlock ORDER BY hits DESC LIMIT 20"unlock step-up under the admin area are admins still onupgrade are plan limits working as designed.CAPABILITY_ENFORCEMENT_AREAS=resource=enforce (unsetCAPABILITY_ENFORCEMENT). Team and admin keep reporting.CAPABILITY_ENFORCEMENT_AREAS=resource=enforce,team=enforce.prorm login) andprorm security mfa enable):CAPABILITY_E3=1 andCAPABILITY_ENFORCEMENT_AREAS=resource=enforce,team=enforce,admin=enforce.CAPABILITY_E3=1 also changes behaviour outside the admin area/admin/* route (and /proxy/status) refuses an admin without a403 mfa_required, with the enrolment routes;POST /security/sessions/cli and the v1 sign-in relay answer410 login_v2_required, except an explicit CI tokenpurpose: "token", 30 days at most). Update CI that mints tokens throughpurpose: "token";GET /provision/databases and /stores default to scope=all.[!WARNING] Do not enforce admin early
SettingCAPABILITY_E3=1before every admin has a v2 session and a
confirmed TOTP factor refuses them: an admin without a factor getsmfa_requiredon every staff route (they can still enrol, since/security/mfa/*is their own), and one still on a legacy CLI token has noadmin:*capability. Customers are not affected.
CAPABILITY_ENFORCEMENT=report is always safe.triggers.crons: ["17 * * * *"] inwrangler.klock.jsonc, and wrangler deploy registers it. Its scheduledmaintain (klock/lib/maintenance.ts), two independent passes| Pass | Removes |
|---|---|
Retention (prune) | klock_sync_log rows older than 30 days; spent RPC nonces older than the 5-minute replay window; redeemed enrolment-ticket nonces a day after the ticket expired |
Transfer sweep (sweepTransfers) | Expires transfers past their 24 hours, cancels those whose sender or recipient device was revoked or wiped, deletes ended transfers' R2 objects (nothing while RELAY is unbound), and prunes spent transfer nonces |
klock: pruned N sync log rows; watch for it withwrangler tail -c wrangler.klock.jsonc.klock_revocations (API D1) is kept for 8 days and pruned by the API'striggers block and redeploy. Nothing breaksapi/kerberos, at KERBEROS_AUTHORITY_URL), which the API calls withKERBEROS_AUTHORITY_TOKEN. Deploy the authority build from this release,| Route | Does |
|---|---|
POST [/{team}]/v1/csr | Signs a device's CSR. Body { csr, subject, uris, spkiSha256, hours }. It checks the CSR's self-signature, that the key is P-256, the exact common name (device/<id> or session/<uuid>), the exact URI SANs and the attested key hash, and refuses any extension but subjectAltName and any extra subject field. hours is 1 to 48 (12 by default). 201 { cert, ca, chain, serial, fingerprint, subject, issuer, notBefore, notAfter }: no key is returned, because none is made. 422 { code } for csr_invalid, csr_key_type or csr_subject_mismatch. |
POST [/{team}]/v1/principals/{session}/rotate | A new random key for a principal (the kvno rises) and its keytab. 404 for a principal that does not exist: rotation never creates one. |
POST /v1/certificates is deprecated: it makes the client's key on the--auth tls.KLOCK_AUTHORITY_TOKEN (authority environment) is a second,AUTHORITY_TOKEN. It cannot make principals, realms or server-keyed/v1/csr.$ prorm klock certs request
$ prorm klock certs listauthority -dev) returns bytes that are not a keytab, soauthority_unavailable); try Kerberos/v2/credentials/* then answersauthority_unavailable; v1 --auth tls keeps working on /v1/certificates.| Setting | What | Without it |
|---|---|---|
STRIPE_SECRET_KEY | Secret, in the Secrets Store | Checkout, portal, card and plan routes answer 503 (account and team alike) |
STRIPE_WEBHOOK_SECRET | Secret, the webhook's signing secret | The webhook refuses events, so a team's plan never moves |
STRIPE_PRICE_USAGE | The usage price id | Checkout answers 503 ("no billing cycle can be started") |
STRIPE_PRICE_MEMBERSHIP | The membership price id | Membership checkout and plan: membership answer 503 |
STRIPE_FAKE | Tests only. An in-process Stripe fake that logs [stripe:fake] …. It refuses to switch on beside a live key (sk_live_/rk_live_). | Never set it in production |
STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are not bound inwrangler.jsonc today; add them to secrets_store_secrets once the storePOST /billing/webhook) must receive:checkout.session.completed (including setup mode, for new cards);customer.subscription.created, .updated and .deleted;invoice.created, invoice.paid and invoice.payment_failed.402nbf now), switch signing to the new key after 24 hours, and drop the old$ bun run scripts/klock-keyset.ts keygen --use grant --out grant-2.key
$ bun run scripts/klock-keyset.ts rotate --current keyset-1.jws --root-key root-2026.key --add grant-2.key.pub.json --retire g-202609-3fa1 --out keyset-2.jws
$ bun run scripts/klock-keyset.ts verify --keyset keyset-2.jws --min-seq 2rotate verifies the current keyset with the root first (an expired oneseq by one, extends the kept keys--add keys now, and ends a --retired key after--retire g-202609-3fa1=3 for three). KeysKLOCK_GRANT_KEYkidrotate … --drop <kid>, which removes it at once). Clients pick it up on