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/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/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/bun.lock b/bun.lock index 4211163fea..f0a78f0795 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,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:*", @@ -704,6 +705,8 @@ "@decocms/bindings": ["@decocms/bindings@workspace:packages/bindings"], + "@decocms/blocks": ["@decocms/blocks@8.1.0-next.7", "", { "dependencies": { "ajv": "^8.20.0", "zod": "^4.4.3" }, "peerDependencies": { "react": "^19.0.0", "typescript": "^5.0.0" }, "bin": { "deco": "bin/deco.js" } }, "sha512-ULmBDjpBCjM/w0HikMtgZXHYA46WANE8wvqSbcvfaFGhV8CrpCAtAbSEB8ya/IfOYGd4sVhAFlz+CNarzTBZIA=="], + "@decocms/e2e": ["@decocms/e2e@workspace:packages/e2e"], "@decocms/harness-runner": ["@decocms/harness-runner@workspace:packages/harness-runner"], @@ -3660,6 +3663,8 @@ "@decocms/better-auth/better-call": ["better-call@1.1.5", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-nQJ3S87v6wApbDwbZ++FrQiSiVxWvZdjaO+2v6lZJAG2WWggkB2CziUDjPciz3eAt9TqfRursIQMZIcpkBnvlw=="], + "@decocms/blocks/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@decocms/native/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@decocms/sandbox-controller/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],