feat(cms): accept a site token on PUT /api/cms/:site/schema
What is this contribution about?
A hosted site's CI can now run deco schema push with the same site token the site passes to createCMS({ token }). Before this, site tokens only authenticated telemetry at the edge, and every /api/cms/:site/* route required a user or API-key caller with org membership, so the CLI got 401 with a valid site token.
- Verification (
hosted/site-token.ts):verifySiteTokenchecks the compact JWS (alg: EdDSA, payload{site, kid, iat}validated with a schema, at most 2 KB) against the public half ofsiteTokenSigningKey(Ed25519). - Scope (
api/routes/cms.ts): onlyPUT /:site/schemaaccepts a site token. The gate matches that route exactly (method plus segment count). The user-only handlers also refuse a site-token caller (userOf). - Gates for a site token:
payload.sitemust equal:site, the site must exist, its org must not be archived, and the kid must be in the site's issued records (site-tokens:<site>in org KV) and not revoked. All of these refusals return the same401 invalid_site_token, so a token for another site gets the same answer as an unknown site. The existing checks run after that, in today's order: plan gate (403), blocked org (403org_blocked), delivery bucket (503), Blocks v8 switch (409), size limit (413). The project scope check is skipped because the token is already bound to its site. The plan is read as the project's creator, the way the scheduled sweeps do it. - Revocation: a new
DELETE /api/cms/:site/site-tokens/:kid(user-only, 404site_token_not_foundwhen the kid is unknown or already revoked) and a revoke button with a confirm dialog in the site token settings. Revoke writes asite-tokens-revoked:<site>:<kid>marker and then removes the record. The records list is a read-modify-write with no compare-and-swap, so without the marker anissueracing a revoke could write the revoked record back. Telemetry at the edge stays signature-only. - Audit: every schema push logs
cms: schema pushedwith the site, org, ETag, and eithervia: "site-token"with thekid, or the user'sviaand id.CmsViais unchanged because thecms_drafts.viacolumn has a CHECK constraint and a site token never makes a draft. - CLI:
deco schema pushlives indecocms/blocks. It already sendsauthorization: Bearer $DECO_TOKENon the PUT, so the only change there is to its help text (companion PR).
Security trade-off
Before this change, a leaked site token could only spoof telemetry. Now it can also replace the site's schema, which changes how Studio draws content forms. It cannot touch content, drafts, publishes, rollbacks, or other tokens, and it only works for its own site. Site tokens still never expire. Revocation is the control: it takes effect on the next push and does not stop telemetry. Site tokens live as server secrets on the site, so this widens a secret's blast radius by one low-impact write in exchange for not needing a personal API key in CI. A few things are left open. A site token is first probed as an API key by the auth middleware, which logs one invalid API key (Bearer) warning per push. Revocation markers and records are not cleaned up when a site is deleted (same as the records today). Revoking from the UI is open to anyone who can list tokens (same as issuing).
How did you verify your code works?
New tests:
apps/api/src/api/routes/cms.test.ts, describe "schema push with a site token":- a valid token for the site gets 200, the schema is stored, and the audit log has
via: "site-token"and the kid; - a token for another site gets 401 with the same body as an unknown site;
- a revoked kid gets 401, while a second live token still pushes;
- a site token can't call the revoke route, and revoking twice returns 404;
- a kid the site never issued gets 401;
- a bad signature, a tampered payload, a foreign key, and malformed input all get 401;
- a site token gets 401 on drafts (list/create/get/delete/rpc), publish, releases, rollback, site-tokens, GET schema, and a draft path ending in
/schema; - the v8 switch (409), size (413), blocked org (403), and plan (403) checks still apply, and an archived org gets 401;
- with no bearer the response is still 401
Unauthorized.
- a valid token for the site gets 200, the schema is stored, and the audit log has
apps/api/src/hosted/site-token.test.ts: verify round-trip; rejection of a foreign key, a changedsite,alg: none, missing claims, an empty, extra, or invalid-character segment, an oversized token, and an empty string; revoke andisLivescoped to org and site; a revoke stays revoked when a racing issue writes the stale list back.apps/web/src/views/virtual-mcp/settings/site-token-section.test.tsx: revoke asks for confirmation, sendsDELETE /api/cms/<site>/site-tokens/<kid>, and the row disappears.
apps/api: bun test src/api/routes/cms.test.ts src/hosted/site-token.test.ts
50 pass
0 fail
Ran 50 tests across 2 files.
apps/web: bun test src/views/virtual-mcp/settings/site-token-section.test.tsx
3 pass
0 fail
Ran 3 tests across 1 file.
Also ran bun run fmt, bun run lint, bun run knip, and tsc --noEmit in apps/api and apps/web; all clean. I did not run the full bun run test locally (it ran out of memory on my machine); CI runs it.
Screenshots/Demonstration
Not captured: I didn't run the app locally for this PR. The UI change is a trash-icon button on each token row in Project settings → CMS → Site tokens, and a confirm dialog ("Revoke this site token?").
How to Test
- In a project with "Blocks v8 site" on, issue a site token in the site token settings.
- In the site repo:
DECO_SITE=<site> DECO_TOKEN=<site token> npx deco schema pushshould succeed. - Revoke the token in settings and push again: the CLI should get 401 (
invalid_site_token). curl -X POST -H "Authorization: Bearer <site token>" https://<studio>/api/cms/<site>/drafts -d '{"name":"x"}'should return 401.
Migration Notes
None. Uses existing org KV keys and the existing DECO_SITE_TOKEN_SIGNING_KEY.
Review Checklist
- PR title is clear and descriptive
- Changes are tested and working
- Documentation is updated (if needed): header comments in
cms.tsandsite-token.ts; CLI help in the companiondecocms/blocksPR - No breaking changes
Summary by cubic
Hosted site CI can now run deco schema push with the same site token the site passes to createCMS({ token }). Previously site tokens only authenticated telemetry at the edge, so every CMS route required a user or API-key caller and the CLI got 401 with a valid token.
- Site tokens are verified by EdDSA signature, must match the path's site, and need a kid the site issued and hasn't revoked; all refusals return the same
401 invalid_site_token. - Only
PUT /:site/schemaaccepts a site token; every other CMS route answers a token caller 401. - Adds
DELETE /api/cms/:site/site-tokens/:kidand a revoke button with a confirm dialog. Revocation takes effect on the next push and doesn't stop telemetry. - Each schema push logs
cms: schema pushedwith site, org, ETag, and eithervia: "site-token"plus kid, or the user's via and id. - A leaked token can now also replace the site's schema, which changes how Studio draws content forms; it can't touch content, drafts, publishes, rollbacks, or other tokens.