Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/record-modded-from.md
Original file line number Diff line number Diff line change
@@ -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: <domain>" 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.
2 changes: 2 additions & 0 deletions e2e/cli/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions src/commands/mod/SetupScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
},
Expand Down Expand Up @@ -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<string, unknown> = {};
if (existsSync(dotJsonPath)) {
Expand All @@ -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");
Expand Down
112 changes: 110 additions & 2 deletions src/utils/deploy/playground.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
buildMetadata,
normalizeDomain,
readGitBranch,
readModdedFrom,
readReadme,
README_CAP_BYTES,
} from "./playground.js";
Expand Down Expand Up @@ -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 = [
"<script>alert(1)</script>",
"<img onerror=fetch(0)>.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 <label>.dot when the suffix is missing", () => {
const dir = makeTmpDir();
try {
writeFileSync(join(dir, "dot.json"), JSON.stringify({ moddedFrom: "original" }));
expect(readModdedFrom(dir)).toBe("original.dot");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

describe("buildMetadata", () => {
it("includes repository when repositoryUrl is non-null", () => {
const meta = buildMetadata({
repositoryUrl: "https://github.com/x/y",
branch: null,
readme: null,
moddedFrom: null,
});
expect(meta).toEqual({ repository: "https://github.com/x/y" });
});

it("omits repository entirely when repositoryUrl is null", () => {
const meta = buildMetadata({ repositoryUrl: null, branch: null, readme: null });
const meta = buildMetadata({
repositoryUrl: null,
branch: null,
readme: null,
moddedFrom: null,
});
expect(meta.repository).toBeUndefined();
});

Expand All @@ -284,12 +365,18 @@ describe("buildMetadata", () => {
repositoryUrl: "https://github.com/x/y",
branch: "develop",
readme: null,
moddedFrom: null,
});
expect(meta).toEqual({ repository: "https://github.com/x/y", branch: "develop" });
});

it("omits branch when repositoryUrl is null (branch alone is meaningless)", () => {
const meta = buildMetadata({ repositoryUrl: null, branch: "develop", readme: null });
const meta = buildMetadata({
repositoryUrl: null,
branch: "develop",
readme: null,
moddedFrom: null,
});
expect(meta.branch).toBeUndefined();
});

Expand All @@ -298,9 +385,30 @@ describe("buildMetadata", () => {
repositoryUrl: null,
branch: null,
readme: { kind: "ok", content: "hello", size: 5 },
moddedFrom: null,
});
expect(meta).toEqual({ readme: "hello" });
});

it("includes moddedFrom when present (independent of repositoryUrl)", () => {
const meta = buildMetadata({
repositoryUrl: null,
branch: null,
readme: null,
moddedFrom: "original.dot",
});
expect(meta).toEqual({ moddedFrom: "original.dot" });
});

it("omits moddedFrom when null or empty", () => {
const meta = buildMetadata({
repositoryUrl: "https://github.com/x/y",
branch: null,
readme: null,
moddedFrom: null,
});
expect(meta.moddedFrom).toBeUndefined();
});
});

describe("publishToPlayground", () => {
Expand Down
35 changes: 35 additions & 0 deletions src/utils/deploy/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export function buildMetadata(input: {
repositoryUrl: string | null;
branch: string | null;
readme: ReadmeStatus | null;
moddedFrom: string | null;
}): Record<string, string> {
const meta: Record<string, string> = {};
if (input.repositoryUrl) meta.repository = input.repositoryUrl;
Expand All @@ -177,9 +178,41 @@ export function buildMetadata(input: {
// bloat the JSON.
if (input.repositoryUrl && input.branch) meta.branch = input.branch;
if (input.readme && input.readme.kind === "ok") meta.readme = input.readme.content;
if (input.moddedFrom) meta.moddedFrom = input.moddedFrom;
return meta;
}

/**
* Returns the canonical `<label>.dot` form, or `null` for any unusable value
* (missing file, parse fail, non-string, or a value that doesn't pass
* `normalizeDomain`). `dot.json` is user-editable, so we shape-validate before
* publishing the field on-chain — the frontend still escapes on render, but
* we don't propagate garbage into shared metadata.
*/
export function readModdedFrom(cwd: string): string | null {
const path = join(cwd, "dot.json");
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const value = (parsed as Record<string, unknown>).moddedFrom;
if (typeof value !== "string") return null;
try {
return normalizeDomain(value).fullDomain;
} catch {
return null;
}
}

export async function publishToPlayground(
options: PublishToPlaygroundOptions,
): Promise<PublishToPlaygroundResult> {
Expand All @@ -192,10 +225,12 @@ export async function publishToPlayground(
// GitHub API call per `dot mod` invocation — see
// `src/commands/mod/SetupScreen.tsx`.
const branch = options.cwd && options.repositoryUrl ? readGitBranch(options.cwd) : null;
const moddedFrom = options.cwd ? readModdedFrom(options.cwd) : null;
const metadata = buildMetadata({
repositoryUrl: options.repositoryUrl,
branch,
readme,
moddedFrom,
});

const metadataBytes = new Uint8Array(Buffer.from(JSON.stringify(metadata), "utf8"));
Expand Down
Loading