diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index e0054d2ca1..eeaa920ba2 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -58,4 +58,8 @@ ENV DATABASE_URL=file:/app/data/mesh.db # The CLI handles migrations automatically on startup # Use --skip-migrations if you want to manage migrations separately -CMD ["bun", "run", "deco", "--no-tui", "--no-local-mode"] +# Runs Studio's CLI by path, not through the `deco` bin name: another installed +# package may declare a `deco` bin too (@decocms/blocks does), and whichever +# wins `node_modules/.bin/deco` would start instead of Studio +# (src/cli/deco-bin.test.ts). +CMD ["bun", "run", "node_modules/decocms/dist/server/cli.js", "--no-tui", "--no-local-mode"] diff --git a/apps/api/package.json b/apps/api/package.json index 83f4c6f4e1..304f61eb85 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -67,6 +67,7 @@ "@better-auth/sso": "1.4.1", "@decocms/better-auth": "1.5.17", "@decocms/bindings": "workspace:*", + "@decocms/blocks": "8.1.0-next.7", "@decocms/mcp-utils": "workspace:*", "@decocms/runtime": "workspace:*", "@decocms/sandbox": "workspace:*", diff --git a/apps/api/scripts/smoke-tarball.ts b/apps/api/scripts/smoke-tarball.ts index 61123b9302..89edc321fa 100644 --- a/apps/api/scripts/smoke-tarball.ts +++ b/apps/api/scripts/smoke-tarball.ts @@ -25,7 +25,7 @@ */ import { $ } from "bun"; -import { mkdtemp, writeFile } from "fs/promises"; +import { mkdtemp, realpath, writeFile } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; @@ -71,7 +71,25 @@ if (!(await Bun.file(clientIndex).exists())) { // eagerly during load, so a missing external crashes here before // --version prints — same symptom a real consumer would hit. const cliBin = join(scratch, "node_modules", ".bin", "deco"); +const studioCli = join( + scratch, + "node_modules", + "decocms", + "dist", + "server", + "cli.js", +); +// Another installed package with a `deco` bin (@decocms/blocks has one) can +// win the link and start instead of Studio: the bin must be Studio's CLI. +if ((await realpath(cliBin)) !== (await realpath(studioCli))) { + console.error( + `node_modules/.bin/deco resolves to ${await realpath(cliBin)}, not Studio's ${studioCli}`, + ); + process.exit(1); +} await $`${cliBin} --version`.cwd(scratch); +// What the Docker image runs (apps/api/Dockerfile CMD): the CLI by path. +await $`bun run node_modules/decocms/dist/server/cli.js --version`.cwd(scratch); console.log( "✅ Smoke test passed — browser assets are present and every external resolves at startup.", diff --git a/apps/api/src/api/routes/admin.ts b/apps/api/src/api/routes/admin.ts index d41d8079a5..fef4377b2a 100644 --- a/apps/api/src/api/routes/admin.ts +++ b/apps/api/src/api/routes/admin.ts @@ -34,6 +34,14 @@ import { OrgSiteStorage, } from "@/storage/org-sites"; import { VirtualMCPStorage } from "@/storage/virtual"; +import { + backfillSiteClaims, + decoSiteExists, + type SiteClaimCandidate, +} from "@/hosted/claim-site"; +import { mainIsV8, projectSite } from "@/hosted/scope"; +import { contentClientForProjectRepo } from "@/git-providers"; +import { parseRepositoryBinding } from "@/tools/sandbox/sync-git-credentials"; import { OrgNoticeInputSchema } from "@decocms/shared/organization/notice"; import { isOrgArchived } from "@decocms/shared/organization/org-archived"; import { invalidateOrgNoticeCache } from "@/core/org-notice-gate"; @@ -947,6 +955,117 @@ export function createAdminRoutes(): Hono { return c.json({ ok: true }); }); + /** + * Links every existing Blocks v8 project (main's `.deco/schema.gen.json` + * says `blocksMajor: 8`) to the site it names (`metadata.siteSlug`) in + * `org_sites`, as project creation now does, claiming a free slug for its + * org first. Safe to re-run; never takes a slug another org, project or + * deco.cx has, nor a deleted org's (reported as `refused`), and links + * nobody to a slug several orgs' or projects name (`ambiguous`). A dry run + * unless the body says `{"dryRun": false}`. A route rather than a + * migration: telling a v8 project needs a GitHub read. + */ + app.post("/hosted/site-claims/backfill", async (c) => { + const raw = (await c.req.json().catch(() => ({}))) as { + dryRun?: unknown; + } | null; + const dryRun = raw?.dryRun !== false; + const { actorId: effectiveActorId, impersonatedBy } = + await getAuditActor(c); + const actorId = impersonatedBy ?? effectiveActorId; + if (!actorId) { + return c.json({ error: "Unauthorized" }, 401); + } + const db = getDb().db; + const ctx = c.var.studioContext; + const rows = await db + .selectFrom("connections") + .innerJoin( + "organization", + "organization.id", + "connections.organization_id", + ) + .select([ + "connections.id as id", + "connections.organization_id as organizationId", + "connections.metadata as metadata", + "organization.slug as orgSlug", + "organization.name as orgName", + ]) + .where("connections.connection_type", "=", "VIRTUAL") + .orderBy("connections.created_at", "asc") + .execute(); + const candidates: SiteClaimCandidate[] = []; + const orgs = new Map(); + for (const row of rows) { + let metadata: Record | null = null; + try { + metadata = + typeof row.metadata === "string" + ? (JSON.parse(row.metadata) as Record) + : (row.metadata as Record | null); + } catch { + continue; + } + const slug = projectSite(metadata); + if (!slug) continue; + candidates.push({ + organizationId: row.organizationId, + projectId: row.id, + slug, + }); + orgs.set(row.organizationId, { + id: row.organizationId, + slug: row.orgSlug, + name: row.orgName, + }); + } + const projects = new VirtualMCPStorage(db); + const report = await backfillSiteClaims({ + orgSites: new OrgSiteStorage(db), + isDecoSite: decoSiteExists, + candidates, + by: actorId, + dryRun, + isV8: async ({ organizationId, projectId }) => { + const [project] = await projects.listByIds(organizationId, [projectId]); + const metadata = (project?.metadata ?? null) as Record< + string, + unknown + > | null; + const repository = parseRepositoryBinding( + metadata, + project?.connections?.map((conn) => conn.connection_id) ?? [], + ); + if (!repository) return false; + // Credentials resolve against the project's org, as in admin-prompts. + const client = await contentClientForProjectRepo( + { ...ctx, organization: orgs.get(organizationId)! }, + organizationId, + repository, + ); + const runtime = metadata?.runtime as + | { path?: string | null } + | undefined; + return mainIsV8( + client, + runtime?.path?.replace(/^\/+|\/+$/g, "") || null, + await client.getDefaultBranch(), + ); + }, + }); + auditAdminAction("hosted_site_claims_backfill", { + actor_user_id: actorId, + ...(impersonatedBy ? { impersonated_user_id: effectiveActorId } : {}), + dry_run: dryRun, + linked: report.linked.length, + refused: report.refused.length, + ambiguous: report.ambiguous.length, + errors: report.errors.length, + }); + return c.json(report); + }); + // 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 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/api/routes/sandbox-proxy.content.test.ts b/apps/api/src/api/routes/sandbox-proxy.content.test.ts new file mode 100644 index 0000000000..0721bcc985 --- /dev/null +++ b/apps/api/src/api/routes/sandbox-proxy.content.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "bun:test"; +import { Hono, type Context } from "hono"; +import { proxyContentAsset, proxyContentRpc } from "./sandbox-proxy"; + +type Handler = typeof proxyContentRpc; +type VmContext = Parameters[0]; + +interface Sent { + claimName: string; + path: string; + method: string; + contentType: string | null; + body: Uint8Array; +} + +/** + * The two content-protocol routes behind a claim like `resolveVmClaim` sets, + * with a runner that records what reached it. + */ +function app(runtime: "sandbox" | "cms", answer: Response) { + const sent: Sent[] = []; + const runner = { + proxyDaemonRequest: async ( + claimName: string, + path: string, + init: { method: string; headers: Headers; body: BodyInit | null }, + ) => { + sent.push({ + claimName, + path, + method: init.method, + contentType: init.headers.get("content-type"), + body: new Uint8Array(await new Response(init.body).arrayBuffer()), + }); + return answer; + }, + adoptLiveClaim: async () => false, + }; + const hono = new Hono(); + hono.use("/:virtualMcpId/:branch/*", async (c, next) => { + (c as unknown as Context).set("vmClaim", { + claimName: "claim-a", + callerUserId: "u1", + runner: runtime === "cms" ? null : runner, + virtualMcpId: c.req.param("virtualMcpId"), + branch: c.req.param("branch"), + userId: "u1", + projectRef: "p1", + virtualMcpMetadata: null, + connectionIds: [], + runtime, + }); + await next(); + }); + const route = (h: Handler) => (c: Context) => h(c as unknown as VmContext); + hono.post("/:virtualMcpId/:branch/rpc", route(proxyContentRpc)); + hono.put("/:virtualMcpId/:branch/assets/:name", route(proxyContentAsset)); + return { hono, sent }; +} + +const rpcAnswer = () => + new Response('{"jsonrpc":"2.0","id":1,"result":{}}', { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + +describe("content protocol proxy", () => { + it("forwards an rpc request's JSON body to the daemon's /_sandbox/rpc", async () => { + const { hono, sent } = app("sandbox", rpcAnswer()); + const body = '{"jsonrpc":"2.0","id":1,"method":"describe"}'; + const res = await hono.request("/vm1/main/rpc", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ jsonrpc: "2.0", id: 1, result: {} }); + expect(res.headers.get("cache-control")).toContain("no-store"); + expect(sent).toHaveLength(1); + expect(sent[0]!.path).toBe("/_sandbox/rpc"); + expect(sent[0]!.method).toBe("POST"); + expect(sent[0]!.contentType).toBe("application/json"); + expect(new TextDecoder().decode(sent[0]!.body)).toBe(body); + }); + + it("passes the daemon's protocol errors through unchanged", async () => { + const { hono } = app( + "sandbox", + new Response( + '{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"use POST"}}', + { status: 405, headers: { "content-type": "application/json" } }, + ), + ); + const res = await hono.request("/vm1/main/rpc", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect(res.status).toBe(405); + expect(((await res.json()) as { error: { code: number } }).error.code).toBe( + -32600, + ); + }); + + it("forwards an asset's bytes and type to /_sandbox/assets/", async () => { + const { hono, sent } = app( + "sandbox", + new Response('{"path":"/assets/logo%20a.png"}', { + status: 201, + headers: { "content-type": "application/json" }, + }), + ); + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]); + const res = await hono.request( + `/vm1/main/assets/${encodeURIComponent("logo a.png")}`, + { + method: "PUT", + headers: { "content-type": "image/png" }, + body: bytes, + }, + ); + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ path: "/assets/logo%20a.png" }); + expect(sent[0]!.path).toBe("/_sandbox/assets/logo%20a.png"); + expect(sent[0]!.method).toBe("PUT"); + expect(sent[0]!.contentType).toBe("image/png"); + expect([...sent[0]!.body]).toEqual([...bytes]); + }); + + it("answers 404 for a sandbox-less session, with no daemon call", async () => { + const { hono, sent } = app("cms", rpcAnswer()); + const res = await hono.request("/vm1/main/rpc", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect(res.status).toBe(404); + expect(sent).toHaveLength(0); + }); +}); diff --git a/apps/api/src/api/routes/sandbox-proxy.ts b/apps/api/src/api/routes/sandbox-proxy.ts index c1805d5077..909f892e95 100644 --- a/apps/api/src/api/routes/sandbox-proxy.ts +++ b/apps/api/src/api/routes/sandbox-proxy.ts @@ -71,6 +71,12 @@ import { parseLoaderInvokeRequest, } from "../../lib/loader-invoke"; import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url"; +import { + draftGitDiscard, + hostedDraftPublishDiff, + hostedDraftPublishStatus, + loadHostedDraft, +} from "../../hosted/draft-publish-status"; import { GitPushAuthError, parseRepositoryBinding, @@ -130,6 +136,13 @@ function assertSandboxBranchParam(branch: string): void { } const JUDGE_REVIEW_MAX_BODY_BYTES = 512 * 1024; +/** + * The content protocol's own caps (`@decocms/blocks/protocol` limits): 8 MiB + * per request and 25 MiB per asset (`describe.assets.maxBytes`), plus slack so + * the daemon, not this proxy, answers a body right at the limit. + */ +const CONTENT_RPC_MAX_BODY_BYTES = 8 * 1024 * 1024 + 64 * 1024; +const CONTENT_ASSET_MAX_BODY_BYTES = 25 * 1024 * 1024 + 64 * 1024; const PREVIEW_INVOKE_MAX_BODY_BYTES = 64 * 1024; /** @@ -393,6 +406,44 @@ async function fastPreviewGitClient(c: Context) { return contentClientForProjectRepo(ctx, organization.id, repository); } +/** The hosted v8 project's draft (see hosted/draft-publish-status.ts), or null. */ +function loadHostedDraftFor(c: Context) { + const claim = c.get("vmClaim"); + const ctx = c.var.studioContext; + return loadHostedDraft({ + storage: ctx.storage, + organizationId: requireOrganization(ctx).id, + virtualMcpId: claim.virtualMcpId, + branch: claim.branch, + metadata: claim.virtualMcpMetadata, + gitClient: () => fastPreviewGitClient(c), + }); +} + +/** `/git/status` without a sandbox: the branch's drift, or a hosted draft's. */ +async function fastPreviewStatus(c: Context) { + const hosted = await loadHostedDraftFor(c); + if (hosted) { + return hostedDraftPublishStatus( + 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 loadHostedDraftFor(c); + if (hosted) return hostedDraftPublishDiff(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 @@ -458,8 +509,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); @@ -512,6 +562,12 @@ async function proxyDaemon( forwardJsonBody?: boolean; /** When set, sent instead of reading the request body. */ jsonBody?: string; + /** + * A binary body (an asset upload), sent with `contentType`. Buffered, not + * streamed, so the runner can resend it when it retries a 401. + */ + rawBody?: ArrayBuffer; + contentType?: string; signal?: AbortSignal; /** Map 404 to 410 (sandbox needs re-provision). */ map404to410?: boolean; @@ -539,10 +595,13 @@ async function proxyDaemon( const { claimName, userId, projectRef } = c.get("vmClaim"); const method = opts?.method ?? "POST"; - let body: string | null = null; + let body: string | ArrayBuffer | null = null; const headers = new Headers(); - if (opts?.jsonBody !== undefined) { + if (opts?.rawBody !== undefined) { + body = opts.rawBody; + headers.set("content-type", opts.contentType ?? "application/octet-stream"); + } else if (opts?.jsonBody !== undefined) { body = opts.jsonBody; headers.set("content-type", "application/json"); } else if (opts?.forwardJsonBody) { @@ -653,6 +712,25 @@ async function proxyDaemon( } } +/** `POST …/rpc`: one content-protocol request to the daemon's `/_sandbox/rpc`. */ +export function proxyContentRpc(c: Context) { + return proxyDaemon(c, "/_sandbox/rpc", { + forwardJsonBody: true, + signal: AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(30_000)]), + }); +} + +/** `PUT …/assets/:name`: an asset upload to the daemon's `/_sandbox/assets/`. */ +export async function proxyContentAsset(c: Context) { + const name = c.req.param("name") ?? ""; + return proxyDaemon(c, `/_sandbox/assets/${encodeURIComponent(name)}`, { + method: "PUT", + rawBody: await c.req.arrayBuffer(), + contentType: c.req.header("content-type"), + signal: AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(60_000)]), + }); +} + /** * Set `repoDir` to null in a daemon config JSON payload. Returns the input * unchanged if it isn't a JSON object with a `repoDir` key, so a non-JSON or @@ -805,6 +883,37 @@ export const createSandboxRoutes = () => { return proxyDaemon(c, `/_sandbox/exec/${encodeURIComponent(script)}/kill`); }); + // -- Content protocol (Blocks v8) ----------------------------------------- + // The daemon serves the working tree's `.deco/blocks` over the content + // protocol, as a `deco serve` would on a developer's machine. A sandbox-less + // session has no daemon: 404, which the editor reads as "no protocol". + app.post( + "/:virtualMcpId/:branch/rpc", + bodyLimit({ + maxSize: CONTENT_RPC_MAX_BODY_BYTES, + onError: (c) => + c.json( + { error: "Payload too large" }, + 413, + SANDBOX_PROXY_CACHE_HEADERS, + ), + }), + proxyContentRpc, + ); + app.put( + "/:virtualMcpId/:branch/assets/:name", + bodyLimit({ + maxSize: CONTENT_ASSET_MAX_BODY_BYTES, + onError: (c) => + c.json( + { error: "Payload too large" }, + 413, + SANDBOX_PROXY_CACHE_HEADERS, + ), + }), + proxyContentAsset, + ); + // -- Tenant config -------------------------------------------------------- app.get("/:virtualMcpId/:branch/config", (c) => proxyDaemon(c, "/_sandbox/config", { @@ -955,8 +1064,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); @@ -1040,6 +1148,11 @@ export const createSandboxRoutes = () => { ); } try { + const hosted = await loadHostedDraftFor(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); @@ -1167,13 +1280,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)]); // The only route under /sandbox that spends real money: one model call // on the org's gateway credential, with a prompt the caller sizes (up // to the body limit above). It is a plain BFF route, so `defineTool`'s diff --git a/apps/api/src/cli/deco-bin.test.ts b/apps/api/src/cli/deco-bin.test.ts new file mode 100644 index 0000000000..bd5fd33836 --- /dev/null +++ b/apps/api/src/cli/deco-bin.test.ts @@ -0,0 +1,77 @@ +/** + * The published `decocms` package and its Docker image must start Studio's + * own CLI. `@decocms/blocks` also declares a `deco` bin: as a runtime + * dependency, `bun add decocms` linked whichever `deco` won into + * `node_modules/.bin`, and the image started the Blocks CLI + * (`unknown command "--no-tui"`). The server bundle inlines what it needs, so + * no runtime dependency may declare a `deco` bin, and the image runs the CLI + * by path. `scripts/smoke-tarball.ts` checks the same on the packed tarball. + */ + +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const API_ROOT = join(import.meta.dir, "..", ".."); + +interface PackageJson { + name: string; + bin?: string | Record; + dependencies?: Record; + optionalDependencies?: Record; +} + +function readPackage(dir: string): PackageJson { + return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); +} + +function binNames(pkg: PackageJson): string[] { + if (!pkg.bin) return []; + if (typeof pkg.bin === "string") return [pkg.name.split("/").pop()!]; + return Object.keys(pkg.bin); +} + +const api = readPackage(API_ROOT); +const runtimeDeps = { + ...api.dependencies, + ...api.optionalDependencies, +}; + +describe("the deco bin", () => { + test("is Studio's CLI", () => { + expect(api.name).toBe("decocms"); + expect(api.bin).toEqual({ deco: "./dist/server/cli.js" }); + }); + + test("@decocms/blocks is bundled, never a runtime dependency", () => { + expect(Object.keys(runtimeDeps)).not.toContain("@decocms/blocks"); + }); + + test("no installed runtime dependency declares another deco bin", () => { + const clashes = Object.keys(runtimeDeps).filter((name) => { + const dir = join(API_ROOT, "node_modules", name); + // Optional platform packages may be missing on this machine. + if (!existsSync(join(dir, "package.json"))) return false; + return binNames(readPackage(dir)).includes("deco"); + }); + expect(clashes).toEqual([]); + }); + + test("the Docker image runs the CLI by path", () => { + const dockerfile = readFileSync(join(API_ROOT, "Dockerfile"), "utf8"); + const cmd = dockerfile + .split("\n") + .filter((line) => line.startsWith("CMD ")) + .pop(); + expect(cmd).toBeDefined(); + const argv = JSON.parse(cmd!.slice("CMD ".length)) as string[]; + // `bun add` of the tarball installs it at node_modules/decocms. + const cliPath = join( + "node_modules", + api.name, + (api.bin as Record).deco!, + ); + expect(argv.slice(0, 3)).toEqual(["bun", "run", cliPath]); + expect(argv).toContain("--no-tui"); + }); +}); diff --git a/apps/api/src/decofile/commit-coalescer.ts b/apps/api/src/decofile/commit-coalescer.ts index e5b3df6a33..f0ba25411a 100644 --- a/apps/api/src/decofile/commit-coalescer.ts +++ b/apps/api/src/decofile/commit-coalescer.ts @@ -1,9 +1,5 @@ -import { - appendCoAuthorTrailer, - type CoAuthorIdentity, -} from "@decocms/sandbox/shared"; -import { blockKeyToFileStem, mergeBlocks } from "@decocms/shared/decofile"; -import { repoIdentityKey } from "@decocms/shared/git-providers"; +import type { CoAuthorIdentity } from "@decocms/sandbox/shared"; +import { blockKeyToFileStem } from "@decocms/shared/decofile"; import { exponentialBackoffWithJitter, sleep } from "@decocms/shared/std"; import { type FileChange, @@ -15,9 +11,9 @@ import { blockEntriesInTree, blocksDirPath, primeBlobCache, - resolveBlockContents, resolveOrCreateHead, } from "./read-decofile"; +import { decofileCommitMessage, regenerateGenArtifact } from "./gen-artifact"; /** * Per-(virtualMcpId, branch) commit coalescer. Autosaves arrive every ~700ms @@ -174,33 +170,24 @@ async function commitBatch(batch: Batch): Promise { if (writes.length === 0) return headSha; - // Repos that track the merged artifact get it regenerated in-commit; - // gitignored repos (the common case) never have the tree entry. - const genPath = packagePath - ? `${packagePath}/.deco/blocks.gen.json` - : ".deco/blocks.gen.json"; - if (tree.some((e) => e.type === "blob" && e.path === genPath)) { - const files = await resolveBlockContents( - client, - nextBlocks.values(), - blobMemo, - ); - const { decofile: genContent, skipped } = mergeBlocks(files); - if (skipped.length > 0) { - console.warn("decofile gen: dropped blocks that were not valid JSON", { - repo: repoIdentityKey(client.repo), - branch, - packagePath, - blocks: skipped.map((s) => s.key), - }); - } - writes.push({ path: genPath, content: genContent }); - } + const gen = await regenerateGenArtifact({ + client, + tree, + packagePath, + branch, + nextBlocks: nextBlocks.values(), + memo: blobMemo, + }); + if (gen) writes.push(gen); try { const { sha } = await client.commitFiles({ branch, - message: commitMessage(batch), + message: decofileCommitMessage( + [...batch.set.keys()], + [...batch.del], + batch.deps.coAuthor, + ), expectedHead: headSha, changes: writes, }); @@ -217,16 +204,3 @@ async function commitBatch(batch: Batch): Promise { } } } - -function commitMessage(batch: Batch): string { - const summarize = (keys: string[]): string => { - const shown = keys.slice(0, 3).join(", "); - return keys.length > 3 ? `${shown} (+${keys.length - 3} more)` : shown; - }; - const parts: string[] = []; - if (batch.set.size > 0) - parts.push(`update ${summarize([...batch.set.keys()])}`); - if (batch.del.size > 0) parts.push(`delete ${summarize([...batch.del])}`); - const subject = `chore(decofile): ${parts.join("; ")}`; - return appendCoAuthorTrailer(subject, batch.deps.coAuthor); -} diff --git a/apps/api/src/decofile/gen-artifact.ts b/apps/api/src/decofile/gen-artifact.ts new file mode 100644 index 0000000000..ed945e9043 --- /dev/null +++ b/apps/api/src/decofile/gen-artifact.ts @@ -0,0 +1,71 @@ +import { + appendCoAuthorTrailer, + type CoAuthorIdentity, +} from "@decocms/sandbox/shared"; +import { mergeBlocks } from "@decocms/shared/decofile"; +import { repoIdentityKey } from "@decocms/shared/git-providers"; +import type { FileChange, RepoContentClient, TreeEntry } from "@/git-providers"; +import { type BlockSource, resolveBlockContents } from "./read-decofile"; + +/** Repo-relative path of the merged `blocks.gen.json` artifact. */ +function genArtifactPath(packagePath: string | null): string { + return packagePath + ? `${packagePath}/.deco/blocks.gen.json` + : ".deco/blocks.gen.json"; +} + +/** + * The regenerated `blocks.gen.json`, for a commit that changes `.deco/blocks`. + * + * Repos that track the merged artifact get it rewritten in the same commit, so + * it never disagrees with the block files; gitignored repos (the common case) + * have no tree entry and get `null`. `nextBlocks` is the post-commit view of + * the blocks dir; `memo` is the blob memo a compare-and-swap retry loop + * threads through, so a retry never re-reads a blob it already resolved. + */ +export async function regenerateGenArtifact(params: { + client: RepoContentClient; + tree: TreeEntry[]; + packagePath: string | null; + branch: string; + nextBlocks: Iterable; + memo: Map; +}): Promise { + const { client, tree, packagePath, branch } = params; + const genPath = genArtifactPath(packagePath); + if (!tree.some((e) => e.type === "blob" && e.path === genPath)) return null; + const files = await resolveBlockContents( + client, + params.nextBlocks, + params.memo, + ); + const { decofile, skipped } = mergeBlocks(files); + if (skipped.length > 0) { + console.warn("decofile gen: dropped blocks that were not valid JSON", { + repo: repoIdentityKey(client.repo), + branch, + packagePath, + blocks: skipped.map((s) => s.key), + }); + } + return { path: genPath, content: decofile }; +} + +/** The commit message of a decofile write: what changed, plus the co-author. */ +export function decofileCommitMessage( + setKeys: string[], + deleteKeys: string[], + coAuthor: CoAuthorIdentity | null | undefined, +): string { + const summarize = (keys: string[]): string => { + const shown = keys.slice(0, 3).join(", "); + return keys.length > 3 ? `${shown} (+${keys.length - 3} more)` : shown; + }; + const parts: string[] = []; + if (setKeys.length > 0) parts.push(`update ${summarize(setKeys)}`); + if (deleteKeys.length > 0) parts.push(`delete ${summarize(deleteKeys)}`); + return appendCoAuthorTrailer( + `chore(decofile): ${parts.join("; ")}`, + coAuthor, + ); +} diff --git a/apps/api/src/decofile/legacy-secret-guard.test.ts b/apps/api/src/decofile/legacy-secret-guard.test.ts new file mode 100644 index 0000000000..397416a532 --- /dev/null +++ b/apps/api/src/decofile/legacy-secret-guard.test.ts @@ -0,0 +1,128 @@ +/** + * The content protocol's secret guard on a legacy (v7) site. + * + * A v7 `website/loaders/secret.ts` block marks its `encrypted` string + * `"format": "secret"`, but that string is the site's own hex ciphertext, not + * a v8 `Secret` field. The guard must let it through unchanged, and stay strict + * for v8 `Secret` fields and `secret` blocks. The published + * `@decocms/blocks@8.1.0-next.3` lacks the exemption, so Studio carries it as + * `patches/@decocms%2Fblocks@8.1.0-next.3.patch` until a release includes it. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createContentHandler } from "@decocms/blocks/protocol/server"; +import { createFsStorage } from "@decocms/blocks/protocol/storage/fs"; + +const LOADER = "website/loaders/secret.ts"; + +/** A v7 `meta.gen.json` with the secret loader, an app that uses it, and a v8 section. */ +const meta = { + manifest: { + blocks: { + loaders: { [LOADER]: { $ref: "#/definitions/c2VjcmV0" } }, + apps: { "site/apps/site.ts": { $ref: "#/definitions/c2l0ZQ==" } }, + sections: { newsletter: { $ref: "#/definitions/bmV3c2xldHRlcg==" } }, + }, + }, + schema: { + definitions: { + c2VjcmV0: { + type: "object", + properties: { + name: { type: "string" }, + encrypted: { type: "string", format: "secret" }, + }, + }, + "c2l0ZQ==": { + type: "object", + properties: { apiKey: { $ref: "#/definitions/c2VjcmV0" } }, + }, + "bmV3c2xldHRlcg==": { + type: "object", + properties: { apiKey: { type: "string", format: "secret" } }, + }, + }, + }, +}; + +let root: string; +let handler: (request: Request) => Promise; + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "legacy-secret-guard-")); + await mkdir(join(root, ".deco", "blocks"), { recursive: true }); + await writeFile(join(root, ".deco", "meta.gen.json"), JSON.stringify(meta)); + handler = createContentHandler(createFsStorage({ root }), { + server: { name: "test", version: "0" }, + }); +}); + +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function apply(set: Record) { + const res = await handler( + new Request("http://localhost/rpc", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "blocks.apply", + params: { set }, + }), + }), + ); + return (await res.json()) as { + result?: { revision: string }; + error?: { code: number; message: string }; + }; +} + +describe("secret guard on a legacy site", () => { + test("saves a v7 secret loader block's hex ciphertext unchanged", async () => { + const body = await apply({ + site: { + __resolveType: "site/apps/site.ts", + apiKey: { + __resolveType: LOADER, + name: "API_KEY", + encrypted: "0a1b2c3d", + }, + }, + }); + expect(body.error).toBeUndefined(); + expect(body.result?.revision).toEqual(expect.any(String)); + }); + + test("still refuses plain text in a v8 Secret field", async () => { + const body = await apply({ + news: { __resolveType: "newsletter", apiKey: "plain-text" }, + }); + expect(body.error).toBeDefined(); + }); + + test("still refuses a malformed v8 secret block", async () => { + const body = await apply({ + news: { + __resolveType: "newsletter", + apiKey: { __resolveType: "secret", ciphertext: "0a1b2c3d" }, + }, + }); + expect(body.error).toBeDefined(); + }); + + test("still refuses a v7 loader block in a v8 Secret field", async () => { + const body = await apply({ + news: { + __resolveType: "newsletter", + apiKey: { __resolveType: LOADER, encrypted: "0a1b2c3d" }, + }, + }); + expect(body.error).toBeDefined(); + }); +}); diff --git a/apps/api/src/decofile/repo-content-storage.ts b/apps/api/src/decofile/repo-content-storage.ts new file mode 100644 index 0000000000..01e24c05cd --- /dev/null +++ b/apps/api/src/decofile/repo-content-storage.ts @@ -0,0 +1,191 @@ +/** + * 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. + * + * 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 ContentStorage, + type StorageSnapshot, + type StoredFileBody, + type StoredSchema, + StorageNotFoundError, + StorageUnavailableError, +} from "@decocms/blocks/protocol"; +import { + type RepoContentClient, + repoRateLimitRetryAfterMs, + requireBranchHead, +} from "@/git-providers"; +import { + blockEntriesInTree, + blocksDirPath, + resolveBlockContents, +} from "./read-decofile"; + +/** 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. */ + branch: string; +} + +/** + * Whether committed schema text is a Blocks v8 one: only a top-level + * `"blocksMajor": 8` (written by `deco schema`) counts. Missing, any other + * value or unparseable text is a v7 site, which this protocol never serves. + * Mirrors the web's `isV8Schema`. + */ +function isV8SchemaText(text: string): boolean { + try { + const schema: unknown = JSON.parse(text); + return ( + typeof schema === "object" && + schema !== null && + !Array.isArray(schema) && + (schema as { blocksMajor?: unknown }).blocksMajor === 8 + ); + } catch { + return false; + } +} + +function decoPath(packagePath: string | null, file: string): string { + return packagePath ? `${packagePath}/.deco/${file}` : `.deco/${file}`; +} + +/** Turns provider failures into the storage errors the core understands. */ +async function guarded(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + const retryAfterMs = repoRateLimitRetryAfterMs(error); + if (retryAfterMs !== undefined) { + throw new StorageUnavailableError( + "the Git provider is rate-limiting this repository", + retryAfterMs ?? undefined, + ); + } + throw error; + } +} + +export function createRepoContentReader( + options: RepoContentStorageOptions, +): RepoContentReader { + const { client, packagePath, branch } = options; + const blocksPrefix = `${blocksDirPath(packagePath)}/`; + + /** The bound branch's head, or the default branch's while it doesn't exist. */ + const readHead = async (): Promise<{ sha: string; ref: string }> => { + const head = await client.getBranch(branch); + if (head) return { sha: head.sha, ref: branch }; + const defaultBranch = await client.getDefaultBranch(); + return { + sha: await requireBranchHead(client, defaultBranch), + ref: defaultBranch, + }; + }; + + /** `schema.gen.json`, else `meta.gen.json`, at one commit. */ + const schemaEntryAt = async (sha: string) => { + const paths = [ + decoPath(packagePath, "schema.gen.json"), + decoPath(packagePath, "meta.gen.json"), + ]; + const entries = await client.getEntriesAtPaths(sha, paths); + for (const path of paths) { + const entry = entries.get(path); + if (entry?.type === "blob") return entry; + } + return null; + }; + + /** The schema the bound branch sees, falling back to the default branch's. */ + const resolveSchema = async (head: { sha: string; ref: string }) => { + const entry = await schemaEntryAt(head.sha); + if (entry) return { entry, ref: head.ref }; + const defaultBranch = await client.getDefaultBranch(); + if (defaultBranch === head.ref) return null; + const fallback = await schemaEntryAt( + await requireBranchHead(client, defaultBranch), + ); + return fallback ? { entry: fallback, ref: defaultBranch } : null; + }; + + const readBlobText = async (stem: string, sha: string): Promise => { + const [file] = await resolveBlockContents(client, [{ stem, sha }]); + return file!.content; + }; + + return { + snapshot: () => + guarded(async (): Promise => { + const head = await readHead(); + const tree = await client.listDecofileEntries(head.sha, packagePath); + if (tree.length === 0 && (await schemaEntryAt(head.sha)) === null) { + throw new StorageNotFoundError( + `no .deco folder in ${packagePath ?? "the repository root"}`, + ); + } + return { + revision: head.sha, + resolvedRef: head.ref, + files: blockEntriesInTree(tree, packagePath).map((entry) => ({ + file: entry.path.slice(blocksPrefix.length), + version: entry.sha, + size: entry.size, + })), + }; + }), + + readFiles: (snapshot, files) => + guarded(async () => { + const wanted = new Set(files); + const sources = snapshot.files.filter((f) => wanted.has(f.file)); + // A snapshot is a commit, so the bytes always match its versions. + const contents = await resolveBlockContents( + client, + sources.map((f) => ({ stem: f.file, sha: f.version })), + ); + const out: Record = {}; + sources.forEach((f, i) => { + out[f.file] = { text: contents[i]!.content, version: f.version }; + }); + return out; + }), + + readSchema: () => + guarded(async (): Promise => { + const schema = await resolveSchema(await readHead()); + if (!schema) return null; + const text = await readBlobText(schema.entry.path, schema.entry.sha); + // A v7 site reads as schemaless, so the editor stays on the classic one. + if (!isV8SchemaText(text)) return null; + return { version: schema.entry.sha, text, resolvedRef: schema.ref }; + }), + + readSecretsPublicKey: () => + guarded(async () => { + const head = await readHead(); + const path = decoPath(packagePath, "secrets.pub"); + const entry = (await client.getEntriesAtPaths(head.sha, [path])).get( + path, + ); + return entry?.type === "blob" + ? readBlobText(entry.path, entry.sha) + : null; + }), + }; +} 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, ``, `