// Starter packs SP8 (#1873): saving an organization or one of its projects as a template. The saved // file carries no credential material and no member, keeps each role's credential request lines, // replaces the organization's own names with placeholders, and reads back as an origin-saved pack. import test from "node:test"; import assert from "node:assert/strict"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { businessDoc, rolesCopy } from "./helpers.mjs"; import { entryFromTemplate, readTemplate } from "../src/definitions.mjs"; import { DEFAULT_BOARD, TemplateError, templateList, templatePreview, templateSave, } from "../src/template-save.mjs"; import { checkPack, readSavedTemplate } from "../../plugins/src/starter-pack.mjs"; const TOKEN = "fixture-held-token-sp8-91c4e7"; // The credential store's check, as the bus passes it: a held value anywhere in the text refuses. const assertClean = (value) => { if (JSON.stringify(value).includes(TOKEN)) throw Object.assign(new Error("credential-leak"), { name: "BusError", code: "credential-leak" }); }; const STRIPPED = ["copyBindings", "copyId", "orgIncarnation", "generation", "holder", "tracker", "vars", "launch", "members", "rev", "hireDecision"]; function fixture(t) { const root = mkdtempSync(join(tmpdir(), "tsave-")); t.after(() => rmSync(root, { recursive: true, force: true })); const rolesDir = rolesCopy(root); const doc = businessDoc(root, "acme"); const coder = readTemplate(rolesDir, "coder"); const writer = entryFromTemplate(coder); writer.instructions = `Write code for acme in ${doc.projects.stack.root}/src. Ask jason; pair with ana on stack.`; writer.personality = { summary: "Careful acme engineer.", traits: ["works with ana", "plain"], voice: "short" }; const hired = entryFromTemplate(readTemplate(rolesDir, "reviewer")); hired.instructions = "Review what ana writes."; doc.definitions = { writer, "hire-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0": hired }; doc.roles.ana = { definition: "org:writer", credentials: { gitea: { file: join(root, "secrets", "ana.token"), rotateBy: "2099-01-01" } } }; doc.roles.bo = { definition: "org:hire-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0" }; doc.retired = ["cy"]; // Credential material the save must never copy. doc.copyBindings = { ana: { copyId: "copy-1", orgIncarnation: "inc-1", generation: 3, state: "bound", source: { level: "root", name: "ana", project: null } } }; doc.board = { columns: [ { state: "todo", label: "acme backlog" }, { state: "in-progress", label: "Doing" }, { state: "in-review", label: "Review" }, { state: "blocked", label: "Stuck" }, { state: "done", label: "Done" }, ], layout: "list" }; doc.workflows = { change: { title: "Change for stack", steps: [ { title: "ana writes it", state: "in-progress", roles: ["writer"] }, { title: "Review it", state: "in-review", roles: ["hire-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", "human"] }, ] } }; doc.packs = [ { id: "coding", version: "1.0.0", origin: "shipped", scope: "org", appliedAt: "2026-11-01T00:00:00Z", revision: "r1", added: ["board"], proposedHires: [] }, { id: "coding", version: "1.0.0", origin: "shipped", scope: "org", appliedAt: "2026-11-02T00:00:00Z", revision: "r2", added: [], proposedHires: [] }, { id: "intake", version: "2.1.0", origin: "installed", project: "stack", appliedAt: "2026-11-03T00:00:00Z", revision: "r3", added: [], proposedHires: [] }, ]; const configDir = join(root, "config"); mkdirSync(configDir, { mode: 0o700 }); return { root, rolesDir, doc, configDir }; } const keysDeep = (value, out = []) => { if (Array.isArray(value)) value.forEach((v) => keysDeep(v, out)); else if (value && typeof value === "object") for (const [k, v] of Object.entries(value)) { out.push(k); keysDeep(v, out); } return out; }; const refused = (fn, code, reason) => assert.throws(fn, (e) => { assert.ok(e instanceof TemplateError, `${e?.name}: ${e?.message}`); assert.equal(e.code, code); if (reason !== undefined) assert.equal(e.reason, reason); return true; }); test("SP8 red before: an instance's credentials and copyBindings never reach the saved file", (t) => { const { doc, rolesDir, configDir, root } = fixture(t); const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); templateSave({ document: doc, name: "acme-shape", digest: preview.digest, rolesDir, assertClean, configDir, account: "jason" }); const file = join(configDir, "templates", "jason", "acme-shape.json"); const text = readFileSync(file, "utf8"); const saved = JSON.parse(text); const keys = keysDeep(saved); for (const k of STRIPPED) assert.ok(!keys.includes(k), `the saved file holds ${k}`); assert.ok(!text.includes(join(root, "secrets")), "the saved file names a token file"); assert.ok(!text.includes("copy-1") && !text.includes("inc-1")); // credentials appears only as a definition's request lines: {service, scopes?}, never a file. for (const [name, entry] of Object.entries(saved.definitions)) { if (!entry.role) continue; assert.ok(Array.isArray(entry.role.credentials), name); for (const line of entry.role.credentials) assert.deepEqual(Object.keys(line).filter((k) => k !== "scopes"), ["service"]); } assert.equal(text, preview.json); }); test("SP8 the saved file keeps each role's credential request lines", (t) => { const { doc, rolesDir } = fixture(t); const { template } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); assert.deepEqual(template.definitions.writer.role.credentials, doc.definitions.writer.role.credentials); assert.ok(template.definitions.writer.role.credentials.length > 0); }); test("SP8 placeholders replace the org and project ids, the owner, agent names and project roots, in free text only", (t) => { const { doc, rolesDir } = fixture(t); const preview = templatePreview({ document: doc, name: "team-shape", rolesDir, assertClean }); const { template } = preview; assert.equal(template.definitions.writer.instructions, "Write code for {org} in {root}/src. Ask {owner}; pair with {agent} on {project}."); assert.equal(template.definitions.writer.personality.summary, "Careful {org} engineer."); assert.deepEqual(template.definitions.writer.personality.traits, ["works with {agent}", "plain"]); assert.equal(template.board.columns[0].label, "{org} backlog"); assert.equal(template.workflows.change.description, "Change for {project}"); assert.equal(template.workflows.change.steps[0].title, "{agent} writes it"); // Agent names that are role names (pm, coder) stay; role text says "coder" on purpose. const all = JSON.stringify(template); for (const literal of ["acme", "jason", "ana", doc.projects.stack.root]) assert.ok(!all.includes(literal), literal); assert.equal(template.description, "Saved from {org}."); // Each replacement is listed by path. const at = (path) => preview.replacements.filter((r) => r.path === path).map((r) => r.placeholder).sort(); assert.deepEqual(at("definitions.writer.instructions"), ["{agent}", "{org}", "{owner}", "{project}", "{root}"]); }); test("SP8 an instance named root, org or agent never rewrites a placeholder already written in", (t) => { const { doc, rolesDir } = fixture(t); doc.roles.root = { definition: "org:writer" }; doc.roles.org = { definition: "org:writer" }; doc.definitions.writer.instructions = "Work for acme with root on stack. Ask org, then jason."; const preview = templatePreview({ document: doc, name: "team-shape", rolesDir, assertClean }); assert.equal(preview.template.definitions.writer.instructions, "Work for {org} with {agent} on {project}. Ask {agent}, then {owner}."); const at = preview.replacements.filter((r) => r.path === "definitions.writer.instructions").map((r) => r.placeholder).sort(); assert.deepEqual(at, ["{agent}", "{org}", "{owner}", "{project}"]); }); test("SP8 the preview lists every kept free-text field with its saved text", (t) => { const { doc, rolesDir } = fixture(t); const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); const { template } = preview; const expected = []; for (const [name, entry] of Object.entries(template.definitions)) { if (typeof entry.instructions === "string") expected.push([`definitions.${name}.instructions`, entry.instructions]); if (entry.personality) { if (entry.personality.summary !== undefined) expected.push([`definitions.${name}.personality.summary`, entry.personality.summary]); if (entry.personality.voice !== undefined) expected.push([`definitions.${name}.personality.voice`, entry.personality.voice]); (entry.personality.traits ?? []).forEach((v, i) => expected.push([`definitions.${name}.personality.traits[${i}]`, v])); } } template.board.columns.forEach((c, i) => expected.push([`board.columns[${i}].label`, c.label])); for (const [w, wf] of Object.entries(template.workflows)) { expected.push([`workflows.${w}.description`, wf.description]); wf.steps.forEach((s, i) => expected.push([`workflows.${w}.steps[${i}].title`, s.title])); } expected.sort(([a], [b]) => (a < b ? -1 : 1)); assert.deepEqual(preview.freeText.map((f) => [f.path, f.text]), expected); }); test("SP8 a held credential in kept text refuses template-secret-suspected naming the field path, never the text", (t) => { const { doc, rolesDir } = fixture(t); doc.definitions.writer.personality.voice = `short, key ${TOKEN}`; refused(() => templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }), "template-secret-suspected", "definitions.writer.personality.voice"); try { templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); } catch (e) { assert.ok(!e.message.includes(TOKEN)); } // A held value a placeholder would split still refuses: the scan reads the text before replacement. const second = fixture(t); second.doc.workflows.change.steps[1].title = `acme${TOKEN}`; refused(() => templatePreview({ document: second.doc, name: "acme-shape", rolesDir: second.rolesDir, assertClean }), "template-secret-suspected", "workflows.change.steps[1].title"); // A workflow key that holds one is named by its parent and [key]. const third = fixture(t); third.doc.workflows[TOKEN.replace(/-/g, "")] = third.doc.workflows.change; const keyed = (v) => { if (JSON.stringify(v).includes(TOKEN.replace(/-/g, ""))) throw Object.assign(new Error("x"), { code: "credential-leak" }); }; refused(() => templatePreview({ document: third.doc, name: "acme-shape", rolesDir: third.rolesDir, assertClean: keyed }), "template-secret-suspected", "workflows[key]"); }); test("SP8 a second save to the same name refuses and leaves the first file", (t) => { const { doc, rolesDir, configDir } = fixture(t); const { digest } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }); const file = join(configDir, "templates", "jason", "acme-shape.json"); const first = readFileSync(file); doc.definitions.writer.instructions = "Changed."; const next = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); refused(() => templateSave({ document: doc, name: "acme-shape", digest: next.digest, rolesDir, assertClean, configDir, account: "jason" }), "invalid-request", "a template of this name is already saved"); assert.deepEqual(readFileSync(file), first); assert.equal(lstatSync(file).mode & 0o777, 0o600); assert.equal(lstatSync(join(configDir, "templates", "jason")).mode & 0o777, 0o700); }); test("SP8 a name that is no pack id refuses before any path is built", (t) => { const { doc, rolesDir, configDir } = fixture(t); for (const name of ["..", ".", "a/b", "../x", ".hidden", "", "A", "x".repeat(65), 7]) { refused(() => templatePreview({ document: doc, name, rolesDir, assertClean }), "invalid-request"); refused(() => templateSave({ document: doc, name, digest: "0".repeat(64), rolesDir, assertClean, configDir, account: "jason" }), "invalid-request"); } for (const account of ["..", "a/b", ""]) refused(() => templateList({ configDir, account, rolesDir }), "human-required"); assert.ok(!existsSync(join(configDir, "templates"))); }); test("SP8 a ninth hire refuses", (t) => { const { doc, rolesDir } = fixture(t); // pm, cto, coder, reviewer, ana and bo are six; three more make nine. doc.roles.dee = { definition: "coder" }; doc.roles.eve = { definition: "coder" }; assert.equal(templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }).template.hires.length, 8); doc.roles.fay = { definition: "reviewer" }; refused(() => templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }), "starter-pack-invalid", "a template holds at most 8 hires; this one would hold 9"); }); test("SP8 each packs entry holds only id and version, once, at the saved scope", (t) => { const { doc, rolesDir } = fixture(t); const org = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }).template; assert.deepEqual(org.packs, [{ id: "coding", version: "1.0.0" }]); const project = templatePreview({ document: doc, name: "stack-shape", project: "stack", rolesDir, assertClean }).template; assert.deepEqual(project.packs, [{ id: "intake", version: "2.1.0" }]); for (const entry of [...org.packs, ...project.packs]) assert.deepEqual(Object.keys(entry).sort(), ["id", "version"]); }); test("SP8 hires and names: no chief, no assistant, no decision id; hire- becomes