diff --git a/.changeset/record-modded-from.md b/.changeset/record-modded-from.md new file mode 100644 index 0000000..13adb87 --- /dev/null +++ b/.changeset/record-modded-from.md @@ -0,0 +1,5 @@ +--- +"playground-cli": minor +--- + +`dot mod` now records the source app's domain in `dot.json`, and `dot deploy --playground` publishes it as a `moddedFrom` field in the on-chain metadata. The playground-app can use this to display "Modded from: " attribution on app detail pages. The value is shape-validated through the same `normalizeDomain` rules as the deploying domain, so a hand-edited `dot.json` can't sneak XSS payloads into shared metadata. diff --git a/e2e/cli/mod.test.ts b/e2e/cli/mod.test.ts index 8a2f590..f35e24e 100644 --- a/e2e/cli/mod.test.ts +++ b/e2e/cli/mod.test.ts @@ -93,6 +93,7 @@ describe("dot mod — clone", () => { const dotJson = JSON.parse(readFileSync(dotJsonPath, "utf-8")) as { domain?: string; name?: string; + moddedFrom?: string; }; // `domain` is set to `targetDir` in writeDotJson() — and // `targetDir` flows from defaultRepoName(), which returns a @@ -102,6 +103,7 @@ describe("dot mod — clone", () => { // documented in src/commands/mod/SetupScreen.tsx.) expect(dotJson.domain).toBe(created[0]); expect(dotJson.name).toBeDefined(); + expect(dotJson.moddedFrom).toBe(TEST_DOMAIN); // ── Step 2 side effect: ignoreModLogs() ──────────────────────── const gitignore = readFileSync( diff --git a/src/commands/mod/SetupScreen.tsx b/src/commands/mod/SetupScreen.tsx index 33158cc..6a11e64 100644 --- a/src/commands/mod/SetupScreen.tsx +++ b/src/commands/mod/SetupScreen.tsx @@ -111,7 +111,7 @@ export function SetupScreen({ domain, metadata: initial, registry, targetDir, on await createOptionalGitBaseline(targetDir, log, sourceLogFile); stripPostinstall(targetDir); - writeDotJson(targetDir, meta.name ?? domain.replace(/\.dot$/, ""), meta); + writeDotJson(targetDir, meta.name ?? domain.replace(/\.dot$/, ""), meta, domain); ignoreModLogs(targetDir); }, }, @@ -202,7 +202,7 @@ function ignoreModLogs(dir: string) { } } -function writeDotJson(dir: string, name: string, meta: AppMetadata) { +function writeDotJson(dir: string, name: string, meta: AppMetadata, sourceDomain: string) { const dotJsonPath = resolve(dir, "dot.json"); let dotJson: Record = {}; if (existsSync(dotJsonPath)) { @@ -212,6 +212,7 @@ function writeDotJson(dir: string, name: string, meta: AppMetadata) { } dotJson.domain = dir; dotJson.name = name; + dotJson.moddedFrom = sourceDomain; if (!dotJson.description && meta.description) dotJson.description = meta.description; if (!dotJson.tag && meta.tag) dotJson.tag = meta.tag; writeFileSync(dotJsonPath, JSON.stringify(dotJson, null, 2) + "\n"); diff --git a/src/utils/deploy/playground.test.ts b/src/utils/deploy/playground.test.ts index 1386e04..15ba859 100644 --- a/src/utils/deploy/playground.test.ts +++ b/src/utils/deploy/playground.test.ts @@ -80,6 +80,7 @@ import { buildMetadata, normalizeDomain, readGitBranch, + readModdedFrom, readReadme, README_CAP_BYTES, } from "./playground.js"; @@ -264,18 +265,98 @@ describe("readGitBranch", () => { }); }); +describe("readModdedFrom", () => { + it("returns null when dot.json is missing", () => { + const dir = makeTmpDir(); + try { + expect(readModdedFrom(dir)).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns the moddedFrom value when set", () => { + const dir = makeTmpDir(); + try { + writeFileSync(join(dir, "dot.json"), JSON.stringify({ moddedFrom: "original.dot" })); + expect(readModdedFrom(dir)).toBe("original.dot"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns null when the field is absent, blank, or non-string", () => { + const dir = makeTmpDir(); + try { + writeFileSync(join(dir, "dot.json"), JSON.stringify({ name: "x" })); + expect(readModdedFrom(dir)).toBeNull(); + writeFileSync(join(dir, "dot.json"), JSON.stringify({ moddedFrom: " " })); + expect(readModdedFrom(dir)).toBeNull(); + writeFileSync(join(dir, "dot.json"), JSON.stringify({ moddedFrom: 42 })); + expect(readModdedFrom(dir)).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns null when dot.json is unparseable", () => { + const dir = makeTmpDir(); + try { + writeFileSync(join(dir, "dot.json"), "{ not json"); + expect(readModdedFrom(dir)).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects malformed domains so they can't reach published metadata", () => { + const dir = makeTmpDir(); + try { + const cases = [ + "", + ".dot", + "foo bar.dot", + "foo/bar.dot", + "../etc.dot", + ]; + for (const moddedFrom of cases) { + writeFileSync(join(dir, "dot.json"), JSON.stringify({ moddedFrom })); + expect(readModdedFrom(dir), `expected null for ${moddedFrom}`).toBeNull(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("canonicalizes to