- template-save: one alternation pass replaces every literal, so an
instance named root, org or agent never rewrites a placeholder already
written in. checkPack's refusal message is passed on as the reason. A
write or fsync failure removes the partial file it created.
- broker #templateFail drops a reason the credential check refuses.
- cli template words: humanTransport throws a CliError named by the bus
code; a known code now gains its hint (the test transport throws the
same CliError).
- template-http: the header notes serve.mjs strips /api/orgs/<id>.
- pins: the two .console verbs in console-decide and request-console;
the packages/business/src re-export for template-save.mjs.
- tests: placeholder collision, a held-value key masked as [key], and a
save after a real pack.apply.console keeping {id, version}.
298 lines
20 KiB
JavaScript
298 lines
20 KiB
JavaScript
// 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-<decision> becomes <template>-<n>", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
doc.roles.helper = { definition: "assistant" };
|
|
const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
const { template } = preview;
|
|
assert.ok(!preview.json.includes("0f1e2d3c"), "a decision id left the organization");
|
|
assert.ok(Object.hasOwn(template.definitions, "reviewer-1"));
|
|
assert.deepEqual(template.workflows.change.steps[1].roles, ["reviewer-1", "human"]);
|
|
assert.deepEqual(template.workflows.change.steps.map((s) => s.id), ["step-1", "step-2"]);
|
|
const hires = Object.fromEntries(template.hires.map((h) => [h.name, h.definition]));
|
|
assert.deepEqual(hires, { "coder-1": "writer", "coder-2": "coder", "cto-1": "cto", "pm-1": "pm", "reviewer-1": "reviewer-1", "reviewer-2": "reviewer" });
|
|
for (const h of template.hires) assert.deepEqual(Object.keys(h).sort(), ["definition", "name", "reason"]);
|
|
assert.ok(!Object.values(hires).some((d) => /chief|assistant/.test(d)));
|
|
assert.deepEqual(template.definitions.coder, { template: "coder", sha256: readTemplate(rolesDir, "coder").sha256 });
|
|
assert.equal(template.board.layout, "list");
|
|
});
|
|
|
|
test("SP8 the saved file reads back as an origin-saved pack, and list shows it", (t) => {
|
|
const { doc, rolesDir, configDir } = fixture(t);
|
|
assert.deepEqual(templateList({ configDir, account: "jason", rolesDir }), { templates: [] });
|
|
const { digest } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" });
|
|
const { pack } = readSavedTemplate({ configDir, account: "jason", name: "acme-shape", rolesDir, uid: process.getuid() });
|
|
assert.equal(pack.id, "acme-shape");
|
|
writeFileSync(join(configDir, "templates", "jason", "broken.json"), "{", { mode: 0o600 });
|
|
const listed = templateList({ configDir, account: "jason", rolesDir });
|
|
assert.deepEqual(listed.templates.map((x) => x.name), ["acme-shape", "broken"]);
|
|
assert.equal(listed.templates[0].hires, 6);
|
|
assert.equal(listed.templates[1].readable, false);
|
|
// Another account's list is its own.
|
|
assert.deepEqual(templateList({ configDir, account: "alex", rolesDir }), { templates: [] });
|
|
});
|
|
|
|
test("SP8 a project template carries the project's agents, the definitions it names and the default board", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
doc.projects.stack.agents = { ana: { definition: "org:writer" }, rev: { definition: "reviewer" }, chief: { exclude: true } };
|
|
const { template, scope } = templatePreview({ document: doc, name: "stack-shape", project: "stack", rolesDir, assertClean });
|
|
assert.equal(scope, "project");
|
|
assert.deepEqual(Object.keys(template.definitions).sort(), ["reviewer", "writer"]);
|
|
assert.deepEqual(template.board, JSON.parse(JSON.stringify(DEFAULT_BOARD)));
|
|
assert.deepEqual(template.workflows, {});
|
|
assert.equal(template.description, "Saved from {project} in {org}.");
|
|
refused(() => templatePreview({ document: doc, name: "x", project: "nope", rolesDir, assertClean }), "invalid-request");
|
|
});
|
|
|
|
test("SP8 a from the template moved under is re-pinned when the role still narrows it, and refused when it widens", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
doc.definitions.writer.from.sha256 = "1".repeat(64);
|
|
const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
assert.deepEqual(preview.repinned, ["definitions.writer.from"]);
|
|
assert.equal(preview.template.definitions.writer.from.sha256, readTemplate(rolesDir, "coder").sha256);
|
|
doc.definitions.writer.role.tools = [...doc.definitions.writer.role.tools, "not-a-coder-tool"];
|
|
assert.throws(() => templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }), (e) => e instanceof TemplateError && /^starter-pack-invalid$/.test(e.code));
|
|
});
|
|
|
|
test("SP8 save takes the preview's digest; a changed organization refuses pack-plan-stale and writes nothing", (t) => {
|
|
const { doc, rolesDir, configDir } = fixture(t);
|
|
const { digest } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
doc.board.columns[1].label = "Now";
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }), "pack-plan-stale");
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest: "zz", rolesDir, assertClean, configDir, account: "jason" }), "invalid-request");
|
|
assert.ok(!existsSync(join(configDir, "templates", "jason", "acme-shape.json")));
|
|
});
|
|
|
|
test("SP8 save refuses a templates directory another user can write, or an account directory that is a link", (t) => {
|
|
const { doc, rolesDir, configDir, root } = fixture(t);
|
|
const { digest } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
mkdirSync(join(configDir, "templates"), { mode: 0o700 });
|
|
chmodSync(join(configDir, "templates"), 0o777);
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }), "not-configured");
|
|
chmodSync(join(configDir, "templates"), 0o700);
|
|
const elsewhere = join(root, "elsewhere");
|
|
mkdirSync(elsewhere, { mode: 0o700 });
|
|
symlinkSync(elsewhere, join(configDir, "templates", "jason"));
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }), "not-configured");
|
|
refused(() => templateList({ configDir, account: "jason", rolesDir }), "not-configured");
|
|
assert.ok(!existsSync(join(elsewhere, "acme-shape.json")));
|
|
// A file planted as a link at the name is not followed.
|
|
rmSync(join(configDir, "templates", "jason"));
|
|
mkdirSync(join(configDir, "templates", "jason"), { mode: 0o700 });
|
|
symlinkSync(join(elsewhere, "target.json"), join(configDir, "templates", "jason", "acme-shape.json"));
|
|
assert.throws(() => templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }), TemplateError);
|
|
assert.ok(!existsSync(join(elsewhere, "target.json")));
|
|
});
|
|
|
|
test("SP8 the saved template is a pack checkPack accepts as saved", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
const { template } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
assert.deepEqual(checkPack(structuredClone(template), { origin: "saved", rolesDir }), template);
|
|
});
|