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 ebab5abd8e..f3e40faca1 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,18 @@ 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 { 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"; @@ -80,6 +82,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 +237,11 @@ const resolveDecofileScope = createMiddleware(async (c, next) => { repository, userId, previewServerUrl, + site: await ownedProjectSite( + ctx.storage.orgSites, + virtualMcpId, + organization.id, + ), }); return next(); }); @@ -264,6 +273,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 +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, @@ -472,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); } @@ -550,16 +648,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 +681,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..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.ts b/apps/api/src/api/routes/sandbox-proxy.ts index cc353bf43d..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, @@ -400,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 @@ -465,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); @@ -1021,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); @@ -1106,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); @@ -1233,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/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 8f5687c45d..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,17 +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, - }; - return description; - }, - snapshot: () => guarded(async (): Promise => { const head = await readHead(); @@ -210,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, ``, `