From 84d9332e058f43eae083328bed55323e42d67605 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:03:08 -0300 Subject: [PATCH 1/2] feat(api): /rpc, v8 draft pointer and publish; hosted routes Wires the hosted modules into routes: the decofile `/rpc` endpoint, the v8 draft pointer and publish on the session routes, the hosted routes (releases, site tokens), all behind the `site_editor_content_protocol` flag and scoped to the org that owns the site. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- apps/api/src/api/routes/decofile.ts | 207 +++++++++++++++++++-- apps/api/src/api/routes/hosted.ts | 249 ++++++++++++++++++++++++++ apps/api/src/api/routes/org-scoped.ts | 2 + apps/api/src/git-providers/clients.ts | 13 ++ 4 files changed, 460 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/api/routes/hosted.ts diff --git a/apps/api/src/api/routes/decofile.ts b/apps/api/src/api/routes/decofile.ts index 59bb521296..f3e40faca1 100644 --- a/apps/api/src/api/routes/decofile.ts +++ b/apps/api/src/api/routes/decofile.ts @@ -8,6 +8,7 @@ * PATCH /api/:org/decofile/:virtualMcpId/:branch write blocks (session) * POST /api/:org/decofile/:virtualMcpId/:branch/publish merge into default (session) * GET /api/:org/decofile/:virtualMcpId/:branch/status drift vs default (session) + * POST /api/:org/decofile/:virtualMcpId/:branch/rpc content protocol (session, flag) * * The surface is inert unless the virtual MCP has both a preview server URL * (`previewServerUrl`, legacy `productionUrl`) and a GitHub repo — what a CMS @@ -15,6 +16,15 @@ * does NOT gate it; that switch only picks the runtime a NEW thread is stamped * with, and gating this on it would strand an already-stamped session. * + * The content-protocol routes serve Blocks v8 sites on the hosted Deco CMS + * (see `hosted/`) and exist only behind the `site_editor_content_protocol` + * org flag. The editor's draft is an object on the delivery CDN + * (`hosted/draft-content-storage.ts`), never a git branch: `rpc` reads main + * with the draft layered on and saves into the draft; the session GET answers + * a v8 project's `?__draft=` pointer (`{ draft, version }`) instead of the + * decofile; publish commits the draft to main and releases it. `rpc` doesn't + * need a preview server: the protocol never renders. + * * Anonymous access: `resolveOrgFromPath` lets unauthenticated requests through * (membership is only enforced for signed-in principals), so the GET handler * self-enforces the signed draft token, mirroring automation-webhooks.ts. @@ -47,6 +57,18 @@ import { import { signDraftToken, verifyDraftToken } from "@/decofile/draft-token"; import { repoGitRebase } from "@/decofile/git-compat"; import { readDecofileSnapshot } from "@/decofile/read-decofile"; +import { + bareEtag, + deliveryStore, + draftPointerTarget, +} from "@/hosted/delivery-store"; +import { deliveryPurge } from "@/hosted/delivery-purge"; +import { createDraftContentStorage } from "@/hosted/draft-content-storage"; +import type { DraftStore, HostedDraftRef } from "@/hosted/draft-store"; +import { MainMovedError, publishDraft } from "@/hosted/publish"; +import { hostedDrafts, mainIsV8, ownedProjectSite } from "@/hosted/scope"; +import { createContentHandler } from "@decocms/blocks/protocol/server"; +import { orgFlagEnabled } from "@decocms/shared/organization/schema"; import { projectPlanningPostsForPreview } from "@/decofile/blog-draft-projection"; import { orgHasFeature } from "@/core/plan-feature-gate"; import type { Env } from "../hono-env"; @@ -59,6 +81,9 @@ interface DecofileScope { repository: RepositoryBinding; /** Present only for session-authenticated (member) requests. */ userId: string | null; + previewServerUrl: string | null; + /** The public site id (`metadata.siteSlug`), the hosted CMS's key. */ + site: string | null; } type DecofileEnv = Env & { @@ -192,7 +217,7 @@ const resolveDecofileScope = createMiddleware(async (c, next) => { * threads, and gating the data plane on it would strand a session already * stamped `cms` the moment someone flips the switch off. */ const previewServerUrl = resolvePreviewServerUrl(metadata); - if (!previewServerUrl) { + if (!previewServerUrl && !isContentProtocolPath(c.req.path)) { return c.json({ error: "Project has no preview server configured" }, 404); } @@ -211,10 +236,75 @@ const resolveDecofileScope = createMiddleware(async (c, next) => { packagePath: runtime?.path?.replace(/^\/+|\/+$/g, "") || null, repository, userId, + previewServerUrl, + site: await ownedProjectSite( + ctx.storage.orgSites, + virtualMcpId, + organization.id, + ), }); return next(); }); +/** Content-protocol routes that work without a preview server. */ +function isContentProtocolPath(path: string): boolean { + return path.endsWith("/rpc"); +} + +/** + * The `site_editor_content_protocol` org flag, read before a content-protocol + * route does anything (and so before any commit). A failed read is "off": + * the route answers 404 and the editor stays on the v7 path. + */ +async function contentProtocolEnabled( + c: Context, +): Promise { + const ctx = c.var.studioContext; + const organizationId = c.get("decofileScope").organizationId; + try { + const settings = await ctx.storage.organizationSettings.get(organizationId); + return orgFlagEnabled(settings?.flags, "site_editor_content_protocol"); + } catch (error) { + console.error("decofile: org settings read failed; protocol off", { + organizationId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +/** + * The draft store and this session's draft, for a member on a flag-on org + * with a delivery bucket configured. Null otherwise; whether the project is + * a v8 one is the caller's question. + */ +async function hostedScope( + c: Context, +): Promise<{ drafts: DraftStore; ref: HostedDraftRef } | null> { + const scope = c.get("decofileScope"); + if (!scope.userId || !scope.site) return null; + if (!(await contentProtocolEnabled(c))) return null; + const drafts = hostedDrafts(c.var.studioContext.storage.kv); + if (!drafts) return null; + return { + drafts, + ref: { + organizationId: scope.organizationId, + virtualMcpId: scope.virtualMcpId, + branch: scope.branch, + site: scope.site, + }, + }; +} + +function signScopeDraftToken(scope: DecofileScope): string { + return signDraftToken({ + organizationId: scope.organizationId, + virtualMcpId: scope.virtualMcpId, + branch: scope.branch, + }); +} + /** * The authority (host[:port]) the editor should bake into the `?__draft=` * pointer. `window.location.host` is wrong in the native app — the webview's @@ -266,6 +356,33 @@ export function createDecofileRoutes() { const scope = c.get("decofileScope"); try { const client = await contentClientForScope(c); + // OPEN: O-S3 — a v8 project's editor reads its `?__draft=` pointer + // here, in place of v7's decofile, token and API host. + const hosted = await hostedScope(c); + if (hosted) { + const draft = await hosted.drafts.load(hosted.ref); + if ( + draft || + (await mainIsV8( + client, + scope.packagePath, + await client.getDefaultBranch(), + )) + ) { + const version = bareEtag(draft?.etag); + return c.json( + { + draft: + draft && version + ? draftPointerTarget(hosted.ref.site, draft.slug) + : null, + version, + }, + 200, + { "Cache-Control": "no-store" }, + ); + } + } const snapshot = await readDecofileSnapshot( client, scope.branch, @@ -300,11 +417,7 @@ export function createDecofileRoutes() { "content-type": "application/json", }); } - const token = signDraftToken({ - organizationId: scope.organizationId, - virtualMcpId: scope.virtualMcpId, - branch: scope.branch, - }); + const token = signScopeDraftToken(scope); return c.body( `{"version":${JSON.stringify(snapshot.sha)},"token":${JSON.stringify(token)},"apiHost":${JSON.stringify(requestApiHost(c))},"decofile":${snapshot.decofile}}`, 200, @@ -407,11 +520,7 @@ export function createDecofileRoutes() { }, patch, ); - const token = signDraftToken({ - organizationId: scope.organizationId, - virtualMcpId: scope.virtualMcpId, - branch: scope.branch, - }); + const token = signScopeDraftToken(scope); return c.json({ version: sha, token, apiHost: requestApiHost(c) }); } catch (err) { return errorResponse(c, err); @@ -423,6 +532,44 @@ export function createDecofileRoutes() { try { const client = await contentClientForScope(c); const baseBranch = await client.getDefaultBranch(); + const hosted = await hostedScope(c); + if ( + hosted && + ((await hosted.drafts.load(hosted.ref)) || + (await mainIsV8(client, scope.packagePath, baseBranch))) + ) { + const store = deliveryStore(); + if (!store) { + return c.json({ error: "hosted delivery not configured" }, 503); + } + const body = (await c.req.json().catch(() => ({}))) as { + note?: unknown; + }; + try { + const result = await publishDraft( + { + client, + packagePath: scope.packagePath, + mainBranch: baseBranch, + store, + purge: deliveryPurge(), + site: hosted.ref.site, + }, + hosted.drafts, + hosted.ref, + { + message: typeof body.note === "string" ? body.note : "", + coAuthor: coAuthorFromStudioContext(c.var.studioContext), + }, + ); + return c.json(result); + } catch (err) { + if (err instanceof MainMovedError) { + return c.json({ error: "main-moved" }, 409); + } + throw err; + } + } if (baseBranch === scope.branch) { return c.json({ error: "Branch is already the default branch" }, 400); } @@ -496,5 +643,43 @@ export function createDecofileRoutes() { } }); + app.post("/:virtualMcpId/:branch/rpc", async (c) => { + if (!(await contentProtocolEnabled(c))) { + return c.json({ error: "Not found" }, 404); + } + const scope = c.get("decofileScope"); + const hosted = await hostedScope(c); + if (!hosted) { + return c.json({ error: "hosted delivery not configured" }, 503); + } + const client = await contentClientForScope(c); + // Per request: the storage carries this caller's repository credential. + // The body cache is off for the same reason, and the blob cache under + // the storage already makes repeated reads cheap. + const handler = createContentHandler( + createDraftContentStorage({ + client, + packagePath: scope.packagePath, + mainBranch: await client.getDefaultBranch(), + drafts: hosted.drafts, + ref: hosted.ref, + }), + { + server: { name: "studio-github", version: "1" }, + preview: scope.previewServerUrl + ? { url: new URL(scope.previewServerUrl).origin } + : null, + bodyCacheBytes: 0, + onError: (error) => + console.error("content protocol: internal error", { + organizationId: scope.organizationId, + virtualMcpId: scope.virtualMcpId, + error: error instanceof Error ? error.message : String(error), + }), + }, + ); + return handler(c.req.raw); + }); + return app; } diff --git a/apps/api/src/api/routes/hosted.ts b/apps/api/src/api/routes/hosted.ts new file mode 100644 index 0000000000..0539f01ae0 --- /dev/null +++ b/apps/api/src/api/routes/hosted.ts @@ -0,0 +1,249 @@ +/** + * The hosted Deco CMS screens of a Blocks v8 project (session, org flag): + * + * GET /api/:org/hosted/:virtualMcpId/releases?cursor= Releases screen + * POST /api/:org/hosted/:virtualMcpId/releases/current Make current { sha, confirm? } + * GET /api/:org/hosted/:virtualMcpId/site-tokens list + * POST /api/:org/hosted/:virtualMcpId/site-tokens issue (shown once) + * + * Publish lives with the draft it publishes (`decofile.ts`). These are + * Studio-internal routes; nothing outside Studio calls them. + */ + +// OPEN: O-S4 — Releases and Make current have no existing route to +// reuse, so they are these Studio-internal routes (with site tokens beside them). + +import { isProjectAllowed } from "@decocms/shared/auth/project-scope"; +import { orgFlagEnabled } from "@decocms/shared/organization/schema"; +import { Hono, type Context } from "hono"; +import { createMiddleware } from "hono/factory"; +import { orgHasFeature } from "@/core/plan-feature-gate"; +import { resolveCallerProjectScope } from "@/core/project-scope"; +import { + contentClientForProjectRepo, + insightsClientForProjectRepo, + repoErrorStatus, + type RepoContentClient, +} from "@/git-providers"; +import { deliveryStore } from "@/hosted/delivery-store"; +import { deliveryPurge } from "@/hosted/delivery-purge"; +import { type HostedRepo, LatestUpdateError } from "@/hosted/publish"; +import { NotV8Site } from "@/hosted/release-objects"; +import { + listReleases, + makeCurrent, + NotPublishedError, + SchemaMismatchError, +} from "@/hosted/releases"; +import { mainIsV8, ownedProjectSite } from "@/hosted/scope"; +import { createSiteTokens, importSigningKey } from "@/hosted/site-token"; +import { getSettings } from "@/settings"; +import type { RepositoryBinding } from "@decocms/shared/sdk/types"; +import { parseRepositoryBinding } from "@/tools/sandbox/sync-git-credentials"; +import type { Env } from "../hono-env"; + +interface HostedProject { + organizationId: string; + virtualMcpId: string; + site: string; + packagePath: string | null; + repository: RepositoryBinding; +} + +type HostedEnv = Env & { + Variables: Env["Variables"] & { hostedProject: HostedProject }; +}; + +const resolveHostedProject = createMiddleware(async (c, next) => { + const ctx = c.var.studioContext; + const organization = ctx.organization; + if (!organization) { + return c.json({ error: "Organization scope required" }, 500); + } + if (!ctx.auth?.user?.id) return c.json({ error: "Unauthorized" }, 401); + if (!(await orgHasFeature(ctx, organization.id, "cms"))) { + return c.json( + { + error: "This organization's plan does not include the CMS", + code: "feature_not_in_plan", + }, + 403, + ); + } + const settings = await ctx.storage.organizationSettings + .get(organization.id) + .catch(() => null); + if (!orgFlagEnabled(settings?.flags, "site_editor_content_protocol")) { + return c.json({ error: "Not found" }, 404); + } + const virtualMcpId = c.req.param("virtualMcpId") ?? ""; + const virtualMcp = await ctx.storage.virtualMcps.findById(virtualMcpId); + if (!virtualMcp || virtualMcp.organization_id !== organization.id) { + return c.json({ error: "Virtual MCP not found" }, 404); + } + // A project-scoped role may only act on projects in its allowlist. + if (!isProjectAllowed(await resolveCallerProjectScope(ctx), virtualMcpId)) { + return c.json({ error: "Virtual MCP not found" }, 404); + } + const metadata = (virtualMcp.metadata as Record) ?? null; + const site = await ownedProjectSite( + ctx.storage.orgSites, + virtualMcpId, + organization.id, + ); + const repository = parseRepositoryBinding( + metadata, + virtualMcp.connections?.map((conn) => conn.connection_id) ?? [], + ); + if (!site || !repository) { + return c.json({ error: "Project has no site or repository" }, 404); + } + const runtime = metadata?.runtime as { path?: string | null } | undefined; + c.set("hostedProject", { + organizationId: organization.id, + virtualMcpId, + site, + packagePath: runtime?.path?.replace(/^\/+|\/+$/g, "") || null, + repository, + }); + return next(); +}); + +const NOT_CONFIGURED = { error: "hosted delivery not configured" } as const; + +async function hostedRepo(c: Context): Promise { + const store = deliveryStore(); + if (!store) return null; + const project = c.get("hostedProject"); + const client: RepoContentClient = await contentClientForProjectRepo( + c.var.studioContext, + project.organizationId, + project.repository, + ); + return { + client, + packagePath: project.packagePath, + mainBranch: await client.getDefaultBranch(), + store, + purge: deliveryPurge(), + site: project.site, + }; +} + +function siteTokens(c: Context) { + const signingKey = getSettings().siteTokenSigningKey; + if (!signingKey) return null; + return createSiteTokens({ + kv: c.var.studioContext.storage.kv, + signingKey: () => importSigningKey(signingKey), + }); +} + +function hostedError(c: Context, err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof NotV8Site) return c.json({ error: message }, 409); + // Writing or purging latest.json failed: a code the UI localizes; the + // user retries. + if (err instanceof LatestUpdateError) { + console.error("hosted: latest.json update failed", { error: message }); + return c.json({ error: "latest-update-failed" }, 502); + } + const status = repoErrorStatus(err); + if (status !== null) { + return c.json({ error: message }, status === 404 ? 404 : 502); + } + console.error("hosted: request failed", { error: message }); + return c.json({ error: message }, 500); +} + +export function createHostedRoutes() { + const app = new Hono(); + app.use("/:virtualMcpId/*", resolveHostedProject); + + app.get("/:virtualMcpId/releases", async (c) => { + try { + const repo = await hostedRepo(c); + if (!repo) return c.json(NOT_CONFIGURED, 503); + const project = c.get("hostedProject"); + const insights = await insightsClientForProjectRepo( + c.var.studioContext, + project.organizationId, + project.repository, + ); + return c.json( + await listReleases(repo, insights, c.req.query("cursor") ?? null), + 200, + { "Cache-Control": "no-store" }, + ); + } catch (err) { + return hostedError(c, err); + } + }); + + app.post("/:virtualMcpId/releases/current", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { + sha?: unknown; + confirm?: unknown; + }; + if (typeof body.sha !== "string") { + return c.json({ error: "sha is required" }, 400); + } + try { + const repo = await hostedRepo(c); + if (!repo) return c.json(NOT_CONFIGURED, 503); + const current = await makeCurrent(repo, body.sha, { + confirm: body.confirm === true, + }); + return c.json({ current }); + } catch (err) { + if (err instanceof NotPublishedError) { + return c.json({ error: err.message }, 404); + } + if (err instanceof SchemaMismatchError) { + return c.json( + { error: "schema-mismatch", target: err.target, head: err.head }, + 409, + ); + } + return hostedError(c, err); + } + }); + + app.get("/:virtualMcpId/site-tokens", async (c) => { + const project = c.get("hostedProject"); + const tokens = siteTokens(c); + if (!tokens) return c.json({ error: "site tokens not configured" }, 503); + return c.json({ + site: project.site, + tokens: await tokens.list(project.organizationId, project.site), + }); + }); + + app.post("/:virtualMcpId/site-tokens", async (c) => { + const project = c.get("hostedProject"); + const tokens = siteTokens(c); + if (!tokens) return c.json({ error: "site tokens not configured" }, 503); + try { + // Site tokens are for Blocks v8 sites only. + const client = await contentClientForProjectRepo( + c.var.studioContext, + project.organizationId, + project.repository, + ); + if ( + !(await mainIsV8( + client, + project.packagePath, + await client.getDefaultBranch(), + )) + ) { + return c.json({ error: "not a Blocks v8 site" }, 409); + } + return c.json(await tokens.issue(project.organizationId, project.site)); + } catch (err) { + return hostedError(c, err); + } + }); + + return app; +} diff --git a/apps/api/src/api/routes/org-scoped.ts b/apps/api/src/api/routes/org-scoped.ts index 97dc1d2a88..7b22a1b60c 100644 --- a/apps/api/src/api/routes/org-scoped.ts +++ b/apps/api/src/api/routes/org-scoped.ts @@ -44,6 +44,7 @@ import { createTriggerCallbackRoutes } from "./trigger-callback"; import { createVirtualMcpRoutes } from "./virtual-mcp"; import { createSandboxRoutes } from "./sandbox-proxy"; import { createDecofileRoutes } from "./decofile"; +import { createHostedRoutes } from "./hosted"; interface OrgScopedDeps { voiceSessions: VoiceSessions; @@ -120,6 +121,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { ); // /api/:org/fs/:volume/... app.route("/sandbox", createSandboxRoutes()); // /api/:org/sandbox/:virtualMcpId/:branch/* app.route("/decofile", createDecofileRoutes()); // /api/:org/decofile/:virtualMcpId/:branch[/*] — sandbox-less Fast Preview CMS + app.route("/hosted", createHostedRoutes()); // /api/:org/hosted/:virtualMcpId/* — hosted Deco CMS (v8) releases and site tokens app.route("/", createHomeNextActionsRoutes()); app.route("/", createOrgNoticeRoutes()); // /api/:org/notice — the org's pinned billing notice app.route("/deco-sites", createDecoSitesOrgRoutes()); // /api/:org/deco-sites diff --git a/apps/api/src/git-providers/clients.ts b/apps/api/src/git-providers/clients.ts index 9020472feb..2c6133ff0b 100644 --- a/apps/api/src/git-providers/clients.ts +++ b/apps/api/src/git-providers/clients.ts @@ -267,6 +267,19 @@ async function insightsClientForTarget( return insightsClientFor(staticRepoCredential(resolved.ref, token)); } +/** {@link insightsClientForTarget} for a project's repository binding. */ +export function insightsClientForProjectRepo( + ctx: StudioContext, + organizationId: string, + repository: RepositoryBinding, +): Promise { + return insightsClientForTarget( + ctx, + organizationId, + repoTargetForBinding(repository), + ); +} + /** {@link insightsClientForTarget} for a first-class repository row. */ export function insightsClientForRepository( ctx: StudioContext, From 3fe5985ab37b04b569b652ac5bc2a8b4bb88064f Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 9 Oct 2026 12:06:11 -0300 Subject: [PATCH 2/2] feat(api): releases/current { head: true } puts main's head live Publish's "Try again" when its changes were committed but didn't go live (release "created" or "none"): main's head gets its companion release when it is missing and is made current. The head holds every published change, so a retry never rolls back a newer Publish; its schema is main's own, so there is nothing to confirm. Before this, a Publish whose release write failed had no way back: Make current needs a companion, and the next Publish with an empty draft is up to date and leaves latest.json alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- apps/api/src/api/routes/hosted.ts | 17 ++++++++++--- apps/api/src/hosted/publish.ts | 5 +++- apps/api/src/hosted/releases.test.ts | 38 ++++++++++++++++++++++++++++ apps/api/src/hosted/releases.ts | 22 ++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/routes/hosted.ts b/apps/api/src/api/routes/hosted.ts index 0539f01ae0..16853071a6 100644 --- a/apps/api/src/api/routes/hosted.ts +++ b/apps/api/src/api/routes/hosted.ts @@ -3,6 +3,8 @@ * * GET /api/:org/hosted/:virtualMcpId/releases?cursor= Releases screen * POST /api/:org/hosted/:virtualMcpId/releases/current Make current { sha, confirm? } + * or { head: true }: main's head, + * its release written if missing * GET /api/:org/hosted/:virtualMcpId/site-tokens list * POST /api/:org/hosted/:virtualMcpId/site-tokens issue (shown once) * @@ -32,6 +34,7 @@ import { NotV8Site } from "@/hosted/release-objects"; import { listReleases, makeCurrent, + makeHeadCurrent, NotPublishedError, SchemaMismatchError, } from "@/hosted/releases"; @@ -184,16 +187,22 @@ export function createHostedRoutes() { const body = (await c.req.json().catch(() => ({}))) as { sha?: unknown; confirm?: unknown; + head?: unknown; }; - if (typeof body.sha !== "string") { + const head = body.head === true; + if (!head && typeof body.sha !== "string") { return c.json({ error: "sha is required" }, 400); } try { const repo = await hostedRepo(c); if (!repo) return c.json(NOT_CONFIGURED, 503); - const current = await makeCurrent(repo, body.sha, { - confirm: body.confirm === true, - }); + // `head`: Publish's "Try again" when its changes were committed but + // didn't go live (no companion release, or latest.json not written). + const current = head + ? await makeHeadCurrent(repo) + : await makeCurrent(repo, body.sha as string, { + confirm: body.confirm === true, + }); return c.json({ current }); } catch (err) { if (err instanceof NotPublishedError) { diff --git a/apps/api/src/hosted/publish.ts b/apps/api/src/hosted/publish.ts index f3a2bf0606..4b92344f15 100644 --- a/apps/api/src/hosted/publish.ts +++ b/apps/api/src/hosted/publish.ts @@ -244,7 +244,10 @@ async function commitDraftToMain( * Step 3 for commit `sha`: its companion release, written only when missing * (it is immutable). Returns its schemaHash. */ -async function ensureRevision(repo: HostedRepo, sha: string): Promise { +export async function ensureRevision( + repo: HostedRepo, + sha: string, +): Promise { if (await hasRevision(repo.store, repo.site, sha)) { return schemaHashAt(repo.client, repo.packagePath, sha); } diff --git a/apps/api/src/hosted/releases.test.ts b/apps/api/src/hosted/releases.test.ts index f58c722c47..ccd89a5b80 100644 --- a/apps/api/src/hosted/releases.test.ts +++ b/apps/api/src/hosted/releases.test.ts @@ -12,6 +12,7 @@ import { buildRevision } from "./release-objects"; import { listReleases, makeCurrent, + makeHeadCurrent, NotPublishedError, SchemaMismatchError, } from "./releases"; @@ -191,3 +192,40 @@ describe("makeCurrent", () => { expect((await readLatest(delivery.store, "acme"))?.revision).toBe(old); }); }); + +describe("makeHeadCurrent (Publish's Try again)", () => { + it("writes main head's missing companion release, then makes it current", async () => { + const { git, delivery, repo } = setup(); + // A Publish committed but its release write failed: no companion. + git.pushDirect({ ".deco/blocks/Home.json": '{"a":1}\n' }, "published"); + const head = git.head(); + delivery.log.length = 0; + const pointer = await makeHeadCurrent(repo); + expect(pointer).toMatchObject({ revision: head, schemaHash: SCHEMA_HASH }); + expect(delivery.log).toEqual([ + `put ${deliveryKeys.revision("acme", head)}`, + `put ${LATEST}`, + `purge ${LATEST}`, + ]); + expect((await readLatest(delivery.store, "acme"))?.revision).toBe(head); + }); + + it("reuses an existing companion (latest.json write had failed)", async () => { + const { delivery, repo, release } = setup(); + const head = await release(); + delivery.log.length = 0; + await makeHeadCurrent(repo); + expect(delivery.log).toEqual([`put ${LATEST}`, `purge ${LATEST}`]); + expect((await readLatest(delivery.store, "acme"))?.revision).toBe(head); + }); + + it("makes main's newest commit live, never an older one", async () => { + const { git, delivery, repo, release } = setup(); + await release(); + // Another Publish landed after the one being retried. + git.pushDirect({ ".deco/blocks/Home.json": '{"b":1}\n' }, "newer"); + const newer = git.head(); + await makeHeadCurrent(repo); + expect((await readLatest(delivery.store, "acme"))?.revision).toBe(newer); + }); +}); diff --git a/apps/api/src/hosted/releases.ts b/apps/api/src/hosted/releases.ts index e2af2a3ca9..491bc1a44e 100644 --- a/apps/api/src/hosted/releases.ts +++ b/apps/api/src/hosted/releases.ts @@ -15,6 +15,7 @@ import { listRevisionShas, } from "./delivery-store"; import { + ensureRevision, type HostedRepo, type LatestPointer, readLatest, @@ -135,3 +136,24 @@ export async function makeCurrent( await writeLatest(repo, pointer); return pointer; } + +/** + * "Try again" after a Publish that committed but didn't go live (`release` + * `created` or `none`): main's head gets its companion release when it is + * missing, and is made current. The head holds every published change, the + * failed Publish's included, so this never rolls back a newer Publish; and + * its schema is main's own, so there is nothing to confirm. + */ +export async function makeHeadCurrent( + repo: HostedRepo, +): Promise { + const head = await requireBranchHead(repo.client, repo.mainBranch); + const schemaHash = await ensureRevision(repo, head); + const pointer: LatestPointer = { + revision: head, + schemaHash, + publishedAt: new Date().toISOString(), + }; + await writeLatest(repo, pointer); + return pointer; +}