Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e6e3238
feat(site-editor): hosted Deco CMS v8 — publish to CDN, CDN drafts, r…
tlgimenes Oct 7, 2026
4ebdc5d
test(e2e): GitHub stub answers commit history; v7 schema check accept…
tlgimenes Oct 7, 2026
5911ea9
chore(deps): pin @decocms/blocks 8.1.0-next.6; drop the next.4 storag…
tlgimenes Oct 7, 2026
f94d8af
fix(hosted): org must own the site; project scope; keep saves made du…
tlgimenes Oct 7, 2026
b6cfa1a
Merge origin/feat/blocks-v8-support into feat/blocks-v8-hosted
tlgimenes Oct 7, 2026
82a9559
feat(hosted): claim a project's siteSlug for its org on create; admin…
tlgimenes Oct 7, 2026
5d9a5aa
fix(hosted): don't auto-claim a slug another org's project names; bou…
tlgimenes Oct 7, 2026
9575c4a
feat(hosted): Releases "Rolled back" by main's history; Resync by the…
tlgimenes Oct 7, 2026
4dfe9ce
Merge feat/blocks-v8-support into feat/blocks-v8-hosted (@decocms/blo…
tlgimenes Oct 7, 2026
99f74c0
Merge feat/blocks-v8-support into feat/blocks-v8-hosted (main + @deco…
tlgimenes Oct 7, 2026
40ed33b
chore(hosted): unexport walkMain (used only in releases.ts; knip)
tlgimenes Oct 7, 2026
1ca3832
feat(hosted): edge-cache latest.json 1 h and purge it after every write
tlgimenes Oct 8, 2026
e0b4fd7
fix(hosted): fail fast when updating latest.json fails
tlgimenes Oct 8, 2026
e84cb5d
fix(hosted): purge latest.json with exactly one retry (5 s per attempt)
tlgimenes Oct 8, 2026
543af34
feat(hosted): Publish is done once merged; Releases derives the CDN s…
tlgimenes Oct 8, 2026
7bb737c
fix(hosted): refresh Releases after Publish; say Not live before the …
tlgimenes Oct 8, 2026
796b9e9
feat(hosted): Releases is a timeline; Current is what latest.json names
tlgimenes Oct 8, 2026
eea8156
fix(hosted): Make current on the Current row; localize its failure
tlgimenes Oct 8, 2026
a05812f
feat(hosted): drop the kill switch and site-token revocation
tlgimenes Oct 8, 2026
c22ccf2
Merge feat/blocks-v8-support (with origin/main) into feat/blocks-v8-h…
tlgimenes Oct 8, 2026
ba3a5a7
Merge feat/blocks-v8-support into feat/blocks-v8-hosted; hosted owner…
tlgimenes Oct 8, 2026
d9cdbab
Merge feat/sandbox-content-protocol into feat/blocks-v8-hosted
tlgimenes Oct 8, 2026
1a73583
Merge feat/sandbox-content-protocol into feat/blocks-v8-hosted
tlgimenes Oct 8, 2026
a7afc84
fix(hosted): report not-found and admin-only relink as their own clai…
tlgimenes Oct 8, 2026
4a53858
Merge feat/sandbox-content-protocol into feat/blocks-v8-hosted
tlgimenes Oct 8, 2026
ca76723
fix(hosted): let an org relink its own used site; drop relink-require…
tlgimenes Oct 8, 2026
d0e9efd
Merge feat/sandbox-content-protocol into feat/blocks-v8-hosted
tlgimenes Oct 8, 2026
a68e1ac
docs(api): rewrap claim-site header comment
tlgimenes Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions apps/api/src/api/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -947,6 +955,117 @@ export function createAdminRoutes(): Hono<Env> {
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<string, { id: string; slug: string; name: string }>();
for (const row of rows) {
let metadata: Record<string, unknown> | null = null;
try {
metadata =
typeof row.metadata === "string"
? (JSON.parse(row.metadata) as Record<string, unknown>)
: (row.metadata as Record<string, unknown> | 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());
Expand Down
176 changes: 122 additions & 54 deletions apps/api/src/api/routes/decofile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,21 @@
* 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
* session needs to render and to commit. The project's `fastPreview` switch
* 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
Expand Down Expand Up @@ -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";
Expand All @@ -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 & {
Expand Down Expand Up @@ -233,6 +237,11 @@ const resolveDecofileScope = createMiddleware<DecofileEnv>(async (c, next) => {
repository,
userId,
previewServerUrl,
site: await ownedProjectSite(
ctx.storage.orgSites,
virtualMcpId,
organization.id,
),
});
return next();
});
Expand Down Expand Up @@ -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<DecofileEnv>,
): 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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" },
Expand All @@ -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;
}
Loading
Loading