From e6e32385377fc4a1efe0e1bb41e5e7bec6d1244b Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 6 Oct 2026 23:37:16 -0300 Subject: [PATCH 01/19] =?UTF-8?q?feat(site-editor):=20hosted=20Deco=20CMS?= =?UTF-8?q?=20v8=20=E2=80=94=20publish=20to=20CDN,=20CDN=20drafts,=20relea?= =?UTF-8?q?ses,=20site=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For Blocks v8 projects on GitHub behind site_editor_content_protocol (v7 is unchanged): - Editor saves go to sites//drafts/.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 (/…/.json@). - Publish commits the draft directly to main (fast-forward only), writes revisions/.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:. - 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 --- apps/api/src/api/routes/admin.ts | 60 ++++ apps/api/src/api/routes/decofile.ts | 170 ++++++--- apps/api/src/api/routes/hosted.ts | 264 ++++++++++++++ apps/api/src/api/routes/org-scoped.ts | 2 + apps/api/src/api/routes/sandbox-proxy.ts | 92 ++++- .../decofile/draft-changes-test-helpers.ts | 45 --- apps/api/src/decofile/draft-changes.test.ts | 209 ----------- apps/api/src/decofile/draft-changes.ts | 231 ------------ apps/api/src/decofile/repo-content-storage.ts | 144 +------- apps/api/src/file-storage/upload-policy.ts | 2 +- .../src/git-providers/bitbucket/content.ts | 4 - apps/api/src/git-providers/clients.ts | 13 + apps/api/src/git-providers/content.ts | 5 - apps/api/src/git-providers/github/content.ts | 9 - apps/api/src/git-providers/gitlab/content.ts | 4 - apps/api/src/hosted/delivery-store.test.ts | 61 ++++ apps/api/src/hosted/delivery-store.ts | 215 +++++++++++ apps/api/src/hosted/denylist.ts | 62 ++++ .../src/hosted/draft-content-storage.test.ts | 210 +++++++++++ apps/api/src/hosted/draft-content-storage.ts | 209 +++++++++++ apps/api/src/hosted/draft-git-compat.test.ts | 81 +++++ apps/api/src/hosted/draft-git-compat.ts | 122 +++++++ apps/api/src/hosted/draft-store.ts | 149 ++++++++ apps/api/src/hosted/hosted-test-helpers.ts | 185 ++++++++++ apps/api/src/hosted/kill-switch.test.ts | 88 +++++ apps/api/src/hosted/kill-switch.ts | 48 +++ apps/api/src/hosted/publish.test.ts | 179 ++++++++++ apps/api/src/hosted/publish.ts | 308 ++++++++++++++++ apps/api/src/hosted/release-objects.test.ts | 69 ++++ apps/api/src/hosted/release-objects.ts | 143 ++++++++ apps/api/src/hosted/releases.test.ts | 110 ++++++ apps/api/src/hosted/releases.ts | 154 ++++++++ apps/api/src/hosted/scope.ts | 40 +++ apps/api/src/hosted/site-token.test.ts | 108 ++++++ apps/api/src/hosted/site-token.ts | 144 ++++++++ apps/api/src/settings/resolve-config.ts | 16 +- apps/api/src/settings/types.ts | 17 + .../deco-studio/en/studio/agentic-cms.mdx | 4 +- .../deco-studio/pt-br/studio/agentic-cms.mdx | 4 +- .../components/projects/project-apps.test.ts | 1 + .../src/components/projects/project-apps.tsx | 11 +- .../sections-editor/content-protocol-api.ts | 64 ++-- .../sections-editor/section-preview-url.ts | 7 +- .../use-fast-preview-draft-url.ts | 10 +- .../src/components/sidebar/project-nav.tsx | 5 + .../thread/repository/cms-publish-popover.tsx | 21 ++ .../thread/repository/hosted-publish-api.ts | 59 +++ .../repository/use-cms-publish-actions.ts | 97 ++++- apps/web/src/hooks/use-destination-route.ts | 1 + apps/web/src/i18n/en/admin.ts | 10 + apps/web/src/i18n/en/common.ts | 1 + apps/web/src/i18n/en/index.ts | 4 + apps/web/src/i18n/en/projects.ts | 2 + apps/web/src/i18n/en/releases.ts | 33 ++ apps/web/src/i18n/en/site-tokens.ts | 23 ++ apps/web/src/i18n/en/thread.ts | 8 + apps/web/src/i18n/pt-br/admin.ts | 11 + apps/web/src/i18n/pt-br/common.ts | 1 + apps/web/src/i18n/pt-br/index.ts | 4 + apps/web/src/i18n/pt-br/projects.ts | 2 + apps/web/src/i18n/pt-br/releases.ts | 35 ++ apps/web/src/i18n/pt-br/site-tokens.ts | 25 ++ apps/web/src/i18n/pt-br/thread.ts | 8 + apps/web/src/layouts/legacy-main-redirect.tsx | 10 + .../project-sidebar-views.test.ts | 9 +- .../main-panel-tabs/project-sidebar-views.ts | 15 +- .../layouts/main-panel-tabs/releases-api.ts | 119 +++++++ .../layouts/main-panel-tabs/releases-tab.tsx | 313 ++++++++++++++++ .../main-panel-tabs/resolve-tab-icon.ts | 3 + .../src/layouts/main-panel-tabs/tab-route.ts | 4 + .../main-panel-tabs/use-main-panel-tabs.ts | 1 + .../use-project-native-view-presence.ts | 12 +- apps/web/src/lib/query-keys.ts | 11 +- apps/web/src/router.tsx | 16 + apps/web/src/routes/admin/orgs.tsx | 84 +++++ .../src/routes/workspace/agent-releases.tsx | 21 ++ apps/web/src/views/virtual-mcp/index.tsx | 11 + .../settings/site-token-section.tsx | 235 ++++++++++++ .../virtual-mcp/settings/use-project-views.ts | 1 + .../virtual-mcp/settings/view-row-actions.tsx | 2 + knip.jsonc | 6 +- packages/e2e/fixtures/delivery-stub-server.ts | 225 ++++++++++++ packages/e2e/fixtures/fast-preview.ts | 3 + packages/e2e/playwright.config.ts | 23 +- .../e2e/tests/content-protocol-github.spec.ts | 337 +++++++++++++----- packages/shared/src/sdk/types/virtual-mcp.ts | 1 + packages/shared/src/tools/tool-io.ts | 18 + 87 files changed, 5033 insertions(+), 870 deletions(-) create mode 100644 apps/api/src/api/routes/hosted.ts delete mode 100644 apps/api/src/decofile/draft-changes-test-helpers.ts delete mode 100644 apps/api/src/decofile/draft-changes.test.ts delete mode 100644 apps/api/src/decofile/draft-changes.ts create mode 100644 apps/api/src/hosted/delivery-store.test.ts create mode 100644 apps/api/src/hosted/delivery-store.ts create mode 100644 apps/api/src/hosted/denylist.ts create mode 100644 apps/api/src/hosted/draft-content-storage.test.ts create mode 100644 apps/api/src/hosted/draft-content-storage.ts create mode 100644 apps/api/src/hosted/draft-git-compat.test.ts create mode 100644 apps/api/src/hosted/draft-git-compat.ts create mode 100644 apps/api/src/hosted/draft-store.ts create mode 100644 apps/api/src/hosted/hosted-test-helpers.ts create mode 100644 apps/api/src/hosted/kill-switch.test.ts create mode 100644 apps/api/src/hosted/kill-switch.ts create mode 100644 apps/api/src/hosted/publish.test.ts create mode 100644 apps/api/src/hosted/publish.ts create mode 100644 apps/api/src/hosted/release-objects.test.ts create mode 100644 apps/api/src/hosted/release-objects.ts create mode 100644 apps/api/src/hosted/releases.test.ts create mode 100644 apps/api/src/hosted/releases.ts create mode 100644 apps/api/src/hosted/scope.ts create mode 100644 apps/api/src/hosted/site-token.test.ts create mode 100644 apps/api/src/hosted/site-token.ts create mode 100644 apps/web/src/components/thread/repository/hosted-publish-api.ts create mode 100644 apps/web/src/i18n/en/releases.ts create mode 100644 apps/web/src/i18n/en/site-tokens.ts create mode 100644 apps/web/src/i18n/pt-br/releases.ts create mode 100644 apps/web/src/i18n/pt-br/site-tokens.ts create mode 100644 apps/web/src/layouts/main-panel-tabs/releases-api.ts create mode 100644 apps/web/src/layouts/main-panel-tabs/releases-tab.tsx create mode 100644 apps/web/src/routes/workspace/agent-releases.tsx create mode 100644 apps/web/src/views/virtual-mcp/settings/site-token-section.tsx create mode 100644 packages/e2e/fixtures/delivery-stub-server.ts diff --git a/apps/api/src/api/routes/admin.ts b/apps/api/src/api/routes/admin.ts index aa062a8b4c..04b053a56f 100644 --- a/apps/api/src/api/routes/admin.ts +++ b/apps/api/src/api/routes/admin.ts @@ -30,6 +30,10 @@ import { OrganizationSettingsStorage } from "@/storage/organization-settings"; import { OrganizationNoticeStorage } from "@/storage/organization-notices"; import { OrgSiteConflictError, OrgSiteStorage } from "@/storage/org-sites"; import { VirtualMCPStorage } from "@/storage/virtual"; +import { KyselyKVStorage } from "@/storage/kv"; +import { denylist } from "@/hosted/denylist"; +import { readKillState, setKilled } from "@/hosted/kill-switch"; +import { projectSite } from "@/hosted/scope"; import { OrgNoticeInputSchema } from "@decocms/shared/organization/notice"; import { isOrgArchived } from "@decocms/shared/organization/org-archived"; import { invalidateOrgNoticeCache } from "@/core/org-notice-gate"; @@ -920,6 +924,62 @@ export function createAdminRoutes(): Hono { return c.json({ ok: true }); }); + // Hosted Deco CMS kill switch: drops an account's telemetry and analytics at + // the edge by writing `kill:` for every site of the org. + // OPEN: O-22 — "staff only" is this deployment-admin fence. + app.get("/orgs/:orgId/hosted-kill", async (c) => { + const orgId = c.req.param("orgId"); + return c.json(await readKillState(new KyselyKVStorage(getDb().db), orgId)); + }); + + app.put("/orgs/:orgId/hosted-kill", async (c) => { + const orgId = c.req.param("orgId"); + const raw = (await c.req.json().catch(() => null)) as { + killed?: unknown; + } | null; + if (typeof raw?.killed !== "boolean") { + return c.json({ error: "killed must be a boolean" }, 400); + } + const deny = denylist(); + if (!deny) return c.json({ error: "denylist not configured" }, 503); + const db = getDb().db; + const org = await db + .selectFrom("organization") + .select("id") + .where("id", "=", orgId) + .executeTakeFirst(); + if (!org) { + return c.json({ error: "Organization not found" }, 404); + } + const { actorId: effectiveActorId, impersonatedBy } = + await getAuditActor(c); + const actorId = impersonatedBy ?? effectiveActorId; + if (!actorId) { + return c.json({ error: "Unauthorized" }, 401); + } + const sites = (await new VirtualMCPStorage(db).list(orgId)).flatMap( + (project) => { + const site = projectSite( + project.metadata as Record | null, + ); + return site ? [site] : []; + }, + ); + const state = await setKilled( + { kv: new KyselyKVStorage(db), denylist: deny }, + orgId, + sites, + raw.killed, + ); + auditAdminAction(raw.killed ? "org_hosted_kill" : "org_hosted_restore", { + actor_user_id: actorId, + ...(impersonatedBy ? { impersonated_user_id: effectiveActorId } : {}), + organization_id: orgId, + sites: sites.length, + }); + return c.json(state); + }); + // The agent-prompt editor (reads/writes decocms/studio over GitHub) — its own // module, mounted here so it inherits this router's admin fence. app.route("/", createAdminPromptRoutes()); diff --git a/apps/api/src/api/routes/decofile.ts b/apps/api/src/api/routes/decofile.ts index ebab5abd8e..14529c77ec 100644 --- a/apps/api/src/api/routes/decofile.ts +++ b/apps/api/src/api/routes/decofile.ts @@ -9,7 +9,6 @@ * 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) - * GET /api/:org/decofile/:virtualMcpId/:branch/changes draft changes vs production (token or 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 @@ -17,15 +16,14 @@ * 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 next-major Blocks sites (see - * `decofile/repo-content-storage.ts`) and exist only behind the - * `site_editor_content_protocol` org flag. `rpc` doesn't need a preview - * server: the protocol never renders, and a project without one just has no - * preview. Their previews use the same Fast Preview pointer as v7, naming - * `changes` instead of the whole decofile: only what the branch changed - * against production (`decofile/draft-changes.ts`), computed on request. - * The editor gets that pointer's draft token and API host the v7 way, from - * the session-authenticated GET read above. + * 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 @@ -57,14 +55,17 @@ import { type DecofilePatch, } from "@/decofile/commit-coalescer"; import { signDraftToken, verifyDraftToken } from "@/decofile/draft-token"; -import { - buildDraftChanges, - DraftChangesInvalidBlock, - DraftChangesTooLarge, -} from "@/decofile/draft-changes"; import { repoGitRebase } from "@/decofile/git-compat"; import { readDecofileSnapshot } from "@/decofile/read-decofile"; -import { createRepoContentStorage } from "@/decofile/repo-content-storage"; +import { + bareEtag, + deliveryStore, + draftPointerTarget, +} from "@/hosted/delivery-store"; +import { createDraftContentStorage } from "@/hosted/draft-content-storage"; +import type { DraftStore, HostedDraftRef } from "@/hosted/draft-store"; +import { MainMovedError, publishDraft } from "@/hosted/publish"; +import { hostedDrafts, mainIsV8, projectSite } from "@/hosted/scope"; import { createContentHandler } from "@decocms/blocks/protocol/server"; import { orgFlagEnabled } from "@decocms/shared/organization/schema"; import { projectPlanningPostsForPreview } from "@/decofile/blog-draft-projection"; @@ -80,6 +81,8 @@ interface DecofileScope { /** 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 & { @@ -233,6 +236,7 @@ const resolveDecofileScope = createMiddleware(async (c, next) => { repository, userId, previewServerUrl, + site: projectSite(metadata), }); return next(); }); @@ -264,6 +268,30 @@ async function contentProtocolEnabled( } } +/** + * 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, @@ -323,6 +351,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, @@ -472,6 +527,43 @@ 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, + 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); } @@ -550,16 +642,21 @@ export function createDecofileRoutes() { 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( - createRepoContentStorage({ + createDraftContentStorage({ client, packagePath: scope.packagePath, - branch: scope.branch, - coAuthor: coAuthorFromStudioContext(c.var.studioContext), + mainBranch: await client.getDefaultBranch(), + drafts: hosted.drafts, + ref: hosted.ref, }), { server: { name: "studio-github", version: "1" }, @@ -578,40 +675,5 @@ export function createDecofileRoutes() { return handler(c.req.raw); }); - /** - * A content-protocol draft's `?__draft=` target: what the branch changed - * against production, never the whole decofile (blocks docs: - * /next/content-delivery#draft-previews). The site's SDK reads it with the - * pointer's token; a branch that doesn't exist yet changed nothing. - */ - app.get("/:virtualMcpId/:branch/changes", async (c) => { - const headers = { - "Cache-Control": "no-store", - "Access-Control-Allow-Origin": "*", - }; - if (!(await contentProtocolEnabled(c))) { - return c.json({ error: "Not found" }, 404, headers); - } - const scope = c.get("decofileScope"); - try { - const changes = await buildDraftChanges( - await contentClientForScope(c), - scope.packagePath, - scope.branch, - ); - return c.json(changes, 200, headers); - } catch (err) { - if (err instanceof DraftChangesTooLarge) { - return c.json({ error: err.message }, 413, headers); - } - if (err instanceof DraftChangesInvalidBlock) { - return c.json({ error: err.message, file: err.file }, 422, headers); - } - const res = errorResponse(c, err); - for (const [k, v] of Object.entries(headers)) res.headers.set(k, v); - return res; - } - }); - 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..e18b1c15ad --- /dev/null +++ b/apps/api/src/api/routes/hosted.ts @@ -0,0 +1,264 @@ +/** + * 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? } + * POST /api/:org/hosted/:virtualMcpId/resync Resync { confirm? } + * GET /api/:org/hosted/:virtualMcpId/site-tokens list + * POST /api/:org/hosted/:virtualMcpId/site-tokens issue (shown once) + * DELETE /api/:org/hosted/:virtualMcpId/site-tokens/:kid revoke + * + * Publish lives with the draft it publishes (`decofile.ts`). These are + * Studio-internal routes; nothing outside Studio calls them. + */ + +// OPEN: O-S4 — Releases, Make current and Resync have no existing route to +// reuse, so they are these Studio-internal routes (with site tokens beside them). + +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 { + contentClientForProjectRepo, + insightsClientForProjectRepo, + repoErrorStatus, + type RepoContentClient, +} from "@/git-providers"; +import { deliveryStore } from "@/hosted/delivery-store"; +import { denylist } from "@/hosted/denylist"; +import { type HostedRepo, RolledBackError, resync } from "@/hosted/publish"; +import { NotV8Site } from "@/hosted/release-objects"; +import { + listReleases, + makeCurrent, + NotPublishedError, + SchemaMismatchError, +} from "@/hosted/releases"; +import { projectSite } from "@/hosted/scope"; +import { + createSiteTokens, + importSigningKey, + SiteTokenNotFoundError, + TooManySiteTokensError, +} 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); + } + const metadata = (virtualMcp.metadata as Record) ?? null; + const site = projectSite(metadata); + 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, + site: project.site, + }; +} + +function siteTokens(c: Context) { + const signingKey = getSettings().siteTokenSigningKey; + const deny = denylist(); + if (!signingKey || !deny) return null; + return createSiteTokens({ + kv: c.var.studioContext.storage.kv, + signingKey: () => importSigningKey(signingKey), + denylist: deny, + }); +} + +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); + 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.post("/:virtualMcpId/resync", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { + confirm?: unknown; + }; + try { + const repo = await hostedRepo(c); + if (!repo) return c.json(NOT_CONFIGURED, 503); + return c.json(await resync(repo, { confirm: body.confirm === true })); + } catch (err) { + if (err instanceof RolledBackError) { + return c.json({ error: "rolled-back" }, 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 { + return c.json(await tokens.issue(project.organizationId, project.site)); + } catch (err) { + if (err instanceof TooManySiteTokensError) { + return c.json({ error: "too-many-tokens" }, 409); + } + throw err; + } + }); + + app.delete("/:virtualMcpId/site-tokens/:kid", async (c) => { + const project = c.get("hostedProject"); + const tokens = siteTokens(c); + if (!tokens) return c.json({ error: "site tokens not configured" }, 503); + try { + const record = await tokens.revoke( + project.organizationId, + project.site, + c.req.param("kid"), + ); + return c.json({ record }); + } catch (err) { + if (err instanceof SiteTokenNotFoundError) { + return c.json({ error: err.message }, 404); + } + throw 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/api/routes/sandbox-proxy.ts b/apps/api/src/api/routes/sandbox-proxy.ts index b26eb58e71..15cc640e5c 100644 --- a/apps/api/src/api/routes/sandbox-proxy.ts +++ b/apps/api/src/api/routes/sandbox-proxy.ts @@ -72,6 +72,13 @@ import { parseLoaderInvokeRequest, } from "../../lib/loader-invoke"; import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; +import { orgFlagEnabled } from "@decocms/shared/organization/schema"; +import { + draftGitDiff, + draftGitDiscard, + draftGitStatus, +} from "../../hosted/draft-git-compat"; +import { hostedDrafts, mainIsV8, projectSite } from "../../hosted/scope"; import { GitPushAuthError, parseRepositoryBinding, @@ -394,6 +401,64 @@ async function fastPreviewGitClient(c: Context) { return contentClientForProjectRepo(ctx, organization.id, repository); } +/** + * A hosted v8 project's draft, for the sandbox-less git routes: its changes + * live on the CDN draft, not on the branch (see hosted/draft-git-compat.ts). + * Null for every other project, which keeps the branch-backed answers. + */ +async function fastPreviewHostedDraft(c: Context) { + const claim = c.get("vmClaim"); + const ctx = c.var.studioContext; + const organization = requireOrganization(ctx); + const site = projectSite(claim.virtualMcpMetadata); + const drafts = site ? hostedDrafts(ctx.storage.kv) : null; + if (!site || !drafts) return null; + const settings = await ctx.storage.organizationSettings.get(organization.id); + if (!orgFlagEnabled(settings?.flags, "site_editor_content_protocol")) { + return null; + } + const ref = { + organizationId: organization.id, + virtualMcpId: claim.virtualMcpId, + branch: claim.branch, + site, + }; + const client = await fastPreviewGitClient(c); + const runtime = claim.virtualMcpMetadata?.runtime as + | { path?: string | null } + | undefined; + const repo = { + client, + packagePath: runtime?.path?.replace(/^\/+|\/+$/g, "") || null, + mainBranch: await client.getDefaultBranch(), + }; + const draft = await drafts.load(ref); + if (!draft && !(await mainIsV8(client, repo.packagePath, repo.mainBranch))) { + return null; + } + return { drafts, ref, repo, draft }; +} + +/** `/git/status` without a sandbox: the branch's drift, or a hosted draft's. */ +async function fastPreviewStatus(c: Context) { + const hosted = await fastPreviewHostedDraft(c); + if (hosted) { + return draftGitStatus(hosted.repo, hosted.ref.branch, hosted.draft); + } + return repoGitStatus(await fastPreviewGitClient(c), c.get("vmClaim").branch); +} + +/** `/git/diff` without a sandbox: the branch's bodies, or a hosted draft's. */ +async function fastPreviewDiff(c: Context, base?: string) { + const hosted = await fastPreviewHostedDraft(c); + if (hosted) return draftGitDiff(hosted.repo, hosted.draft); + return repoGitDiff( + await fastPreviewGitClient(c), + c.get("vmClaim").branch, + base, + ); +} + function fastPreviewGitError(c: Context, err: unknown): Response { const message = err instanceof Error ? err.message : String(err); /** 429 with the provider's own wait, so the client backs off instead of @@ -459,8 +524,7 @@ async function proxyPreviewUpstream( async function fastPreviewGitStatus(c: Context): Promise { try { - const client = await fastPreviewGitClient(c); - const status = await repoGitStatus(client, c.get("vmClaim").branch); + const status = await fastPreviewStatus(c); return c.json(status, 200, SANDBOX_PROXY_CACHE_HEADERS); } catch (err) { return fastPreviewGitError(c, err); @@ -956,8 +1020,7 @@ export const createSandboxRoutes = () => { const body = (await c.req.json().catch(() => ({}))) as { base?: string; }; - const client = await fastPreviewGitClient(c); - const diff = await repoGitDiff(client, claim.branch, body.base); + const diff = await fastPreviewDiff(c, body.base); return c.json(diff, 200, SANDBOX_PROXY_CACHE_HEADERS); } catch (err) { return fastPreviewGitError(c, err); @@ -1041,6 +1104,11 @@ export const createSandboxRoutes = () => { ); } try { + const hosted = await fastPreviewHostedDraft(c); + if (hosted) { + await draftGitDiscard(hosted.drafts, hosted.ref, filepaths); + return c.json({ ok: true }, 200, SANDBOX_PROXY_CACHE_HEADERS); + } const client = await fastPreviewGitClient(c); await repoGitDiscard(client, claim.branch, filepaths); return c.json({ ok: true }, 200, SANDBOX_PROXY_CACHE_HEADERS); @@ -1162,13 +1230,7 @@ export const createSandboxRoutes = () => { ]) : // Sandbox-less backfill: same GitHub-backed shapes the /git // routes serve (no daemon exists to ask). - await (async () => { - const client = await fastPreviewGitClient(c); - return Promise.all([ - repoGitStatus(client, claim.branch), - repoGitDiff(client, claim.branch), - ]); - })(); + await Promise.all([fastPreviewStatus(c), fastPreviewDiff(c)]); // These two routes are the only ones under /sandbox that spend real // money: each is one `generateText` on the org's gateway credential, // with a prompt the caller sizes (up to the body limit above). They @@ -1263,13 +1325,7 @@ export const createSandboxRoutes = () => { { userId, projectRef }, ), ]) - : await (async () => { - const client = await fastPreviewGitClient(c); - return Promise.all([ - repoGitStatus(client, claim.branch), - repoGitDiff(client, claim.branch), - ]); - })(); + : await Promise.all([fastPreviewStatus(c), fastPreviewDiff(c)]); // These two routes are the only ones under /sandbox that spend real // money: each is one `generateText` on the org's gateway credential, // with a prompt the caller sizes (up to the body limit above). They diff --git a/apps/api/src/decofile/draft-changes-test-helpers.ts b/apps/api/src/decofile/draft-changes-test-helpers.ts deleted file mode 100644 index d5ddf9d5d9..0000000000 --- a/apps/api/src/decofile/draft-changes-test-helpers.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** A fake repository for draft-changes tests: branches named after their commits. */ -import type { RepoContentClient, TreeEntry } from "@/git-providers"; -import { gitBlobSha } from "./read-decofile"; - -/** Saved-block files by name, per commit. */ -type Commit = Record; - -/** - * A repository whose commits are listed by sha; each commit is also a branch - * of the same name, and the merge base is fixed per test. - */ -export function fakeRepo(input: { - commits: Record; - mergeBase: string; - packagePath?: string; -}) { - const blobs = new Map(); - const reads: string[] = []; - const prefix = input.packagePath - ? `${input.packagePath}/.deco/blocks/` - : ".deco/blocks/"; - const client = { - repo: { provider: "github", host: "github.com", path: "acme/site" }, - getDefaultBranch: async () => "main", - getBranch: async (name: string) => - input.commits[name] ? { sha: name } : null, - mergeBase: async () => input.mergeBase, - listDecofileEntries: async (treeish: string): Promise => { - const commit = input.commits[treeish]; - if (!commit) throw new Error(`unknown commit ${treeish}`); - return Object.entries(commit).map(([file, content]) => { - const sha = gitBlobSha(content); - blobs.set(sha, content); - return { path: `${prefix}${file}`, type: "blob", sha }; - }); - }, - readBlob: async (sha: string) => { - reads.push(sha); - const content = blobs.get(sha); - if (content === undefined) throw new Error(`unknown blob ${sha}`); - return content; - }, - } as unknown as RepoContentClient; - return { client, reads }; -} diff --git a/apps/api/src/decofile/draft-changes.test.ts b/apps/api/src/decofile/draft-changes.test.ts deleted file mode 100644 index c82a09cbf2..0000000000 --- a/apps/api/src/decofile/draft-changes.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import { serializeBlock } from "@decocms/blocks/protocol"; -import { - buildDraftChanges, - clearDraftChangesCache, - DraftChangesInvalidBlock, - DraftChangesTooLarge, - MAX_DRAFT_CHANGE_BYTES, -} from "./draft-changes"; -import { fakeRepo } from "./draft-changes-test-helpers"; -import { gitBlobSha } from "./read-decofile"; - -beforeAll(() => { - // No disk blob cache: every body read goes through the fake client. - process.env.FAST_PREVIEW_CACHE_DIR = ""; -}); - -beforeEach(() => clearDraftChangesCache()); - -const block = (value: unknown) => serializeBlock(value); - -describe("buildDraftChanges", () => { - it("is the cumulative file-level difference from the merge base", async () => { - const { client } = fakeRepo({ - mergeBase: "base", - commits: { - base: { - "Header.json": block({ title: "old" }), - "Footer.json": block({ links: 1 }), - "OldPromotion.json": block({ on: true }), - }, - draft: { - "Header.json": block({ title: "new" }), - "Footer.json": block({ links: 1 }), - "Added.json": block({ fresh: true }), - }, - }, - }); - expect(await buildDraftChanges(client, null, "draft")).toEqual({ - format: 1, - set: { Added: { fresh: true }, Header: { title: "new" } }, - delete: ["OldPromotion"], - }); - }); - - it("never carries production changes the draft didn't make", async () => { - // Production edited Footer after the draft branched: the draft's copy is - // still the merge base's, so it inherits production, not the stale copy. - const { client } = fakeRepo({ - mergeBase: "base", - commits: { - base: { "Footer.json": block({ v: 1 }) }, - draft: { "Footer.json": block({ v: 1 }) }, - }, - }); - expect(await buildDraftChanges(client, null, "draft")).toEqual({ - format: 1, - set: {}, - delete: [], - }); - }); - - it("changed nothing while the branch doesn't exist yet", async () => { - const { client } = fakeRepo({ mergeBase: "base", commits: { base: {} } }); - expect(await buildDraftChanges(client, null, "missing")).toEqual({ - format: 1, - set: {}, - delete: [], - }); - }); - - it("reads only changed bodies", async () => { - const { client, reads } = fakeRepo({ - mergeBase: "base", - commits: { - base: { - "A.json": block({ a: 1 }), - "B.json": block({ b: 1 }), - "C.json": block({ c: 1 }), - }, - draft: { - "A.json": block({ a: 2 }), - "B.json": block({ b: 1 }), - "C.json": block({ c: 1 }), - }, - }, - }); - await buildDraftChanges(client, null, "draft"); - expect(reads).toEqual([gitBlobSha(block({ a: 2 }))]); - }); - - it("treats a respelled file of one name as a replacement, not a deletion", async () => { - const { client } = fakeRepo({ - mergeBase: "base", - commits: { - base: { "pages%2Fhome.json": block({ path: "/", v: 1 }) }, - draft: { "pages%2fhome.json": block({ path: "/", v: 2 }) }, - }, - }); - const changes = await buildDraftChanges(client, null, "draft"); - expect(Object.keys(changes.set)).toEqual(["pages/home"]); - expect(changes.delete).toEqual([]); - }); - - it("keeps an entry named __proto__ as an entry", async () => { - const { client } = fakeRepo({ - mergeBase: "base", - commits: { base: {}, draft: { "__proto__.json": block({ x: 1 }) } }, - }); - const changes = await buildDraftChanges(client, null, "draft"); - expect(Object.hasOwn(changes.set, "__proto__")).toBe(true); - }); - - it("reads the app root's saved blocks in a monorepo", async () => { - const { client } = fakeRepo({ - packagePath: "apps/store", - mergeBase: "base", - commits: { base: {}, draft: { "Hero.json": block({ x: 1 }) } }, - }); - const changes = await buildDraftChanges(client, "apps/store", "draft"); - expect(Object.keys(changes.set)).toEqual(["Hero"]); - }); - - it("refuses changes larger than the limit", async () => { - const big = "x".repeat(MAX_DRAFT_CHANGE_BYTES / 2 + 1); - const { client } = fakeRepo({ - mergeBase: "base", - commits: { - base: {}, - draft: { "A.json": block({ big }), "B.json": block({ big }) }, - }, - }); - await expect(buildDraftChanges(client, null, "draft")).rejects.toThrow( - DraftChangesTooLarge, - ); - }); - it("tombstones what a delete-only branch removed", async () => { - const { client } = fakeRepo({ - mergeBase: "base", - commits: { - base: { "Keep.json": block({ k: 1 }), "Gone.json": block({ g: 1 }) }, - draft: { "Keep.json": block({ k: 1 }) }, - }, - }); - expect(await buildDraftChanges(client, null, "draft")).toEqual({ - format: 1, - set: {}, - delete: ["Gone"], - }); - }); - - it("answers a rename as a delete plus a set", async () => { - const { client } = fakeRepo({ - mergeBase: "base", - commits: { - base: { "Old.json": block({ x: 1 }) }, - draft: { "New.json": block({ x: 1 }) }, - }, - }); - expect(await buildDraftChanges(client, null, "draft")).toEqual({ - format: 1, - set: { New: { x: 1 } }, - delete: ["Old"], - }); - }); - - it("sets the other spelling when the draft deletes the winning one", async () => { - // Two spellings of one name: the page-like one wins. Deleting it leaves - // the other as the name's entry, so the name is set, not deleted. - const { client } = fakeRepo({ - mergeBase: "base", - commits: { - base: { - "pages%2Fhome.json": block({ path: "/", v: 1 }), - "pages%2fhome.json": block({ v: 2 }), - }, - draft: { "pages%2fhome.json": block({ v: 2 }) }, - }, - }); - expect(await buildDraftChanges(client, null, "draft")).toEqual({ - format: 1, - set: { "pages/home": { v: 2 } }, - delete: [], - }); - }); - - it("names the file of a saved block that isn't valid JSON", async () => { - const { client } = fakeRepo({ - mergeBase: "base", - commits: { base: {}, draft: { "Broken.json": "{ not json" } }, - }); - const error = await buildDraftChanges(client, null, "draft").catch( - (e: unknown) => e, - ); - expect(error).toBeInstanceOf(DraftChangesInvalidBlock); - expect((error as DraftChangesInvalidBlock).file).toBe("Broken.json"); - }); - - it("reuses a result for the same branch head", async () => { - const { client, reads } = fakeRepo({ - mergeBase: "base", - commits: { base: {}, draft: { "A.json": block({ a: 1 }) } }, - }); - const first = await buildDraftChanges(client, null, "draft"); - const again = await buildDraftChanges(client, null, "draft"); - expect(again).toEqual(first); - expect(reads).toHaveLength(1); - }); -}); diff --git a/apps/api/src/decofile/draft-changes.ts b/apps/api/src/decofile/draft-changes.ts deleted file mode 100644 index 0a4d9a295a..0000000000 --- a/apps/api/src/decofile/draft-changes.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * A content-protocol draft's preview, for the GitHub backend (blocks docs: - * /next/content-delivery#draft-previews). The site's `?__draft=` pointer - * names `GET …/:branch/changes`, which answers only what the branch changed - * since it left production, never a whole decofile: - * `{format: 1, set: {name: block JSON}, delete: [name]}`. The site layers it - * over the production content it already has. - * - * "Changed" is file-level draft wins: a file whose blob differs from the - * merge base with the production branch replaces production's entry whole; - * a file the draft deleted is a tombstone; every other file inherits - * production. Only changed bodies are read, through the blob cache. Nothing - * is prepared or stored: each request computes it from Git, and a result - * is reused for a short while per branch head (it can't change for one head - * except when production takes the branch in, which leaves nothing to show). - */ - -import { - entryHasPath, - fullyDecodeFileName, - isBlockFileName, - resolveSpellings, -} from "@decocms/blocks/protocol"; -import type { RepoContentClient } from "@/git-providers"; -import { - blockEntriesInTree, - blocksDirPath, - resolveBlockContents, -} from "./read-decofile"; - -/** Cumulative changed-block bytes one response may carry. */ -export const MAX_DRAFT_CHANGE_BYTES = 8 * 1024 * 1024; - -/** What a draft branch changed against production. */ -export interface DraftChanges { - format: 1; - set: Record; - delete: string[]; -} - -/** Thrown when a draft's changes exceed {@link MAX_DRAFT_CHANGE_BYTES}. */ -export class DraftChangesTooLarge extends Error {} - -/** Thrown when a saved block on the branch isn't valid JSON; names its file. */ -export class DraftChangesInvalidBlock extends Error { - constructor(readonly file: string) { - super( - `saved block "${file}" isn't valid JSON; fix or discard it to preview`, - ); - } -} - -function parseBlock(file: string, text: string): unknown { - try { - return JSON.parse(text); - } catch { - throw new DraftChangesInvalidBlock(file); - } -} - -interface BlockFile { - file: string; - sha: string; - /** Blob bytes, when the provider's tree reports them. */ - size?: number; -} - -interface Winner extends BlockFile { - name: string; -} - -/** Saved-block files at `treeish`, grouped by spelling (every alias of one name). */ -async function blockFiles( - client: RepoContentClient, - treeish: string, - packagePath: string | null, -): Promise> { - const tree = await client.listDecofileEntries(treeish, packagePath); - const groups = new Map(); - const prefix = `${blocksDirPath(packagePath)}/`; - for (const entry of blockEntriesInTree(tree, packagePath)) { - const file = entry.path.slice(prefix.length); - if (!isBlockFileName(file)) continue; - const key = fullyDecodeFileName(file).name; - const group = groups.get(key) ?? []; - group.push({ file, sha: entry.sha, size: entry.size }); - groups.set(key, group); - } - return groups; -} - -function sameGroup(a: BlockFile[] = [], b: BlockFile[] = []): boolean { - if (a.length !== b.length) return false; - const shas = new Map(a.map((f) => [f.file, f.sha])); - return b.every((f) => shas.get(f.file) === f.sha); -} - -/** The entry a spelling group resolves to, by the shared key rule. */ -async function winnerOf( - client: RepoContentClient, - group: BlockFile[] | undefined, - memo: Map, -): Promise { - if (!group?.length) return null; - // Only an aliased name needs bodies: the winner prefers a page-like entry. - const bodies = - group.length === 1 - ? [null] - : await resolveBlockContents( - client, - group.map((f) => ({ stem: f.file, sha: f.sha })), - memo, - ); - const candidates = group.map((f, i) => ({ - ...f, - hasPath: bodies[i] - ? entryHasPath(parseBlock(f.file, bodies[i].content)) - : false, - })); - const [resolved] = resolveSpellings(candidates).values(); - if (!resolved) return null; - return { - name: resolved.name, - file: resolved.winner.file, - sha: resolved.winner.sha, - size: resolved.winner.size, - }; -} - -/** - * What `branch` changed against the production branch, from their merge - * base. A branch that doesn't exist yet changed nothing. - */ -export async function buildDraftChanges( - client: RepoContentClient, - packagePath: string | null, - branch: string, -): Promise { - const head = await client.getBranch(branch); - if (!head) return { format: 1, set: {}, delete: [] }; - const { host, path } = client.repo; - const key = `${host}/${path}\0${packagePath ?? ""}\0${head.sha}`; - const now = Date.now(); - const cached = recent.get(key); - if (cached && cached.until > now) return cached.changes; - const changes = changesAt(client, packagePath, head.sha); - recent.delete(key); - recent.set(key, { until: now + REUSE_MS, changes }); - while (recent.size > REUSE_ENTRIES) { - recent.delete(recent.keys().next().value!); - } - changes.catch(() => { - if (recent.get(key)?.changes === changes) recent.delete(key); - }); - return changes; -} - -/** How long, and for how many heads, a computed result is reused. */ -const REUSE_MS = 60_000; -const REUSE_ENTRIES = 100; -const recent = new Map< - string, - { until: number; changes: Promise } ->(); - -/** Forgets reused results (tests). */ -export function clearDraftChangesCache(): void { - recent.clear(); -} - -async function changesAt( - client: RepoContentClient, - packagePath: string | null, - headSha: string, -): Promise { - const empty: DraftChanges = { format: 1, set: {}, delete: [] }; - const production = await client.getDefaultBranch(); - const mergeBaseSha = await client.mergeBase(production, headSha); - if (mergeBaseSha === headSha) return empty; - const [draft, base] = await Promise.all([ - blockFiles(client, headSha, packagePath), - blockFiles(client, mergeBaseSha, packagePath), - ]); - - const memo = new Map(); - const replaced: Winner[] = []; - const deleted = new Set(); - for (const key of new Set([...draft.keys(), ...base.keys()])) { - if (sameGroup(draft.get(key), base.get(key))) continue; - const [now, before] = await Promise.all([ - winnerOf(client, draft.get(key), memo), - winnerOf(client, base.get(key), memo), - ]); - if (now && before && now.file === before.file && now.sha === before.sha) { - continue; - } - if (before && before.name !== now?.name) deleted.add(before.name); - if (now) replaced.push(now); - } - - const tooLarge = () => - new DraftChangesTooLarge( - `draft changes exceed ${MAX_DRAFT_CHANGE_BYTES} bytes; publish or discard some of them to preview`, - ); - // Refuse from tree metadata before reading; the read below re-checks real bytes. - if ( - replaced.reduce((sum, w) => sum + (w.size ?? 0), 0) > MAX_DRAFT_CHANGE_BYTES - ) { - throw tooLarge(); - } - const bodies = await resolveBlockContents( - client, - replaced.map((w) => ({ stem: w.file, sha: w.sha })), - memo, - ); - let bytes = 0; - const set: Array<[string, unknown]> = []; - for (const [i, winner] of replaced.entries()) { - const text = bodies[i]!.content; - bytes += Buffer.byteLength(text); - if (bytes > MAX_DRAFT_CHANGE_BYTES) throw tooLarge(); - set.push([winner.name, parseBlock(winner.file, text)]); - deleted.delete(winner.name); - } - return { - format: 1, - // fromEntries: an entry named "__proto__" stays an entry. - set: Object.fromEntries(set), - delete: [...deleted].sort(), - }; -} diff --git a/apps/api/src/decofile/repo-content-storage.ts b/apps/api/src/decofile/repo-content-storage.ts index 29fa09b925..01e24c05cd 100644 --- a/apps/api/src/decofile/repo-content-storage.ts +++ b/apps/api/src/decofile/repo-content-storage.ts @@ -1,56 +1,44 @@ /** - * The content protocol's storage over a project's Git repository: the site - * editor's GitHub backend (the hosted side of `deco serve`). + * The read side of the content protocol over a project's Git repository: the + * file list, file bodies, schema and secrets key of one branch. The hosted v8 + * editor (`hosted/draft-content-storage.ts`) reads the default branch through + * it and layers the CDN draft on top; nothing here writes. * - * One storage is bound to one `(repository, app root, branch)`. The vendored - * protocol core (`createContentHandler`) owns names, validation, the secret - * guard and retries; this file only lists, reads and commits files - * under `/.deco/`, through the same `RepoContentClient`, blob cache and - * compare-and-swap commit the legacy decofile routes use. - * - * Versions are git blob shas and the revision is the branch head sha. Before - * the first write a missing branch reads as the default branch (reported in - * `resolvedRef`); the first commit creates it at the head it was read from. + * Versions are git blob shas and the revision is the branch head sha. A + * missing branch reads as the default branch (reported in `resolvedRef`). */ -import type { CoAuthorIdentity } from "@decocms/sandbox/shared"; import { - blockNameFromFile, - type CommitResult, type ContentStorage, - type StorageDescription, type StorageSnapshot, type StoredFileBody, type StoredSchema, StorageNotFoundError, StorageUnavailableError, - unsupported, } from "@decocms/blocks/protocol"; import { - type FileChange, type RepoContentClient, - RepoWriteConflict, repoRateLimitRetryAfterMs, requireBranchHead, } from "@/git-providers"; -import { decofileCommitMessage, regenerateGenArtifact } from "./gen-artifact"; import { - type BlockSource, blockEntriesInTree, blocksDirPath, - gitBlobSha, - primeBlobCache, resolveBlockContents, } from "./read-decofile"; -export interface RepoContentStorageOptions { +/** What the hosted draft storage reads from the repository. */ +export type RepoContentReader = Pick< + ContentStorage, + "snapshot" | "readFiles" | "readSchema" | "readSecretsPublicKey" +>; + +interface RepoContentStorageOptions { client: RepoContentClient; /** The app root inside the repository; `null` at the repository root. */ packagePath: string | null; - /** The one branch this storage reads and writes. */ + /** The one branch this storage reads. */ branch: string; - /** Acting user, appended as a `Co-authored-by:` trailer when present. */ - coAuthor?: CoAuthorIdentity | null; } /** @@ -93,9 +81,9 @@ async function guarded(work: () => Promise): Promise { } } -export function createRepoContentStorage( +export function createRepoContentReader( options: RepoContentStorageOptions, -): ContentStorage { +): RepoContentReader { const { client, packagePath, branch } = options; const blocksPrefix = `${blocksDirPath(packagePath)}/`; @@ -142,22 +130,6 @@ export function createRepoContentStorage( }; return { - describe(): StorageDescription { - const description = { - kind: "git" as const, - root: packagePath ?? ".", - readOnly: false, - // Uploads go to Studio's own file storage, never into the repository. - assets: null, - // Only for @decocms/blocks 8.1.0-next.4, whose types require these - // two and whose conformance ties `resolvedRef` to `refs`; the - // protocol drops both. Delete with the next bump. - refs: { default: branch, autoCreate: true }, - idempotency: null, - }; - return description; - }, - snapshot: () => guarded(async (): Promise => { const head = await readHead(); @@ -215,89 +187,5 @@ export function createRepoContentStorage( ? readBlobText(entry.path, entry.sha) : null; }), - - commit: (attempt) => - guarded(async (): Promise => { - const base = attempt.base.revision; - try { - // The whole head is the guard: any commit since `base` is stale, - // which covers every per-file expectation the core asks for. - const current = await client.getBranch(branch); - if (current === null) { - await client.createBranch(branch, base); - } else if (current.sha !== base) { - return { status: "stale" }; - } - // Never write a v7 site through the protocol, even by a direct call. - const schema = await resolveSchema({ sha: base, ref: branch }); - if ( - !schema || - !isV8SchemaText( - await readBlobText(schema.entry.path, schema.entry.sha), - ) - ) { - throw unsupported("not a Blocks v8 site"); - } - if (attempt.expectedSchemaVersion !== undefined) { - if (schema.entry.sha !== attempt.expectedSchemaVersion) { - return { status: "stale" }; - } - } - - const tree = await client.listDecofileEntries(base, packagePath); - const present = new Set(attempt.base.files.map((f) => f.file)); - const nextBlocks = new Map( - blockEntriesInTree(tree, packagePath).map((e) => [ - e.path, - { stem: e.stem, sha: e.sha }, - ]), - ); - const changes: FileChange[] = []; - const versions: Record = {}; - for (const [file, content] of Object.entries(attempt.put)) { - const path = `${blocksPrefix}${file}`; - changes.push({ path, content }); - nextBlocks.set(path, { - stem: file.slice(0, -".json".length), - content, - }); - versions[file] = gitBlobSha(content); - await primeBlobCache(client.repo, content); - } - for (const file of attempt.delete) { - if (!present.has(file)) continue; - const path = `${blocksPrefix}${file}`; - changes.push({ path, deleted: true }); - nextBlocks.delete(path); - } - if (changes.length === 0) { - return { status: "committed", revision: base, versions }; - } - const gen = await regenerateGenArtifact({ - client, - tree, - packagePath, - branch, - nextBlocks: nextBlocks.values(), - memo: new Map(), - }); - if (gen) changes.push(gen); - - const { sha } = await client.commitFiles({ - branch, - message: decofileCommitMessage( - Object.keys(attempt.put).map(blockNameFromFile), - attempt.delete.map(blockNameFromFile), - options.coAuthor, - ), - expectedHead: base, - changes, - }); - return { status: "committed", revision: sha, versions }; - } catch (error) { - if (error instanceof RepoWriteConflict) return { status: "stale" }; - throw error; - } - }), }; } diff --git a/apps/api/src/file-storage/upload-policy.ts b/apps/api/src/file-storage/upload-policy.ts index 14aa4195c4..05678d0664 100644 --- a/apps/api/src/file-storage/upload-policy.ts +++ b/apps/api/src/file-storage/upload-policy.ts @@ -33,7 +33,7 @@ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; * browsers do NOT execute scripts when SVG is loaded as a pure image. * - Top-level navigation, ``, `