Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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");
});
});
128 changes: 128 additions & 0 deletions apps/api/src/decofile/legacy-secret-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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/[email protected]` lacks the exemption, so Studio carries it as
* `patches/@decocms%[email protected]` 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<Response>;

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<string, unknown>) {
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();
});
});
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading