Skip to content

feat(site-editor): hosted Deco CMS v8 — publish to CDN, CDN drafts, releases, site tokens - #7766

Closed
tlgimenes wants to merge 28 commits into
feat/sandbox-content-protocolfrom
feat/blocks-v8-hosted
Closed

tlgimenes wants to merge 28 commits into
feat/sandbox-content-protocolfrom
feat/blocks-v8-hosted

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Stack

One Studio stack, each PR based on the one before it:

#7796 (org_sites project link, base: main) → #7728 (blocks v8 support) → #7770 (sandbox content protocol) → #7766 (hosted)

This PR: #7766, based on #7770.

Restack onto #7796

What

Hosted Deco CMS for Blocks v8 projects on GitHub (blocksMajor === 8, org flag site_editor_content_protocol). v7 is unchanged.

  • Site ownership. Every hosted path (the hosted routes, the hosted branches of decofile.ts and the sandbox-less git routes in sandbox-proxy.ts) uses the project's org_sites link from feat(sites): link org_sites to projects — site ids are immutable and never reused #7796 (ownedProjectSite(orgSites, projectId, orgId) in hosted/scope.ts, which reads getByProject), never metadata.siteSlug. A project that isn't linked to a site its own org owns has no hosted features (404), so nobody can write another org's delivery objects or drafts, or mint its tokens. The slug itself can't change (feat(sites): link org_sites to projects — site ids are immutable and never reused #7796 refuses it in the API). The hosted routes also apply project-scoped roles (isProjectAllowed).

  • Site links. Creating or importing a project links it to its metadata.siteSlug once (linkProjectSite / claimProjectSite in hosted/claim-site.ts). A slug no org owns is claimed for the project's org first, then linked. Nothing is linked (project creation still succeeds, the slug stays unlinked and gets no hosted features) when the slug is reserved (its org was deleted: never reused), another org owns it, another project already has it, the project already has a different site, deco.cx has a site by that name (the deco import claims those after proving access), or a project of another org already names an unowned slug. The deco.cx lookup is bounded at 3 s and fails closed. POST /api/_admin/hosted/site-claims/backfill links existing v8 projects the same way: dry run unless {"dryRun": false}, idempotent, and it reports linked, alreadyLinked, refused (with the reason) and ambiguous (an unowned slug several orgs name, or a slug several projects of one org name with none linked yet).

  • CDN drafts. The v8 editor's saves go to sites/<site>/drafts/<slug>.json ({ set, delete }, Cache-Control: no-cache, max-age=0, must-revalidate) on the delivery bucket through its S3 API. No git commit; the last writer wins. /rpc now reads main with the draft layered on (hosted/draft-content-storage.ts). The slug is random (16 bytes, base64url) and kept in the org KV per (project, branch). The preview pointer is <delivery host>/sites/<site>/drafts/<slug>.json@<etag>, answered by the session GET /decofile/:vmcp/:branch for a v8 project.

  • Publish. POST /decofile/:vmcp/:branch/publish for a v8 project: commit the draft directly to main (fast-forward only) → delete the draft (the merge is done) → write the commit's companion release revisions/<sha>.json ({ revision, schemaHash, blocks }, immutable) → make it current: re-read main's head and, only if it is still that commit, PUT latest.json ({ revision, schemaHash, publishedAt }) and purge that URL from Cloudflare's edge (5 s timeout per attempt, exactly one retry, no restore). A failed commit fails Publish and keeps the draft. The answer is { result: "merged", sha, release: "current" | "created" | "none" } → "Merged · Current on the CDN" / "Merged · release created, making it current failed — use Make current on Releases" / "Merged · no release created (the next Publish includes these changes)". A draft that commits nothing (empty, or byte-equal to main; such blocks are also dropped from the popover's change list) answers { result: "up-to-date" } → "Up to date, nothing to publish", deletes the draft and never touches latest.json. Only the published save is deleted (ETag re-checked under the draft lock); a save made during the publish keeps the draft. schemaHash = sha256Hex(canonicalJson(schema.gen.json)), pinned to the same test vector as deco content.

  • Releases (new project view next to Assets): a timeline of main's commits (GitHub integration, one page of 50), newest first, joined to one R2 listing of sites/<site>/revisions/ per page (no per-commit HEADs). A commit with a companion release has Make current; one without (a developer push, or a Publish whose release write failed) is just a merge: no badge, no action. Current marks exactly the commit latest.json names, read from latest.json, never inferred; with no latest.json the header says "Nothing on the CDN yet". Make current writes latest.json for that companion and purges it; it succeeds or fails at once (502 { error: "latest-update-failed" }, shown as a localized toast), and the screen refetches. The Current row keeps Make current too, so a failed purge can be retried on the commit latest.json already names, so Current shows whatever latest.json says. It warns when the target's schemaHash differs from main's. Every latest.json write sets publishedAt to now: the SDK prefers the CDN only when publishedAt is later than its bundle's build time.

  • Site tokens (Settings → Site): issue and list, for v8 sites only (issuing checks main's schema). An Ed25519 JWS {site, kid, iat}, shown once, signed with DECO_SITE_TOKEN_SIGNING_KEY. Issuing always works (no limit). There is no revocation and no kill switch (product owner decision): the edge only verifies the signature and stamps rows with the token's site.

  • The managed-asset CDN default moves from decoims.com to assets.decocms.com.

  • Deleted: the v8 git-branch draft machinery: GET /:branch/changes, draft-changes.ts and its tests, mergeBase on the content clients, the v8 write side of repo-content-storage.ts (it is now a read-only reader of main), the v8 "/changes" pointer suffix and the draft token for v8.

The publish popover keeps working unchanged: for a v8 project the sandbox-less /git/status|diff|discard routes answer from the draft (hosted/draft-publish-status.ts: loadHostedDraft, hostedDraftPublishStatus, hostedDraftPublishDiff; routes and response shapes unchanged).

New Studio env (infra)

  • DELIVERY_R2_ACCOUNT_ID, DELIVERY_R2_ACCESS_KEY_ID, DELIVERY_R2_SECRET_ACCESS_KEY, DELIVERY_R2_BUCKET
  • DECO_SITE_TOKEN_SIGNING_KEY (Ed25519 private key, base64 PKCS8)
  • CF_DELIVERY_ZONE_ID (zone decocms.com), CF_PURGE_API_TOKEN (Zone → Cache Purge on it). Unset, the latest.json purge is skipped with a warning (local/dev/e2e); publish never fails for it.
  • Test-only and undocumented: DELIVERY_R2_ENDPOINT, DELIVERY_PUBLIC_ORIGIN

Without the delivery settings, the v8 hosted routes (and /rpc) answer 503 "hosted delivery not configured".

OPEN (smallest choice taken, marked // OPEN: in code)

  • O-S1: the draft slug is kept in the org KV as v8-draft:<project>:<branch>, so there's no migration. Saves in one process are serialized per draft; across pods the last writer wins.
  • O-S2: site-token records {kid, iat} are kept in the org KV as site-tokens:<site>.
  • O-S3: a v8 project's session GET /decofile/:vmcp/:branch answers { draft, version } in place of v7's decofile, token and API host. For v8 detection it reads main's schema.gen.json, but only on flag-on orgs with delivery configured.
  • O-S4: Releases, Make current and site tokens are Studio-internal routes under /api/:org/hosted/:vmcp/*. Publish reuses /decofile/.../publish.
  • O-S5: listCommits needs since. It is the epoch, paged 50 at a time by cursor.
  • O-S6: the signing key is base64 PKCS8 (openssl genpkey -algorithm ed25519).
  • O-S7: Make current reads the revision object to get its schemaHash. There is no R2 metadata field.
  • O-9 / O-10 / O-11 / O-26:
    • A non-fast-forward stops with "main moved" and no retry.
    • The latest.json PUT is plain after the head check.
    • The draft is deleted once git has the commit, even when delivery failed.
    • An empty draft answers the existing no-changes state.
  • O-15: kid is 16 random bytes, base64url.
  • O-22: "staff only" is the existing deployment-admin fence (/api/_admin, DEPLOYMENT_ADMIN_EMAILS), not an @deco.cx check.
  • draftGitDiscard kept its name (the decision renamed only status/diff).
  • Review mode: "submit for review" (publish policy) has no hosted equivalent, because the draft isn't a branch, so there is no PR to open. For a v8 project the header menu and the popover don't offer it.
  • Unclaimed site: a v8 project whose siteSlug couldn't be claimed (a conflict above, or a slug changed later via COLLECTION_VIRTUAL_MCP_UPDATE) gets no hosted features until an admin claims it (// OPEN: in hosted/scope.ts).
  • Hosted publish-popover adapter: kept per the PO and renamed, no behaviour change: hosted/draft-git-compat.ts → hosted/draft-publish-status.ts (+test), fastPreviewHostedDraft → loadHostedDraft (now in hosted/; sandbox-proxy.ts keeps a thin call), draftGitStatus/draftGitDiff → hostedDraftPublishStatus/hostedDraftPublishDiff. It's reached only by the sandbox-less Fast Preview session; sandbox sessions of v8 projects edit over the daemon's /_sandbox/rpc.
  • No v8 probe in the web: the Releases view and the Site tokens card show for flag-on orgs on projects with a repo and a site id. A v7 site gets a "not a Blocks v8 site" error in Releases and when issuing a token.
  • Generated contracts: tool-io.ts was hand-patched (adding "releases" to the sidebar-view enum). generate:tool-contracts produced a broken file in this environment, so it should be regenerated in CI or a clean checkout.

Rollout

Run the backfill with {"dryRun": false} right after deploying, before announcing the feature: until it runs, an existing v8 project's site is unclaimed, and although the create path no longer claims a slug another org's project names, those existing projects get no hosted features until claimed. Do a dry run first and resolve the reported conflicts/ambiguous slugs by hand (admin claim).

Verification

  • apps/api tsc --noEmit, apps/web / packages/e2e / packages/shared typecheck, oxlint, knip, biome format: pass.
  • Unit tests: bun run test: 10493 pass. One failure, duplicate-check.test.ts (task-board), passes when run on its own. 41 new tests in apps/api/src/hosted/, covering:
    • keys and validation;
    • the schemaHash vector shared with deco content;
    • draft layering and the §9 rules;
    • publish order, the three merged outcomes, a no-change publish leaving the pointer, main-moved;
    • the releases timeline (companions, Current from latest.json, missing latest.json, one listing) and make-current with the schema warning;
    • JWS sign and verify, and issuing with no limit;
    • a save made during a publish keeps the draft;
    • the draft git compat.
  • e2e, one worker:
    • content-protocol-github.spec.ts: 12/12 passed, against a new in-memory delivery stub (S3 API plus the public origin with ETag/304) and a GraphQL commit-history answer on the GitHub stub. It covers conformance, saves into the CDN draft, the pointer with 304 revalidation, publish/releases/rollback/resync, and site tokens.
    • Related specs (decofile-api, cms-publish-*, fast-preview-git-sync, content-protocol-deco-serve): 28 passed. 1 failed, deco-serve › refuses a link to a server off this machine: the anonymous /site-editor page was still on the splash after 5 s. That code isn't touched here, and it wasn't reproduced on the base branch.
  • Review round (ownership, project scope, publish/save race, v8-only tokens, no review mode for v8): apps/api and apps/web typecheck, oxlint, biome format, and the hosted, decofile and route unit tests (207 pass). A new e2e case checks that a siteSlug the org doesn't own gets 404 on the hosted routes; the e2e fixture claims each test site in org_sites. (Since the restack: changing a project's site is refused, and its hosted keys stay on the linked site.) content-protocol-github.spec.ts: 13/13 passed after merging the base branch. The GitHub ETag cache always revalidates (If-None-Match on every GET), so the publish head check never reads a stale head.
  • Claim fixes (no claim of a slug another org's project names; 3 s bound on the deco.cx lookup): apps/api typecheck, oxlint, biome format, apps/api/src/hosted unit tests (57 pass, incl. 2 new claimProjectSite cases); the connections query was checked read-only against a local Postgres; bun run test 10513 pass, 1 fail (hosted-harness-workflow.test.ts heartbeat timing; passes alone); e2e content-protocol-github.spec.ts 14/14 passed.
  • Releases rule / Resync / publishedAt / adapter rename: apps/api, apps/web, packages/e2e typecheck, oxlint, biome format: pass. apps/api/src/hosted unit tests 67 pass (new: the state rule incl. developer push, older revision, revision off main, the 5×50 walk cap with "no CMS-published release", Resync without asking over a developer push and asking over a rollback, reusing an existing revision, publishedAt stamped on Publish/Make current/Resync). bun run test 10523 pass, 1 fail (circuit-breaker.test.ts timing; passes alone). e2e content-protocol-github.spec.ts (one worker): 14/14 passed, now also asserting the new fields and that rollback and Resync restamp publishedAt.
  • Not done: screenshots of the Releases view and the token panel; the end-to-end hosted flow against a real storefront build.

On published @decocms/[email protected]

  • Merged feat/blocks-v8-support (4dfe9ce, then again with main after it): @decocms/blocks is pinned exactly at 8.1.0-next.7 (published, dist-tag next) in api, web and e2e; bun.lock differs only in the @decocms/blocks entries. next.7 is the SDK side of hosted releases (reads latest.json, swaps only when publishedAt is newer than the bundle's committedAt and schemaHash matches).
  • walkMain in hosted/releases.ts is no longer exported (it's only used in that file; knip flagged it) (40ed33b).

latest.json edge cache + purge (1ca3832, fail fast e0b4fd7, one retry e84cb5d)

  • latest.json is written with Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate: the edge holds it up to 1 h, browsers and servers revalidate every poll (ETag). Revisions stay public, max-age=31536000, immutable, drafts no-cache, max-age=0, must-revalidate. No new cache rule: the existing decocms.com rule respects origin headers.
  • After every latest.json write (Publish, Make current; Resync was later removed) Studio purges exactly <delivery origin>/sites/<site>/latest.json via POST /zones/<zone>/purge_cache {"files":[…]} (hosted/delivery-purge.ts): each attempt bounded by AbortSignal.timeout(5000) (a timed-out attempt counts as failed), and exactly one retry: two attempts at most. Order: commit → revision → head check → latest.json → purge. Fail fast: a failed latest.json write (the purge is then skipped) or a purge whose two attempts both failed (or timed out) throws LatestUpdateError, with no restore of the previous pointer and no automatic recovery. Publish: the commit stands and Publish is done (draft already deleted), answering cdn: "failed". Make current / Resync: 502 with a clear message ending in "Try again", shown as the existing error toast. The user retries from Studio (Resync, Make current); the 1 h s-maxage still bounds how stale the edge can be.
  • Tests (the Publish outcomes below were superseded by 543af34: "pending, draft kept" is now merged with cdn: "failed" and the draft deleted): exact header values; the purge request (endpoint, bearer, exact URL, configured origin, a timeout signal); first attempt fails + retry succeeds → success (two calls); both attempts fail → failure after exactly two calls; 200 without success fails; a timed-out attempt counts as failed and is retried; both time out → failure after two calls; skip with a warning when unconfigured; purge logged right after each pointer write on Publish, Resync and Make current; purge failure → Publish pending, one purge call, no restore, draft kept; with the real purge, both attempts failing → Publish pending after exactly two calls, no restore, draft kept, and a successful retry → published, draft deleted; latest.json write failure → no purge, pending, draft kept; Resync and Make current failures surface LatestUpdateError ("Try again") with no restore, and a Resync retry succeeds; success path unchanged (draft deleted). apps/api/src/hosted 85 pass; apps/api tsc --noEmit, oxlint, biome format, knip, apps/web tsc --noEmit: pass.
  • CI on 1ca3832: Gitleaks, SAST, Multi-Pod pass (multi-pod's rollout churn thread scenario timed out once, unrelated to hosted, and passed on rerun). Test/e2e workflows only run on PRs to main, so they don't run on this stacked PR.

Publish is done once merged (543af34)

Product decision: "the publish flow is considered as 'done' when the merge is successful."

  • hosted/publish.ts: the draft is deleted right after a successful commit, before the CDN step; the CDN step's failure no longer fails Publish. PublishResult is { result: "merged"; sha; cdn: "live" | "failed" } | { result: "up-to-date" } (was published/pending).
  • hosted/releases.ts: ReleaseState is live | failed | rolled-back, derived by releaseState(current, head, headDate). Dropped walkMain, HISTORY_WINDOW and the unpublishedCommits / revisionOffMain / noRecentRelease flags. Resync returns { sha, cdn }.
  • Web: the popover no longer has an in-place Resync (the draft is gone after a merge); it closes with a success or warning toast. Releases rows show Merged, Live on the served commit, and Failed + Resync on the head when it isn't live. i18n (en, pt-br) updated.
  • Known limit: a purge that fails after latest.json was written still reads Live on Releases (the pointer names the head), while the edge may serve the old pointer for up to 1 h. A developer's push (or one with an old committer date) shows Failed until resynced.
  • Tests: apps/api/src/hosted 85 pass (draft deleted after the commit when the revision write, latest.json write or purge fails, or main moved; draft kept when the commit fails; Merged·Live vs Merged·Failed results; derived state for live / newest-not-live / missing latest.json / rolled back / whole-second compare; Resync flips to Live). apps/web related tests 603 pass. tsc --noEmit (api, web), oxlint, biome format, knip: pass.

Releases is a timeline; Current is what latest.json names (796b9e9)

Product decision: "current" means current on the CDN; a merge without a publish is just a merge.

  • Removed: the derived ReleaseState (Live / Failed / Rolled back / Not live), the commit-date heuristic, releaseStatus, the "Failed · Resync" badge, Resync (route POST /hosted/:vmcp/resync, resync(), RolledBackError, useResync, its dialog and i18n). Make current on the newest release covers what Resync did; commits without a release are not meant to get one after the fact.
  • Kept: the head check before Publish writes latest.json (a slower Publish must not move the pointer back past a newer commit). When it trips, the result is release: "created".
  • ReleasesPage is { current, commits: [{ sha, date, message, author, hasRelease }], nextCursor }.
  • draftFileChanges skips a block whose serialized content is byte-equal to main's file, so a no-change publish commits nothing.
  • Tests: apps/api/src/hosted 78 pass (timeline with/without companions, Current from latest.json, missing latest.json, one prefix listing and one latest.json read per page; Make current success, failed purge, failed write; Publish current / pointer fails / main moved / companion fails / no-change and empty drafts leave the pointer). tsc --noEmit (api, web), oxlint, biome format, knip: pass. The e2e hosted spec was updated to the new shapes (not run locally).

Review fixes (eea8156)

  • The Current row shows the badge and Make current (when it has a release), so a failed purge after latest.json was written can be retried from Releases; the API already re-purges the current sha.
  • A failed latest.json write or purge on Make current returns { error: "latest-update-failed" }; the web maps it to releases.makeCurrentFailed (en "Making it current failed. Try again." / pt-br "Não foi possível torná-la atual. Tente de novo.").
  • e2e: the not-owned-siteSlug test now asserts 404 on GET /releases and POST /releases/current (the removed /resync route always 404'd).
  • Checks: apps/api/src/hosted 78 pass; tsc --noEmit (api, web), oxlint, biome format, knip (api, web): pass; web i18n/tab tests 261 pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig

tlgimenes and others added 2 commits October 6, 2026 23:37
…eleases, site tokens

For Blocks v8 projects on GitHub behind site_editor_content_protocol (v7 is
unchanged):

- Editor saves go to sites/<site>/drafts/<slug>.json on the delivery bucket
  (R2, S3 API), never git; /rpc reads main with the draft layered on, and the
  session read answers the draft pointer (<host>/…/<slug>.json@<etag>).
- Publish commits the draft directly to main (fast-forward only), writes
  revisions/<sha>.json, re-checks main's head, writes latest.json and deletes
  the draft; a release that isn't live yet is "pending" with Resync.
- A Releases view next to Assets: what latest.json serves, main's history,
  "Make current" for commits with a revision (rewrites latest.json only),
  the Rolled back state and the schemaHash warning.
- Site tokens (Ed25519 JWS {site, kid, iat}, two active at most) with revoke
  through the Cloudflare KV denylist, and a deployment-admin kill switch
  writing kill:<site>.
- The managed-asset CDN default moves to assets.decocms.com.
- Removes the v8 git-branch draft machinery (/changes, draft-changes,
  mergeBase, the v8 write side of repo-content-storage).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions github-actions Bot added the claude PR authored by a coding agent label Oct 7, 2026
tlgimenes and others added 5 commits October 7, 2026 00:28
…e shim

- apps/api (dev), apps/web and packages/e2e pin 8.1.0-next.6 exactly;
  bun.lock changes only the @decocms/blocks entries (frozen install passes).
- The hosted draft storage no longer describes refs/idempotency, which
  next.6 removed from the protocol.
- e2e: a `ref` param on blocks.list is rejected again (Invalid params,
  an unknown parameter in next.6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ring publish

- Hosted routes, the hosted decofile branches and the sandbox-less git
  routes use metadata.siteSlug only when the org owns it in org_sites
  (siteSlug is member-editable); otherwise no hosted features (404).
- Hosted routes honour project-scoped roles (isProjectAllowed).
- Publish deletes the draft only while it is still the published save
  (ETag check under the draft lock); a save during publish is kept.
- Site tokens are issued for Blocks v8 sites only.
- Kill switch: state is read from the denylist (kill:<site>), the org KV
  copy is gone; it covers the sites the org owns in org_sites.
- Hosted v8 publish never offers submit for review (no pull request).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Takes the base's lockfile and its retirement of the docs workspace; keeps
this branch's repo-content-storage (no describe) and e2e.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… backfill

Creating a project (web create, GitHub import, deco import) now claims its
`metadata.siteSlug` in `org_sites` for the project's org, so the hosted
ownership check never blocks a legitimate project. Ownership also authorises
asset-storage credentials for `<slug>/*`, and `siteSlug` is member-editable
free text, so a slug is claimed only when no org owns it and deco.cx has no
site by that name (the deco import claims those after proving access).
Best-effort: a failed claim never fails the creation.

`POST /api/_admin/hosted/site-claims/backfill` claims the sites of existing
Blocks v8 projects. Dry run unless `{"dryRun": false}`; safe to re-run;
never takes a slug another org or deco.cx has (reported as conflicts); a free
slug two orgs' projects name is claimed for neither (ambiguous).

e2e: the fast-preview fixture no longer claims the slug itself, so the hosted
specs run on the auto-claim; a new case checks another org naming the same
site gets no claim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…nd the deco.cx lookup

Creating a project no longer claims an unowned siteSlug that a project of
another org already names (that org may hold assets under it from before
claims existed) — the same rule the backfill applies as `ambiguous`; it is
reported as a conflict. The deco.cx site lookup made during creation is
bounded at 3 s and fails closed (no claim) on timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
tlgimenes and others added 13 commits October 7, 2026 13:11
… same rule

- Rolled back = latest.json serves a revision older (in main's history)
  than the newest CMS-published one, or one no longer on main. A head the
  CMS didn't publish keeps the header Live with "main has unpublished
  commits". The search walks main 50 commits per page, at most 5 pages.
- Resync asks for confirmation only when the screen says Rolled back, and
  writes main head's revision only when it's missing.
- Every latest.json write stamps publishedAt now (the SDK's timeline rule).
- Rename the hosted publish-popover adapter (no behaviour change):
  draft-git-compat.ts -> draft-publish-status.ts, fastPreviewHostedDraft ->
  loadHostedDraft (in hosted/), draftGitStatus/draftGitDiff ->
  hostedDraftPublishStatus/hostedDraftPublishDiff.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
latest.json is now `public, max-age=0, s-maxage=3600, must-revalidate`:
the edge holds it up to an hour, readers revalidate every poll. After every
pointer write (Publish, Make current, Resync) Studio purges exactly
<delivery origin>/sites/<site>/latest.json through the zone's purge_cache
API, retrying with short backoff. A purge that still fails leaves Publish
pending; Resync reruns the pointer write and the purge. The 1 h s-maxage
heals a purge that never landed.

New settings CF_DELIVERY_ZONE_ID and CF_PURGE_API_TOKEN (Zone -> Cache
Purge); unset, the purge is skipped with a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
Updating the pointer is now: write latest.json, then ONE purge with a 5 s
timeout (AbortSignal.timeout). The purge retry loop is gone, and there is no
restore of the previous pointer or other automatic recovery. A failed write
(which skips the purge) or a failed/timed-out purge throws LatestUpdateError
at once:

- Publish: the git commit stands, the result is the existing "pending"
  state, and the draft is kept; it is deleted only after the pointer write
  and its purge both succeeded.
- Make current and Resync: answer 502 with a clear message that ends in
  "Try again".

The user retries from Studio (Resync, Make current, Publish). The popover's
pending text now says "Resync to try again". Unconfigured purge (no zone or
token) still skips with a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
PO follow-up to the fail-fast change: the purge now makes at most two
attempts, each bounded by AbortSignal.timeout(5000); a timed-out attempt
counts as failed. If both fail, the operation fails as before (no restore
of the previous pointer, no other recovery): Publish is pending with the
draft kept, Make current and Resync surface the error, and the user
retries from Studio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
…tatus

Publish deletes the draft right after the commit to main succeeds; the
CDN step (revision, head check, latest.json, purge) still runs but its
failure no longer fails Publish. The result is { result: "merged", sha,
cdn: "live" | "failed" } and the popover closes with "Merged · Live" or
"Merged · CDN update failed. Resync it from Releases."

Releases shows every commit as Merged; the CDN status is derived, not
stored: latest.json on main's head is Live; otherwise the newest release
shows Failed with Resync, unless latest.json was written after main's
head was committed (a Make current: Rolled back). Drops the history walk
and its flags (unpublished commits, revision off main, no recent release)
and the popover's in-place Resync.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
- Releases lists main's commits; a commit with a companion release
  (revisions/<sha>.json, one prefix listing per page) can be made current.
  Current is read from latest.json, never inferred; none means "Nothing on
  the CDN yet".
- Removed the derived states (Live/Failed/Rolled back/Not live), the
  commit-date heuristic and Resync (route, API and UI): Make current on the
  newest release covers it.
- Publish: merge, write the companion, make it current. The result says
  current / created (making it current failed) / none (no release). A
  draft that commits nothing (also when it equals main byte for byte)
  answers up-to-date, deletes the draft and leaves latest.json alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
The Current row now keeps its Make current action, so a failed CDN purge
can be retried on the commit latest.json already names. A failed
latest.json write or purge returns the code latest-update-failed, which
the Releases screen shows as a localized message. The e2e ownership test
checks the Releases routes instead of the removed resync route.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
The product owner removed the kill switch and token revocation: there is
no denylist. Studio still issues Ed25519 site tokens and the edge still
verifies them.

- Delete hosted/denylist.ts, hosted/kill-switch.ts and its test.
- Remove the deployment-admin hosted-kill routes and dialog.
- Remove DELETE .../site-tokens/:kid and the Revoke UI; issuing has no
  two-token limit any more (the limit only existed for revoke-and-rotate).
- Drop CF_ACCOUNT_ID, CF_DENYLIST_KV_NAMESPACE_ID, CF_KV_API_TOKEN settings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
tlgimenes and others added 4 commits October 8, 2026 10:59
…ship follows the org_sites link

Brings in #7796 (org_sites.project_id link, immutable site slugs) and main.

Hosted switch:
- ownedProjectSite(orgSites, projectId, orgId) reads the project's org_sites
  link (getByProject) instead of metadata.siteSlug. hosted routes,
  decofile scope and the sandbox-less draft status use it.
- claim-site.ts: a project's site is linked once (linkProjectSite): a slug no
  org owns is claimed first (still refused for deco.cx sites and slugs another
  org's project names), then linked. Tombstoned (reserved) slugs, another
  org's, another project's, or a second slug for a linked project are refused.
- Admin backfill links v8 projects (report: linked / alreadyLinked / refused /
  ambiguous); several projects of one org naming one slug are ambiguous.
- e2e: a slug change is refused and hosted keys stay on the linked site.

Conflicts:
- publish UI: main (#7792) replaced cms-publish-popover.tsx with
  publish-dialog.tsx; the hosted changes (no review mode, no Request approval,
  hosted publish action) moved into publish-dialog.tsx.
- main removed the project "git" tab; kept hosted's "releases" tab only.
- sandbox-proxy.ts: main dropped /git/suggest-commit; judge-review keeps the
  hosted-aware fastPreviewStatus/fastPreviewDiff backfill.
- create.ts: project creation goes through claimProjectSite (claim + link).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
Makes the Studio stack one line: #7796 -> #7728 -> #7770 -> #7766.
Conflict in content-protocol-api.ts (applyProtocolPatch doc): kept both the
hosted CDN-draft and the sandbox working-tree notes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
…m refusals

A row that vanished between claim and link is "not-found", not "other-org".
A used slug whose project is gone (or that predates the link) is refused as
"relink-requires-admin", matching the org_sites rule from #7796, in dry runs
too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
@tlgimenes
tlgimenes changed the base branch from feat/blocks-v8-support to feat/sandbox-content-protocol October 8, 2026 15:46
This was referenced Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude PR authored by a coding agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant