Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
a403831
chore(shared): vendor the Blocks content protocol
tlgimenes Oct 3, 2026
6398a60
feat(shared): add the site_editor_content_protocol org flag
tlgimenes Oct 3, 2026
fe93039
feat(api): serve the content protocol over a project's repository
tlgimenes Oct 3, 2026
2938627
test(e2e): content-protocol conformance against the GitHub backend
tlgimenes Oct 3, 2026
a47113d
feat(web): content backend seam for the content protocol
tlgimenes Oct 3, 2026
45929de
feat(web): connect the site editor to deco serve
tlgimenes Oct 3, 2026
09cf0da
feat(web): next-major content shapes and protocol degradations
tlgimenes Oct 3, 2026
47b5b5d
test(e2e): site editor over a deco serve connect link
tlgimenes Oct 3, 2026
8eef1d2
docs(studio): editing next-major Blocks sites
tlgimenes Oct 3, 2026
e84b5b1
fix(web): sections editor reads the protocol's decofile cache entry
tlgimenes Oct 3, 2026
1af66f7
fix(web): no /live/previews global-section renders on the protocol
tlgimenes Oct 3, 2026
3cecc60
refactor(api): one decofile commit message; draft-grant needs a previ…
tlgimenes Oct 3, 2026
216570a
test(e2e): a legacy site's secret loader blocks save over the protocol
tlgimenes Oct 3, 2026
88be2f5
fix(web): flat redirects keep the fields the editor doesn't know
tlgimenes Oct 3, 2026
57ed831
fix(shared): reserve the connect organization slug
tlgimenes Oct 3, 2026
5cbf6fa
fix(web): harden and simplify the content-protocol editor
tlgimenes Oct 3, 2026
a2a9f66
fix(shared): reserve the site-editor organization slug
tlgimenes Oct 3, 2026
a9bfdce
feat(web): account-less site editor at /site-editor over deco serve
tlgimenes Oct 3, 2026
f665b3c
feat(web): the Local draft option connects deco serve, for every org
tlgimenes Oct 3, 2026
96406c1
docs(studio): /site-editor and the Local option for v8 sites
tlgimenes Oct 3, 2026
6cf4881
fix(web): keep the deco serve token out of analytics URLs
tlgimenes Oct 3, 2026
0d7cc84
fix(web): the Local form asks to paste a new deco serve link
tlgimenes Oct 3, 2026
8b7499a
fix(web): no version badge while the GitHub probe has failed
tlgimenes Oct 3, 2026
10140cc
refactor(web): useVirtualMCP reuses useCollectionItem with a null client
tlgimenes Oct 3, 2026
3210de1
refactor(web): drop /connect; /site-editor is the only local entry point
tlgimenes Oct 3, 2026
85fb268
feat(web): /site-editor opens in the app layout; Preview loads deco s…
tlgimenes Oct 3, 2026
222bf7e
fix(web): page picker for local preview URLs; exact tab match on /sit…
tlgimenes Oct 3, 2026
fc45dfe
feat(web): /site-editor requires the New Layout preference
tlgimenes Oct 3, 2026
597f3c6
feat(web): /site-editor mounts the site editor app in the app takeover
tlgimenes Oct 3, 2026
bea750f
refactor: depend on published @decocms/[email protected] instead of…
tlgimenes Oct 3, 2026
de0ae60
feat(web): /site-editor is ungated, always in the New Layout, Site Ed…
tlgimenes Oct 3, 2026
e3a0414
fix(site-editor): work against a migrated v8 storefront over deco serve
tlgimenes Oct 3, 2026
8ef15fe
feat(site-editor): variant tabs preview v8 sites through the draft po…
tlgimenes Oct 4, 2026
198311d
feat(site-editor): no async rendering on v8 sites
tlgimenes Oct 4, 2026
304fac0
fix(site-editor): async rendering only on known-v7 sites; v8 can stil…
tlgimenes Oct 4, 2026
b7efa29
feat(site-editor): serve v8 draft previews as overlays of changed blocks
tlgimenes Oct 4, 2026
6640ba0
test(site-editor): end-to-end draft preview loop over the real SDK
tlgimenes Oct 4, 2026
fbff161
fix(site-editor): draft overlay previews fail visibly, expire on time…
tlgimenes Oct 4, 2026
5133034
chore: depend on @decocms/blocks@^8.1.0-next.3 (compiled dist); drop …
tlgimenes Oct 5, 2026
c9123e7
fix(site-editor): let legacy secret loader blocks through the secret …
tlgimenes Oct 5, 2026
61f9e4a
fix(site-editor): no async rendering UI on v8 sites
tlgimenes Oct 5, 2026
bdb7dd3
feat(site-editor): deco serve has no token
tlgimenes Oct 5, 2026
22e973a
fix(site-editor): drop the last deco serve token leftovers
tlgimenes Oct 5, 2026
a1c4fac
feat(site-editor): Settings entry for the CMS settings block on v8 sites
tlgimenes Oct 5, 2026
2bca4bc
fix(site-editor): Settings' first save is create-only (ifMatch CMS null)
tlgimenes Oct 5, 2026
719bd3d
feat(site-editor): guided first run and plain-language deco serve states
tlgimenes Oct 5, 2026
8a27baf
fix(site-editor): polish the first-run guide after a browser pass
tlgimenes Oct 5, 2026
bd31d61
fix(site-editor): one rule for /site-editor, no Disconnect
tlgimenes Oct 5, 2026
debabe5
feat(site-editor): v8 sites always get the new blocks editor
tlgimenes Oct 5, 2026
29c4801
feat(site-editor): plain deco serve command, no --allow-origin
tlgimenes Oct 5, 2026
f34fb21
chore(deps): bump @decocms/blocks to 8.1.0-next.4, drop the secret-gu…
tlgimenes Oct 5, 2026
9a4efd5
feat(site-editor): deco serve on localhost
tlgimenes Oct 5, 2026
0fe373d
feat(site-editor): edit every block before the schema exists
tlgimenes Oct 5, 2026
0028b40
fix(site-editor): link the Blocks docs at docs.decocms.com
tlgimenes Oct 6, 2026
0017fb9
feat(site-editor): v8 drafts on the Fast Preview pointer, changes only
tlgimenes Oct 6, 2026
2dd6fbb
chore(api): keep key-utils at the branch's merge base
tlgimenes Oct 6, 2026
4df8fdf
fix(site-editor): cheaper, sturdier draft changes
tlgimenes Oct 6, 2026
c3d66f3
fix(site-editor): keep v7 sites exactly as on main; v8 only by blocks…
tlgimenes Oct 6, 2026
12c4186
fix(site-editor): carry the v8 changes onto main's refactors after th…
tlgimenes Oct 6, 2026
49307ea
fix(api): keep main's key-utils (avif) after the rebase
tlgimenes Oct 6, 2026
47ead55
test(ct): stub the content backend so field specs mount as v7 sites
tlgimenes Oct 6, 2026
7bd82f5
test(e2e): read the draft pointer from a storefront origin
tlgimenes Oct 6, 2026
a01bb07
fix(api,web): enforce blocksMajor on /rpc; keep union array items res…
tlgimenes Oct 6, 2026
0384f58
fix(site-editor): v8 draft token the v7 way; drop removed protocol op…
tlgimenes Oct 6, 2026
c0c0ad4
fix(api): keep refs in the next.4 storage description so its conforma…
tlgimenes Oct 6, 2026
0db68bc
refactor(site-editor): name the v8 draft grant query key after its so…
tlgimenes Oct 6, 2026
b287375
fix(site-editor): v8 draft token from the v7 decofile read
tlgimenes Oct 7, 2026
b163a2d
chore(deps): pin @decocms/blocks 8.1.0-next.6; drop the next.4 storag…
tlgimenes Oct 7, 2026
4a1e921
test(e2e): a v7 site's schema.get may answer an absent schema (next.6)
tlgimenes Oct 7, 2026
a702910
Merge origin/main into feat/blocks-v8-support
tlgimenes Oct 7, 2026
55f6063
chore(deps): lock @decocms/blocks 8.1.0-next.6 on top of main's lockfile
tlgimenes Oct 7, 2026
926cef5
chore(deps): pin @decocms/blocks 8.1.0-next.7
tlgimenes Oct 7, 2026
d113d53
Merge origin/main into feat/blocks-v8-support
tlgimenes Oct 7, 2026
384d129
Merge origin/main into feat/blocks-v8-support
tlgimenes Oct 8, 2026
c9a0da8
Merge remote-tracking branch 'origin/feat/org-sites-project-link' int…
tlgimenes Oct 8, 2026
1a43917
Merge feat/org-sites-project-link into feat/blocks-v8-support
tlgimenes Oct 8, 2026
cb3f73d
Merge feat/org-sites-project-link into feat/blocks-v8-support
tlgimenes Oct 8, 2026
7d3613f
Merge feat/org-sites-project-link into feat/blocks-v8-support
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
6 changes: 5 additions & 1 deletion apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
20 changes: 19 additions & 1 deletion apps/api/scripts/smoke-tarball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.",
Expand Down
139 changes: 128 additions & 11 deletions apps/api/src/api/routes/decofile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@
* 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)
* 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.
*
* 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.
Expand Down Expand Up @@ -45,8 +57,16 @@ 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 { 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";
Expand All @@ -59,6 +79,7 @@ interface DecofileScope {
repository: RepositoryBinding;
/** Present only for session-authenticated (member) requests. */
userId: string | null;
previewServerUrl: string | null;
}

type DecofileEnv = Env & {
Expand Down Expand Up @@ -192,7 +213,7 @@ const resolveDecofileScope = createMiddleware<DecofileEnv>(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);
}

Expand All @@ -211,10 +232,46 @@ const resolveDecofileScope = createMiddleware<DecofileEnv>(async (c, next) => {
packagePath: runtime?.path?.replace(/^\/+|\/+$/g, "") || null,
repository,
userId,
previewServerUrl,
});
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<DecofileEnv>,
): Promise<boolean> {
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;
}
}

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
Expand Down Expand Up @@ -300,11 +357,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,
Expand Down Expand Up @@ -407,11 +460,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);
Expand Down Expand Up @@ -496,5 +545,73 @@ 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 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({
client,
packagePath: scope.packagePath,
branch: scope.branch,
coAuthor: coAuthorFromStudioContext(c.var.studioContext),
}),
{
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);
});

/**
* 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;
}
77 changes: 77 additions & 0 deletions apps/api/src/cli/deco-bin.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
}

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<string, string>).deco!,
);
expect(argv.slice(0, 3)).toEqual(["bun", "run", cliPath]);
expect(argv).toContain("--no-tui");
});
});
Loading
Loading