feat(packs): the owner saves an organization as a template (SP8, #1873) #1959

Merged
jason.woltje merged 3 commits from sp8 into refactor 2026-11-13 02:44:22 +00:00
17 changed files with 1455 additions and 11 deletions

No files matched your search

+43 -1
View File
@@ -184,7 +184,9 @@ export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.rem
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// #1873 SP4: the owner's starter packs, board and workflows.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console']);
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console',
// #1873 SP8: and saving a template, with the list of the owner's own.
'template.save.console', 'template.list.console']);
// What an owner's console cap may ask of the request lifecycle (a read, the intake controls, and Task 4's mode and draft).
const INTAKE_CONSOLE = Object.freeze(['artifactConsole', 'get', 'answer', 'attach', 'cancel', 'mode', 'draft']);
const REQUEST_VERBS = Object.freeze({ 'request.open': 'open', 'request.get': 'get', 'request.projects': 'projects', 'request.propose': 'propose',
@@ -221,6 +223,17 @@ const PACK_KEYS = Object.freeze({
'workflow.set': [['revision', 'name', 'workflow', 'project'], ['revision', 'name', 'workflow']],
});
export const PACK_BUS_KEYS = Object.freeze(Object.fromEntries(Object.entries(PACK_KEYS).flatMap(([verb, k]) => [[verb, k], [`${verb}.console`, k]])));
// #1873 SP8: saving a template and listing the caller's own, the owner's alone. The CLI calls the
// unsuffixed name as the owner's human cap, the Console the .console one; no agent, member or tester
// reaches either. template.save previews with preview: true and writes with the preview's digest.
const TEMPLATE_KEYS = Object.freeze({
'template.save': [['name', 'project', 'preview', 'digest'], ['name']],
'template.list': [[], []],
});
export const TEMPLATE_BUS_KEYS = Object.freeze(Object.fromEntries(Object.entries(TEMPLATE_KEYS).flatMap(([verb, k]) => [[verb, k], [`${verb}.console`, k]])));
// The codes a template refusal keeps, each with its reason: a field path or a checked name, never a
// value from the organization file and never a host path.
const TEMPLATE_CODES = Object.freeze(['template-secret-suspected', 'invalid-request', 'human-required', 'not-configured', 'pack-plan-stale', 'starter-pack-invalid', 'starter-pack-template-changed']);
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -6348,6 +6361,23 @@ export class Broker {
};
return result?.then ? result.then(done, packFail) : done(result);
}
// #1873 SP8: saving a template. Only the organization's owner: the human cap from the CLI or the
// owner's console cap. An agent, a member's cap and the tester are refused before any argument
// check. The account the template is saved under is the caller's own s.human.
if (Object.hasOwn(TEMPLATE_BUS_KEYS, request.verb)) {
if (s.tester) fail('tester-refused');
const owner = this.#business(s.business).human;
if (request.verb.endsWith('.console')) { if (!s.console || s.human !== owner) fail('human-required'); }
else { this.#human(s); if (s.human !== owner) fail('human-required'); }
keys(args, ...TEMPLATE_BUS_KEYS[request.verb]);
if (!this.#organization) fail('not-configured');
const verb = request.verb.replace(/\.console$/, '');
let result;
try { result = this.#organization(s.business, verb, structuredClone(args), s.human, {}); }
catch (e) { this.#templateFail(e); }
const done = (value) => { this.#secretCheck(value); return value; };
return result?.then ? result.then(done, (e) => this.#templateFail(e)) : done(result);
}
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
@@ -7013,6 +7043,18 @@ export class Broker {
if (e?.name === 'BusinessError') fail('invalid-request');
throw new BusError('outcome-unknown');
}
// #1873 SP8: a template refusal keeps its code and its reason (a path, never the text); anything
// else is read as a pack refusal.
#templateFail(e) {
if (e?.name === 'TemplateError' && TEMPLATE_CODES.includes(e.code)) {
// A reason names a field path or a definition name; one that would quote a held value is dropped.
// The check reads the whole reason before the cut, so a held value cut short is still found.
let reason = typeof e.reason === 'string' ? e.reason : undefined;
if (reason !== undefined) try { this.#secretCheck(reason); reason = reason.slice(0, 300); } catch { reason = undefined; }
throw new BusError(e.code, reason);
}
this.#packFail(e);
}
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
+7
View File
@@ -218,6 +218,13 @@ group(
"R8: a hire raised from a pack proposal matches its recorded pin",
"The role entry stored with this proposal no longer matches the hash it was recorded with. Preview and apply the pack again for a fresh proposal, then raise the hire from that one."
);
// #1873 SP8: a saved template carries no credential the bus holds. Save checks every kept string and
// key against the credential store; a hit names the field, never the text.
group(
"template-secret-suspected",
"R1/R10: a saved template carries no credential value the bus holds",
"A field the template would keep holds a credential value. Remove it from the named definition, board or workflow text, then preview and save again."
);
group(
"not-resolver",
"Role resolve right and decision route",
+13
View File
@@ -42,6 +42,7 @@ import { organizationWriter, OrganizationError, setReplyLimits } from '../../org
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { ORG_PREFIX, orgDefinitionName, compositionOf, readTemplate, entryFromTemplate, lineDiff, PRIMITIVES } from '../../organization/src/definitions.mjs';
import { installedStarterPacks, readSavedTemplate } from '../../plugins/src/starter-pack.mjs';
import { TemplateError, templateList, templatePreview, templateSave } from '../../organization/src/template-save.mjs';
import { checkComposition } from '../../organization/src/composition.mjs';
import { validateBusinessDocument } from '../../organization/src/organization-config.mjs';
import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './definition-scan.mjs';
@@ -1187,6 +1188,18 @@ export async function startBroker({
const committed = value => { reload(id); limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message})); return value; };
return out?.then ? out.then(committed) : committed(out);
}
// #1873 SP8: saving a template and listing the owner's own. The broker passes only the owner's
// human; both read the organization file and neither writes it, so nothing reloads.
if (verb === 'template.save' || verb === 'template.list') {
const base = { rolesDir: organization.rolesDir, configDir: organization.dir, account: actor };
if (verb === 'template.list') return templateList(base);
const { name, project, preview, digest } = args;
const document = organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}).read(id).document;
const assertClean = value => credentials.assertClean(value);
if (preview === true && digest === undefined) return templatePreview({ document, name, project, rolesDir: organization.rolesDir, assertClean });
if (preview === undefined && typeof digest === 'string') return templateSave({ ...base, document, name, project, digest, assertClean });
throw new TemplateError('invalid-request', 'template.save takes preview: true or the preview digest');
}
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
+3 -1
View File
@@ -92,7 +92,9 @@ test('N4: owner Console verbs have an explicit immutable whitelist and exclude d
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console']);
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console',
// Starter packs SP8 (#1873): the owner saves and lists templates.
'template.save.console', 'template.list.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
assert.ok(Object.isFrozen(CONSOLE_VERBS));
+3 -1
View File
@@ -93,7 +93,9 @@ test('the console verb list includes the artifact read and existing owner contro
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console']);
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console',
// Starter packs SP8 (#1873): the owner saves and lists templates.
'template.save.console', 'template.list.console']);
});
test('only the signed-in owner capability reaches the console verbs and it reaches nothing else', async t => {
+178
View File
@@ -0,0 +1,178 @@
// #1873 SP8: saving a template at the bus. Only the organization's owner saves or lists: the CLI's
// human cap on the unsuffixed names, the owner's console cap on the .console ones. The saved file
// applies through SP4 as saved:<name> for its saver.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CONSOLE_VERBS, TEMPLATE_BUS_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { VERB_RIGHTS } from '../../organization/src/org-rights.mjs';
const TOKEN = 'fixture-held-token-sp8-bus-3a9d';
const SUBJECT = { provider: 'test', iss: 'test', sub: 'owner' };
async function harness(t, { mutate = () => {} } = {}) {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const { entryFromTemplate, readTemplate } = await import('../../organization/src/definitions.mjs');
const root = mkdtempSync(join(tmpdir(), 'tmpl-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi'] };
const writer = entryFromTemplate(readTemplate(rolesDir, 'coder'));
writer.instructions = 'Write code for demo. Ask jason.';
doc.definitions = { writer };
// An instance of the org definition, with its own token files and tracker bot.
doc.roles.ana = { ...structuredClone(doc.roles.coder), definition: 'org:writer', tracker: { bot: 'bot-demo-ana', botId: 11 } };
mutate(doc, root);
writeJson(join(root, 'businesses/demo.json'), doc);
const data = join(root, 'data');
mkdirSync(data, { mode: 0o700 });
const token = join(root, 'held.token');
writeFileSync(token, TOKEN, { mode: 0o600 });
chmodSync(token, 0o600);
const roles = { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } };
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles } };
const bus = await startBroker({ dataRoot: data, businesses, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const consoleOwner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = async (verb, args = {}, cap = consoleOwner) => b.request(cap, { verb, args });
const path = join(root, 'businesses/demo.json');
const saved = (name, account = 'jason') => join(root, 'templates', account, `${name}.json`);
return { bus, b, root, rolesDir, path, consoleOwner, human, call, saved };
}
// Preview, then save with the preview's digest: the console names by default, the cli names for cap.
const save = async (call, name, extra = {}, cap) => {
const verb = cap ? 'template.save' : 'template.save.console';
const preview = await call(verb, { name, preview: true, ...extra }, cap);
return { preview, out: await call(verb, { name, digest: preview.digest, ...extra }, cap) };
};
test('SP8 red before: the owner saves an organization whose instance holds credentials; neither credentials nor copy material reach the file', async t => {
const { call, saved, root } = await harness(t);
const { preview, out } = await save(call, 'demo-shape');
assert.deepEqual(out, { name: 'demo-shape', scope: 'org', digest: preview.digest });
const text = readFileSync(saved('demo-shape'), 'utf8');
assert.equal(text, preview.json);
for (const k of ['copyBindings', 'copyId', 'orgIncarnation', 'generation', 'tracker', 'holder']) assert.ok(!text.includes(`"${k}"`), k);
assert.ok(!text.includes(join(root, 'secrets')), 'a token file path reached the template');
assert.ok(!text.includes('"file"'));
// The role's credential request lines are kept.
assert.ok(JSON.parse(text).definitions.writer.role.credentials.length > 0);
});
test('SP8 a saved template applies through SP4 as saved:<name> for its saver', async t => {
const { call, path } = await harness(t);
await save(call, 'demo-shape');
const listed = await call('template.list.console');
assert.deepEqual(listed.templates.map(x => [x.name, x.scope]), [['demo-shape', 'org']]);
const preview = await call('pack.preview.console', { pack: 'saved:demo-shape' });
assert.equal(preview.pack.origin, 'saved');
assert.ok(preview.hires.some(h => h.name === 'coder-1'), JSON.stringify(preview.hires.map(h => h.name)));
const before = JSON.parse(readFileSync(path, 'utf8'));
const out = await call('pack.apply.console', { pack: 'saved:demo-shape', digest: preview.digest });
const after = JSON.parse(readFileSync(path, 'utf8'));
assert.equal(after.packs.length, (before.packs ?? []).length + 1);
assert.equal(after.packs.at(-1).origin, 'saved');
assert.equal(after.packs.at(-1).id, 'demo-shape');
// writer is already the organization's own (same); the template references the coder template.
assert.ok(out.added.includes('definitions.coder'), JSON.stringify(out.added));
assert.equal(preview.hires.find(h => h.name === 'coder-1').definition, 'writer');
});
test('SP8 the owner\'s CLI cap saves on the unsuffixed name and writes the file', async t => {
const { call, human, saved } = await harness(t);
const { out } = await save(call, 'cli-shape', {}, human);
assert.equal(out.name, 'cli-shape');
assert.ok(existsSync(saved('cli-shape')));
assert.deepEqual((await call('template.list', {}, human)).templates.map(x => x.name), ['cli-shape']);
});
test('SP8 who may call: a member\'s session, an agent and the tester refuse; the owner\'s console cap cannot call the cli name', async t => {
const { b, call, human, root } = await harness(t);
assert.deepEqual(TESTER_ORGANIZATION_VERBS, ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
const member = b.bindHireConsole({ business: 'demo', actor: 'alice', subject: { provider: 'test', iss: 'test', sub: 'alice' }, rights: ['org.hire', 'board.read', 'org.setup'] });
for (const verb of ['template.save', 'template.save.console']) assert.throws(() => b.request(member, { verb, args: { name: 'x', preview: true } }), /human-required/);
for (const verb of ['template.list', 'template.list.console']) assert.throws(() => b.request(member, { verb, args: {} }), /human-required/);
const tester = b.bindTester({ business: 'demo' });
assert.throws(() => b.request(tester, { verb: 'template.save', args: { name: 'x', preview: true } }), /tester-refused/);
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
b.request(agent, { verb: 'role.claim' });
for (const verb of ['template.save', 'template.save.console']) assert.throws(() => b.request(agent, { verb, args: { name: 'x', preview: true } }), /human-required/);
await assert.rejects(call('template.save', { name: 'x', preview: true }), /human-required/);
await assert.rejects(call('template.save.console', { name: 'x', preview: true }, human), /human-required/);
assert.ok(!existsSync(join(root, 'templates')));
});
test('SP8 the real credential store: a held value in kept text refuses template-secret-suspected with the field path as its reason', async t => {
// A definition holding one never loads (definition-holds-credential); workflow text is what save
// must catch itself.
const workflow = { description: `Ship it, key ${TOKEN}`, steps: [{ title: 'Write', state: 'todo', roles: ['writer'] }] };
const { call, root } = await harness(t, { mutate: doc => { doc.workflows = { change: workflow }; } });
await assert.rejects(call('template.save.console', { name: 'leaky', preview: true }), (e) => {
assert.equal(e.code, 'template-secret-suspected');
assert.equal(e.reason, 'workflows.change.description');
assert.ok(!String(e.message).includes(TOKEN) && !JSON.stringify(e).includes(TOKEN));
return true;
});
assert.ok(!existsSync(join(root, 'templates')));
});
test('SP8 a key that holds a held value: the refusal reason masks it as [key], never the value', async t => {
// A definition keyed by one never loads (definition-holds-credential); a workflow keyed by one is
// what save sees. The broker also drops any reason its credential check refuses.
const workflow = { description: 'Ship it.', steps: [{ title: 'Write', state: 'todo', roles: ['writer'] }] };
const { call, root } = await harness(t, { mutate: doc => { doc.workflows = { [TOKEN]: workflow }; } });
await assert.rejects(call('template.save.console', { name: 'leaky', preview: true }), (e) => {
assert.equal(e.code, 'template-secret-suspected');
assert.equal(e.reason, 'workflows[key]');
assert.ok(!String(e.message).includes(TOKEN) && !JSON.stringify(e).includes(TOKEN));
return true;
});
assert.ok(!existsSync(join(root, 'templates')));
});
test('SP8 a save after a real pack.apply.console keeps each packs entry as {id, version}', async t => {
const { call, saved } = await harness(t);
const packs = await call('packs.console');
const shipped = packs.packs.find(p => p.origin === 'shipped');
assert.ok(shipped, JSON.stringify(packs.packs.map(p => [p.id, p.origin])));
const pack = await call('pack.preview.console', { pack: shipped.id });
await call('pack.apply.console', { pack: shipped.id, digest: pack.digest });
await save(call, 'after-apply');
const kept = JSON.parse(readFileSync(saved('after-apply'), 'utf8')).packs;
assert.deepEqual(kept, [{ id: shipped.id, version: pack.pack.version }]);
});
test('SP8 argument checks: a second save, a .. name, an unknown key, preview with a digest, and a stale digest', async t => {
const { call, saved } = await harness(t);
const { preview } = await save(call, 'demo-shape');
const first = readFileSync(saved('demo-shape'));
await assert.rejects(call('template.save.console', { name: 'demo-shape', digest: preview.digest }), (e) => e.code === 'invalid-request' && e.reason === 'a template of this name is already saved');
assert.deepEqual(readFileSync(saved('demo-shape')), first);
for (const name of ['..', '../x', 'a/b']) await assert.rejects(call('template.save.console', { name, preview: true }), { code: 'invalid-request' });
for (const args of [{ name: 'x', preview: true, account: 'alex' }, { name: 'x', preview: true, digest: preview.digest }, { name: 'x' }, { preview: true }, { name: 'x', preview: false }])
await assert.rejects(call('template.save.console', args), { code: 'invalid-request' });
await assert.rejects(call('template.list.console', { account: 'alex' }), { code: 'invalid-request' });
await assert.rejects(call('template.save.console', { name: 'other', digest: preview.digest }), { code: 'pack-plan-stale' });
});
test('SP8 the schema pins: both .console verbs are console verbs, owner-only in VERB_RIGHTS, and template-secret-suspected has a rail', () => {
for (const verb of ['template.save.console', 'template.list.console']) {
assert.ok(CONSOLE_VERBS.includes(verb), verb);
assert.ok(Object.hasOwn(VERB_RIGHTS, verb) && VERB_RIGHTS[verb] === null, verb);
assert.ok(Object.hasOwn(TEMPLATE_BUS_KEYS, verb));
}
assert.ok(!CONSOLE_VERBS.includes('template.save') && !CONSOLE_VERBS.includes('template.list'));
const c = 'template-secret-suspected';
assert.ok(Object.hasOwn(REFUSALS, c));
assert.ok(REFUSALS[c].rail?.length > 5);
assert.ok(REFUSALS[c].nextStep?.length > 20);
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep));
});
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/template-save.mjs (rename design r2, stage B).
export * from "../../organization/src/template-save.mjs";
+47 -1
View File
@@ -17,7 +17,9 @@ import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs';
// Starter packs SP4 (design section 8): the owner's pack, board and workflow words.
export const PACKS_USAGE = 'mosaic org pack list | pack preview ID PACK [--project P] [--as KIND.NAME=NEW]... | pack apply ID PACK --digest D [--project P] [--as KIND.NAME=NEW]... | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
// #1873 SP8: saving the organization, or one project, as a template; the owner's alone.
export const TEMPLATE_USAGE = 'mosaic org template save ID NAME [--project P] (--preview | --digest D) | template list ID';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | ' + TEMPLATE_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
@@ -114,6 +116,49 @@ async function packsCommand(args, io, deps) {
}
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
// #1873 SP8: template save previews (--preview) or writes the previewed JSON (--digest); template
// list prints the owner's saved templates. Both call the bus as the owner's human; an agent run is
// refused before the bus is called.
const TEMPLATE_REFUSALS = {
'template-secret-suspected': 'a field the template would keep holds a credential the organization\'s bus holds; remove it and preview again (nothing was written)',
'pack-plan-stale': 'the organization or a template changed since the preview; preview again and save its digest',
'invalid-request': 'the bus refused the request: a bad name, a name already saved, an unknown project, or an organization the template cannot hold',
'human-required': 'only the organization\'s owner, from a human shell, may do this',
'not-configured': 'the templates directory is missing or not private to this instance\'s user',
'starter-pack-invalid': 'the template would not be a valid pack: more than 8 hires, or a definition wider than its template',
};
async function templateCommand(args, io, deps) {
const pos = [], values = {}, flags = new Set();
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (['--project', '--digest'].includes(a)) {
if (!args[i + 1] || args[i + 1].startsWith('--') || values[a] !== undefined) throw new CliError(`${a} needs one value`, 4);
values[a] = args[++i];
} else if (a === '--preview') { if (flags.has(a)) throw new CliError('--preview is given twice', 4); flags.add(a); }
else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
else pos.push(a);
}
const [, sub, id, name] = pos;
refuseAgentRun(io);
let verb, request;
if (sub === 'save' && pos.length === 4 && flags.has('--preview') !== (values['--digest'] !== undefined)) {
verb = 'template.save';
request = { name, ...(values['--project'] === undefined ? {} : { project: values['--project'] }), ...(flags.has('--preview') ? { preview: true } : { digest: values['--digest'] }) };
} else if (sub === 'list' && pos.length === 3 && !flags.size && !Object.keys(values).length) { verb = 'template.list'; request = {}; }
else throw new CliError(TEMPLATE_USAGE, 4);
let result;
try { result = await packCall(id, io, deps)(verb, request); }
catch (e) {
// humanTransport throws a CliError named by the bus code alone: a known code gains its hint.
const why = typeof e?.code !== 'string' ? undefined
: Object.hasOwn(TEMPLATE_REFUSALS, e.code) ? TEMPLATE_REFUSALS[e.code] : Object.hasOwn(PACK_REFUSALS, e.code) ? PACK_REFUSALS[e.code] : undefined;
if (!why) throw e;
const error = new CliError(`${e.code}: ${why}`, busExit(e.code));
error.code = e.code;
throw error;
}
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
const busStartVerb = id => `mosaic bus start ${id} --dev-local-owner --detach`;
export async function orgCommand(args, io, deps = {}) {
try {
@@ -122,6 +167,7 @@ export async function orgCommand(args, io, deps = {}) {
// E2 (#1873, P1-3): the hire form and the autonomy panel, through the running Console (org-owner.mjs).
if (ORG_OWNER_VERBS.includes(args[0])) return await orgOwnerCommand(args, io, deps);
if (['pack', 'board', 'workflow'].includes(args[0])) return await packsCommand(args, io, deps);
if (args[0] === 'template' && ['save', 'list'].includes(args[1])) return await templateCommand(args, io, deps);
const p = parse(args); const words = p.pos; let result; let accepted = [];
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
+76
View File
@@ -0,0 +1,76 @@
// Starter packs SP8: `mosaic org template save|list`. Each word goes through the organization's bus as
// its human; save --preview writes nothing, save --digest writes the previewed JSON under the owner's
// account, and template render keeps its own words.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, lstatSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { main } from '../src/cli.mjs';
import { ORG_USAGE, TEMPLATE_USAGE } from '../src/org.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { CliError } from '../src/errors.mjs';
import { busExit } from '../src/transport.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { fixture, io, tmp } from './helpers.mjs';
async function setup(t) {
const root = tmp(t);
const f = fixture(root, 'acme');
const file = join(root, 'config/businesses/acme.json');
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => b.close());
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
const calls = [];
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
calls.push([verb, Object.keys(args).sort()]);
try { return structuredClone(await b.broker.request(human, { verb, args })); }
// What humanTransport throws for a bus refusal: a CliError named by the code alone.
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new CliError(e.code, busExit(e.code)), { code: e.code }); }
} };
const run = async (args, env = f.env) => { const x = io(env); await main(['org', ...args], x, deps); return JSON.parse(x.out.text); };
const saved = (name) => join(boot.organization.dir, 'templates', f.doc.human, `${name}.json`);
return { root, f, file, calls, run, saved };
}
test('SP8 mosaic org template save: --preview writes nothing, --digest writes the previewed JSON, a second save refuses, list shows it', async (t) => {
const s = await setup(t);
assert.ok(ORG_USAGE.includes(TEMPLATE_USAGE.slice('mosaic org '.length)));
const before = readFileSync(s.file);
const preview = await s.run(['template', 'save', 'acme', 'acme-shape', '--preview']);
assert.match(preview.digest, /^[0-9a-f]{64}$/);
assert.ok(Array.isArray(preview.freeText) && Array.isArray(preview.replacements));
assert.ok(!existsSync(s.saved('acme-shape')), 'preview writes no file');
const out = await s.run(['template', 'save', 'acme', 'acme-shape', '--digest', preview.digest]);
assert.deepEqual(out, { name: 'acme-shape', scope: 'org', digest: preview.digest });
assert.equal(readFileSync(s.saved('acme-shape'), 'utf8'), preview.json);
assert.equal(lstatSync(s.saved('acme-shape')).mode & 0o777, 0o600);
assert.deepEqual(readFileSync(s.file), before, 'save writes nothing to the organization file');
await assert.rejects(s.run(['template', 'save', 'acme', 'acme-shape', '--digest', preview.digest]), (e) => e.code === 'invalid-request' && /already saved/.test(e.message));
await assert.rejects(s.run(['template', 'save', 'acme', 'other', '--digest', preview.digest]), (e) => e.code === 'pack-plan-stale' && /preview again/.test(e.message));
const listed = await s.run(['template', 'list', 'acme']);
assert.deepEqual(listed.templates.map((x) => x.name), ['acme-shape']);
assert.deepEqual(s.calls.map(([verb, keys]) => [verb, keys.join(',')]), [
['template.save', 'name,preview'], ['template.save', 'digest,name'], ['template.save', 'digest,name'], ['template.save', 'digest,name'], ['template.list', ''],
]);
});
test('SP8 template words: a bad shape prints the usage; an agent shell refuses before the bus is called; template render keeps its words', async (t) => {
const s = await setup(t);
for (const args of [['template', 'save', 'acme', 'x'], ['template', 'save', 'acme', 'x', '--preview', '--digest', 'a'.repeat(64)], ['template', 'save', 'acme'], ['template', 'list'], ['template', 'list', 'acme', '--preview']]) {
await assert.rejects(s.run(args), (e) => e.exitCode === 4 && e.message === TEMPLATE_USAGE, args.join(' '));
}
await assert.rejects(s.run(['template', 'save', 'acme', 'x', '--force']), (e) => e.exitCode === 4 && /unknown option --force/.test(e.message));
await assert.rejects(s.run(['template', 'save', 'acme', '..', '--preview']), (e) => e.code === 'invalid-request');
const agent = { ...s.f.env, MOSAIC_RUN_ID: 'run-1' };
const calls = s.calls.length;
for (const args of [['template', 'save', 'acme', 'x', '--preview'], ['template', 'save', 'acme', 'x', '--digest', 'a'.repeat(64)], ['template', 'list', 'acme']]) {
await assert.rejects(s.run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message), args.join(' '));
}
assert.equal(s.calls.length, calls);
// template render is still routed to its own words (it needs --output).
await assert.rejects(s.run(['template', 'render', 'acme']), (e) => e.exitCode === 4 && /--output/.test(e.message));
});
+4
View File
@@ -97,6 +97,10 @@ export const VERB_RIGHTS = record({
'pack.apply.console': null,
'board.set.console': null,
'workflow.set.console': null,
// #1873 SP8: saving a template, and listing the saved ones, are the owner's alone: a template is a
// copy of the organization's roles, and the files live under the owner's account.
'template.save.console': null,
'template.list.console': null,
});
function refuse(code) {
+484
View File
@@ -0,0 +1,484 @@
// Starter packs SP8 (#1873): saving an organization, or one of its projects, as a template. Design:
// starter-packs-design section 5 and SP8 in section 8.
//
// A saved template is a pack document (packVersion 1) written to
// <configDir>/templates/<account>/<name>.json and read later with origin `saved`
// (plugins/src/starter-pack.mjs readSavedTemplate). Save copies what section 5 captures and nothing
// else: definitions (role, instructions, personality, from), one hire proposal per agent, the board,
// the workflows, the union of the captured roles' skills and {id, version} for each pack applied
// before. Everything else stays in the organization: members, identities, credential material
// (instance credentials, copyBindings, copyId, orgIncarnation, generation), launch, plugins, vars,
// project roots and git state, definition revs and hire decisions, and everything on the bus.
//
// Free text is kept, with the organization id, its project ids, its agent names, the owner's
// account and the project roots replaced by {org}, {project}, {agent}, {owner} and {root}. The
// preview lists each replacement and every kept free-text field by path, and the exact JSON save
// writes; save takes that JSON's digest. Every string and key is passed to the credential store's
// assertClean; a hit refuses template-secret-suspected naming the path, never the text.
//
// A refusal is a TemplateError: a bus code and a reason that names a path or a checked name only,
// never a value from the document and never a host path.
import { closeSync, constants, fchmodSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, unlinkSync, writeSync } from "node:fs";
import { join } from "node:path";
import { ORG_PREFIX, checkDefinition, readTemplate } from "./definitions.mjs";
import { sha256 } from "./util.mjs";
import { ID_PATTERN } from "./vocabulary.mjs";
import { NAME } from "../../plugins/src/kinds.mjs";
import {
PACK_BOUNDS, PACK_VERSION, SAVED_MAX_BYTES, checkPack, narrowsTemplate, readSavedTemplate,
} from "../../plugins/src/starter-pack.mjs";
// A template name is a pack id (NAME), at most 64 characters, the length of every other pack name.
export const TEMPLATE_NAME_MAX = 64;
// list reads at most this many files; a longer directory refuses rather than truncating silently.
export const TEMPLATE_LIST_MAX = 256;
const CHIEF = "chief-of-staff";
const ASSISTANT = "assistant";
const HIRE_DEFINITION = /^hire-/;
// The board a template carries when the organization (or project) has none: the five task states in
// store order, with plain labels, as a kanban. A pack must carry a board (SP1 checkPack).
export const DEFAULT_BOARD = Object.freeze({
columns: Object.freeze([
Object.freeze({ state: "todo", label: "To do" }),
Object.freeze({ state: "in-progress", label: "In progress" }),
Object.freeze({ state: "in-review", label: "In review" }),
Object.freeze({ state: "blocked", label: "Blocked" }),
Object.freeze({ state: "done", label: "Done" }),
]),
layout: "kanban",
});
const HIRE_REASON = "The organization this template was saved from ran this agent.";
// A word boundary for placeholder replacement: an identifier character on either side means the
// literal is part of a longer name, which stays as it is.
const WORD = "A-Za-z0-9_-";
export class TemplateError extends Error {
constructor(code, reason) {
super(reason ? `${code}: ${reason}` : code);
this.name = "TemplateError";
this.code = code;
this.reason = reason;
}
}
const refuse = (code, reason) => {
throw new TemplateError(code, reason);
};
const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// The account (the caller's s.human) and the name are checked before any path is built from them.
export function checkAccount(account) {
if (typeof account !== "string" || !ID_PATTERN.test(account)) refuse("human-required", "the saving account is not a valid account name");
return account;
}
export function checkTemplateName(name) {
if (typeof name !== "string" || name.length > TEMPLATE_NAME_MAX || !NAME.test(name)) {
refuse("invalid-request", `a template name must match ${NAME}, at most ${TEMPLATE_NAME_MAX} characters`);
}
return name;
}
// One template, read now, or null when the name is no usable version 2 template.
function templateOrNull(rolesDir, name, cache) {
if (!cache.has(name)) {
let value = null;
if (typeof name === "string" && ID_PATTERN.test(name)) {
try {
value = readTemplate(rolesDir, name);
} catch (error) {
if (error?.name !== "BusinessError") throw error;
}
}
cache.set(name, value);
}
return cache.get(name);
}
// ---- Placeholders ----
// The literals section 5 replaces, longest first so a root path is replaced before the ids inside
// it. A name that is also a template or a definition name is a role's name, not a person's or a
// client's, and stays: replacing "coder" would rewrite every role text that says coder.
function literals(document, { rolesDir, cache }) {
const out = [];
const add = (text, placeholder) => {
if (typeof text === "string" && text.length > 0 && !out.some((l) => l.text === text)) out.push({ text, placeholder });
};
for (const project of Object.values(document.projects ?? {})) {
add(project?.root, "{root}");
add(project?.repo, "{root}");
}
add(document.id, "{org}");
for (const id of Object.keys(document.projects ?? {})) add(id, "{project}");
add(document.human, "{owner}");
const role = (name) => Object.hasOwn(document.definitions ?? {}, name) || templateOrNull(rolesDir, name, cache) !== null;
const agents = new Set([...Object.keys(document.roles ?? {}), ...(Array.isArray(document.retired) ? document.retired : [])]);
for (const project of Object.values(document.projects ?? {})) for (const name of Object.keys(project?.agents ?? {})) agents.add(name);
for (const name of agents) if (!role(name)) add(name, "{agent}");
out.sort((a, b) => b.text.length - a.text.length);
return out;
}
// Returns the text with every literal replaced, and counts each placeholder used. One pass over the
// text with one alternation, longest literal first: a placeholder written in never matches a later
// literal, so an instance named "root" or "agent" cannot rewrite {root} or {agent}.
const combined = new WeakMap();
function replace(text, list, counts) {
if (list.length === 0) return text;
let entry = combined.get(list);
if (!entry) {
const by = new Map(list.map((l) => [l.text, l.placeholder]));
entry = { by, pattern: new RegExp(`(?<![${WORD}])(?:${list.map((l) => escape(l.text)).join("|")})(?![${WORD}])`, "g") };
combined.set(list, entry);
}
return text.replace(entry.pattern, (match) => {
const placeholder = entry.by.get(match);
counts[placeholder] = (counts[placeholder] ?? 0) + 1;
return placeholder;
});
}
// ---- Capture ----
// Builds the template document. With list null, free text is kept as it is (the scan's second
// copy); otherwise each replacement and each kept free-text field is recorded by path.
function capture(document, { name, project, rolesDir, list, record }) {
const cache = new Map();
const text = (value, path) => {
if (list === null) return value;
const counts = {};
const out = replace(value, list, counts);
for (const [placeholder, count] of Object.entries(counts)) record.replacements.push({ path, placeholder, count });
record.freeText.push({ path, text: out });
return out;
};
const orgDefinitions = plain(document.definitions) ? document.definitions : {};
const scope = project === undefined ? "org" : "project";
let source;
if (scope === "project") {
if (!plain(document.projects) || !Object.hasOwn(document.projects, project)) refuse("invalid-request", "the project is not declared in this organization");
source = document.projects[project];
if (source?.kind === "library") refuse("invalid-request", "a library project has no agents, board or workflows to save");
} else {
source = document;
}
// The agents: one hire per org instance, or per project agent that defines one. Never the chief
// or the assistant, which every organization already has.
const agentEntries = scope === "org"
? Object.entries(plain(document.roles) ? document.roles : {})
: Object.entries(plain(source.agents) ? source.agents : {}).filter(([, e]) => typeof e?.definition === "string");
agentEntries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const agents = [];
for (const [, entry] of agentEntries) {
const definition = entry?.definition;
if (typeof definition !== "string") continue;
const org = definition.startsWith(ORG_PREFIX) ? definition.slice(ORG_PREFIX.length) : null;
const base = org === null ? definition : orgDefinitions[org]?.from?.template;
if (base === CHIEF || base === ASSISTANT) continue;
if (org !== null && !Object.hasOwn(orgDefinitions, org)) refuse("invalid-request", "an agent names an organization definition the file does not hold");
agents.push({ org, template: org === null ? definition : null });
}
// Which org definitions are copied: every one for an org template; for a project template the
// ones its agents and workflows name.
const sourceWorkflows = plain(source.workflows) ? source.workflows : {};
let copied;
if (scope === "org") {
copied = Object.keys(orgDefinitions);
} else {
const named = new Set(agents.filter((a) => a.org !== null).map((a) => a.org));
for (const workflow of Object.values(sourceWorkflows)) {
for (const step of Array.isArray(workflow?.steps) ? workflow.steps : []) {
for (const role of Array.isArray(step?.roles) ? step.roles : []) if (Object.hasOwn(orgDefinitions, role)) named.add(role);
}
}
copied = Object.keys(orgDefinitions).filter((n) => named.has(n));
}
// Names. A definition named hire-<decision> becomes <from>-<n>, so no decision id leaves the
// organization; every other copied definition keeps its name.
const taken = new Set(copied.filter((n) => !HIRE_DEFINITION.test(n)));
const free = (base) => {
if (!taken.has(base)) { taken.add(base); return base; }
for (let n = 1; ; n++) if (!taken.has(`${base}-${n}`)) { taken.add(`${base}-${n}`); return `${base}-${n}`; }
};
const numbered = (base) => {
for (let n = 1; ; n++) if (!taken.has(`${base}-${n}`)) { taken.add(`${base}-${n}`); return `${base}-${n}`; }
};
const renamed = new Map();
for (const n of copied) {
if (!HIRE_DEFINITION.test(n)) { renamed.set(n, n); continue; }
renamed.set(n, numbered(orgDefinitions[n]?.from?.template ?? "role"));
}
// Definitions: role, instructions, personality and from. No rev.
const definitions = {};
const skills = [];
const addSkills = (list) => { for (const s of Array.isArray(list) ? list : []) if (!skills.includes(s)) skills.push(s); };
for (const n of copied) {
const entry = orgDefinitions[n];
const key = renamed.get(n);
const at = `definitions.${key}`;
if (!plain(entry) || !plain(entry.role)) refuse("invalid-request", `${at} is not a definition entry`);
const out = {};
if (entry.from !== undefined) {
const template = templateOrNull(rolesDir, entry.from?.template, cache);
if (!template) refuse("starter-pack-invalid", `${at}.from names a template that is not a version 2 role`);
// A template that changed since the definition copied it: the saved from names its current
// hash, and the role must still narrow the current template. The preview lists the re-pin.
if (template.sha256 !== entry.from.sha256 && list !== null) record.repinned.push(`${at}.from`);
out.from = { template: template.name, sha256: template.sha256 };
let composition;
try {
({ composition } = checkDefinition(key, { ...entry, rev: 1, from: out.from }, { where: "definitions" }));
} catch (error) {
if (error?.name === "BusinessError") refuse("starter-pack-invalid", `${at} fails the composition check`);
throw error;
}
if (!narrowsTemplate(composition, template.role)) refuse("starter-pack-invalid", `${at}.role is wider than template ${template.name}`);
}
out.role = structuredClone(entry.role);
if (typeof entry.instructions === "string") out.instructions = text(entry.instructions, `${at}.instructions`);
if (entry.personality !== undefined) {
const p = structuredClone(entry.personality);
if (plain(p)) {
if (typeof p.summary === "string") p.summary = text(p.summary, `${at}.personality.summary`);
if (typeof p.voice === "string") p.voice = text(p.voice, `${at}.personality.voice`);
if (Array.isArray(p.traits)) p.traits = p.traits.map((t, i) => (typeof t === "string" ? text(t, `${at}.personality.traits[${i}]`) : t));
}
out.personality = p;
}
definitions[key] = out;
addSkills(entry.role.skills);
}
// Hires: <from>-<n> and the definition. A template agent hires a template reference; an org
// definition agent hires the copied definition.
const refs = new Map();
const hireNames = new Set();
const hires = [];
for (const agent of agents) {
let definition;
let base;
if (agent.template !== null) {
const template = templateOrNull(rolesDir, agent.template, cache);
if (!template) refuse("starter-pack-invalid", `an agent uses template ${agent.template}, which is not a version 2 role`);
if (!refs.has(agent.template)) {
const key = free(agent.template);
definitions[key] = { template: template.name, sha256: template.sha256 };
refs.set(agent.template, key);
addSkills(template.role.skills);
}
definition = refs.get(agent.template);
base = agent.template;
} else {
definition = renamed.get(agent.org);
base = orgDefinitions[agent.org]?.from?.template ?? definition;
}
let hireName;
for (let n = 1; ; n++) if (!hireNames.has(`${base}-${n}`)) { hireName = `${base}-${n}`; break; }
hireNames.add(hireName);
hires.push({ name: hireName, definition, reason: HIRE_REASON });
}
if (hires.length > PACK_BOUNDS.hires) refuse("starter-pack-invalid", `a template holds at most ${PACK_BOUNDS.hires} hires; this one would hold ${hires.length}`);
// The board, or the default when there is none.
const sourceBoard = plain(source.board) ? source.board : DEFAULT_BOARD;
const board = {
columns: sourceBoard.columns.map((c, i) => ({ state: c.state, label: text(c.label, `board.columns[${i}].label`) })),
layout: sourceBoard.layout,
};
// Workflows. A pack step carries an id; the organization's steps have none, so save numbers them.
// A pack workflow has no title: a workflow without a description takes its title as one.
const workflows = {};
for (const [wname, workflow] of Object.entries(sourceWorkflows)) {
const at = `workflows.${wname}`;
const description = typeof workflow?.description === "string" ? text(workflow.description, `${at}.description`)
: typeof workflow?.title === "string" ? text(workflow.title, `${at}.description`)
: `The ${wname} workflow.`;
const steps = (Array.isArray(workflow?.steps) ? workflow.steps : []).map((step, i) => ({
id: `step-${i + 1}`,
title: text(step.title, `${at}.steps[${i}].title`),
roles: (Array.isArray(step.roles) ? step.roles : []).map((r) => (renamed.has(r) ? renamed.get(r) : r)),
state: step.state,
}));
workflows[wname] = { description, steps };
}
// packs: {id, version} for each pack applied before at this scope, once each, the latest 32.
const seen = new Set();
const packs = [];
for (const entry of Array.isArray(document.packs) ? document.packs : []) {
if (scope === "org" ? entry?.scope !== "org" : entry?.project !== project) continue;
const k = `${entry.id}\n${entry.version}`;
if (seen.has(k)) continue;
seen.add(k);
packs.push({ id: entry.id, version: entry.version });
}
if (list !== null) record.packsDropped = Math.max(0, packs.length - PACK_BOUNDS.packs);
const keptPacks = packs.slice(Math.max(0, packs.length - PACK_BOUNDS.packs));
const label = name.slice(0, PACK_BOUNDS.label);
const descriptionText = scope === "org" ? "Saved from {org}." : "Saved from {project} in {org}.";
return {
packVersion: PACK_VERSION, id: name, version: "1.0.0", label, description: descriptionText, scope,
definitions, hires, board, workflows, skills, packs: keptPacks,
};
}
// Every string and every key, by path, through the credential store's check. A hit refuses
// template-secret-suspected and names the path; the text never leaves.
function scan(value, path, assertClean) {
const check = (v, where) => {
try {
assertClean(v);
} catch (error) {
if (error?.code === "credential-leak") refuse("template-secret-suspected", where);
throw error;
}
};
if (typeof value === "string") return check(value, path);
if (Array.isArray(value)) return value.forEach((v, i) => scan(v, `${path}[${i}]`, assertClean));
if (value !== null && typeof value === "object") {
for (const [k, v] of Object.entries(value)) {
const where = path ? `${path}.${k}` : k;
check(k, `${path || "template"}[key]`);
scan(v, where, assertClean);
}
}
}
// The preview: the exact JSON save writes, its digest, each replacement and each kept free-text
// field by path. Refuses before returning when any string or key holds a held credential value.
export function templatePreview({ document, name, project, rolesDir, assertClean }) {
checkTemplateName(name);
if (project !== undefined && (typeof project !== "string" || !ID_PATTERN.test(project))) refuse("invalid-request", "the project id is invalid");
if (typeof assertClean !== "function") throw new TypeError("templatePreview needs the credential store's assertClean");
if (!plain(document)) refuse("invalid-request", "the organization file is not readable");
const scope = { name, project, rolesDir };
// The scan reads both copies: the kept text before replacement (a held value a placeholder would
// break up still refuses) and the document save writes.
const raw = capture(document, { ...scope, list: null, record: null });
scan(raw, "", assertClean);
const record = { replacements: [], freeText: [], repinned: [], packsDropped: 0 };
const template = capture(document, { ...scope, list: literals(document, { rolesDir, cache: new Map() }), record });
scan(template, "", assertClean);
let checked;
try {
checked = checkPack(template, { origin: "saved", rolesDir });
} catch (error) {
if (error?.name === "PluginError") {
refuse(/^starter-pack-[a-z-]+$/.test(error.code ?? "") ? error.code : "starter-pack-invalid", typeof error.message === "string" ? error.message.slice(0, 300) : undefined);
}
throw error;
}
const json = `${JSON.stringify(checked, null, 2)}\n`;
if (Buffer.byteLength(json) > SAVED_MAX_BYTES) refuse("starter-pack-invalid", `a saved template is at most ${SAVED_MAX_BYTES} bytes`);
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
return {
name, scope: checked.scope, ...(project === undefined ? {} : { project }),
template: checked, json, digest: sha256(json),
replacements: record.replacements.sort(byPath), freeText: record.freeText.sort(byPath),
repinned: record.repinned.sort(), packsDropped: record.packsDropped,
};
}
// ---- The file ----
// A directory save writes under: real, the uid's own, writable by nobody else. The account's own
// directory is private (0700 or narrower), as readSavedTemplate requires.
function directory(path, uid, { private: closed }) {
try {
mkdirSync(path, { mode: 0o700 });
} catch (error) {
if (error?.code !== "EEXIST") refuse("not-configured", `the ${closed ? "account" : "templates"} directory cannot be created`);
}
let st;
try {
st = lstatSync(path);
} catch {
refuse("not-configured", `the ${closed ? "account" : "templates"} directory is unreadable`);
}
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== uid || (st.mode & 0o022) !== 0 || (closed && (st.mode & 0o077) !== 0)) {
refuse("not-configured", `the ${closed ? "account" : "templates"} directory is not private to this instance's user`);
}
}
// Save: the preview again, its digest compared, then the file created exclusively (wx, 0600). A
// name already saved refuses; nothing is replaced.
export function templateSave({ document, name, project, digest, rolesDir, assertClean, configDir, account, uid = process.getuid() }) {
checkAccount(account);
checkTemplateName(name);
if (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest)) refuse("invalid-request", "save takes the preview's digest");
const preview = templatePreview({ document, name, project, rolesDir, assertClean });
if (preview.digest !== digest) refuse("pack-plan-stale", "the organization or a template changed since the preview");
const templates = join(configDir, "templates");
const home = join(templates, account);
directory(templates, uid, { private: false });
directory(home, uid, { private: true });
const file = join(home, `${name}.json`);
let fd;
try {
fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
} catch (error) {
if (error?.code === "EEXIST") refuse("invalid-request", "a template of this name is already saved");
refuse("not-configured", "the template file cannot be created");
}
try {
fchmodSync(fd, 0o600);
const bytes = Buffer.from(preview.json);
let offset = 0;
while (offset < bytes.length) offset += writeSync(fd, bytes, offset);
fsyncSync(fd);
} catch (error) {
// A partial file would hold the name: remove the file this call created, then report the failure.
const open = fd;
fd = undefined;
try { closeSync(open); } catch { /* the write failure is the one reported */ }
try { unlinkSync(file); } catch { /* already gone */ }
throw error;
} finally {
if (fd !== undefined) closeSync(fd);
}
return { name, scope: preview.scope, ...(project === undefined ? {} : { project }), digest };
}
// List: the caller's own saved templates, each read through readSavedTemplate. A file that does not
// read is listed as unreadable, by name only. No account directory yet is an empty list.
export function templateList({ configDir, account, rolesDir, uid = process.getuid() }) {
checkAccount(account);
const home = join(configDir, "templates", account);
let names;
try {
const st = lstatSync(home);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== uid || (st.mode & 0o077) !== 0) {
refuse("not-configured", "the account directory is not private to this instance's user");
}
names = readdirSync(home);
} catch (error) {
if (error instanceof TemplateError) throw error;
if (error?.code === "ENOENT") return { templates: [] };
refuse("not-configured", "the account directory is unreadable");
}
const files = names.filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length))
.filter((n) => n.length <= TEMPLATE_NAME_MAX && NAME.test(n)).sort();
if (files.length > TEMPLATE_LIST_MAX) refuse("invalid-request", `the account holds more than ${TEMPLATE_LIST_MAX} saved templates`);
const templates = files.map((n) => {
try {
const { pack } = readSavedTemplate({ configDir, account, name: n, rolesDir, uid });
return { name: n, version: pack.version, label: pack.label, description: pack.description, scope: pack.scope, hires: pack.hires.length };
} catch (error) {
if (error?.name === "PluginError") return { name: n, readable: false, code: error.code };
throw error;
}
});
return { templates };
}
@@ -89,6 +89,9 @@ const VERBS = {
'pack.apply.console': null,
'board.set.console': null,
'workflow.set.console': null,
// Starter packs SP8 (#1873): saving a template and listing the saved ones are the owner's alone.
'template.save.console': null,
'template.list.console': null,
};
test('M1/R1 the eleven rights pin the grantable flags, including compulsory org.view', () => {
@@ -0,0 +1,297 @@
// 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);
});
+4
View File
@@ -8,6 +8,7 @@ import { organizationConsole } from './organizations.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
import { packsCaller } from './packs-http.mjs';
import { templatesCaller } from './template-http.mjs';
import { autonomyCaller } from './autonomy-http.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
@@ -201,6 +202,9 @@ try {
// Starter packs SP4 (#1873): the owner's pack apply, board and workflow writes, the three write
// .console verbs only. Preview and the pack list are reads over requestRead.
packsWrite: session.user === undefined && sendOn ? packsCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
// Starter packs SP8 (#1873): the owner's template save, template.save.console with a digest only. The list
// and the preview are reads over requestRead.
templatesWrite: session.user === undefined && sendOn ? templatesCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
// Starter packs SP6 (#1873): the owner's workflow read, the organization file and its revision as the
// writer reads them (the read `mosaic org workflow set` takes its revision from), and the V2 template names.
workflowsRead: session.user === undefined ? () => ({ ...organizationWriter({ dir: configDir(), rolesDir: rolesDir() }).read(id), templates: templateHashes(rolesDir()).map(t => t.template) }) : null,
+16 -7
View File
@@ -23,6 +23,7 @@ import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { SYSTEM_TYPES_ROUTE, systemTypesHTTP } from './system-types-http.mjs';
import { PACKS_ROUTE, packsHTTP, packsServes } from './packs-http.mjs';
import { TEMPLATES_ROUTE, templatesHTTP } from './template-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { consoleRestartHTTP } from './console-restart-http.mjs';
import { workspaceHTTP } from './workspace-read.mjs';
@@ -535,7 +536,9 @@ const ORG_OWNER_WRITE = path => AUTONOMY_ROUTE.test(path) || path === '/api/sett
// Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks.
|| SYSTEM_TYPES_ROUTE.test(path)
// Starter packs SP4 (#1873): pack apply, board and workflow writes; the route keeps its own checks.
|| PACKS_ROUTE.test(path);
|| PACKS_ROUTE.test(path)
// Starter packs SP8 (#1873): saving a template; the route keeps its own checks.
|| TEMPLATES_ROUTE.test(path);
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
let target = null;
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
@@ -872,7 +875,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
let boundAddress = null, boundPort = null;
const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null, packsWrite = null, workflowsRead = null;
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null;
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
@@ -944,7 +947,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// No CORS. JSON content type and same-origin checks keep browser forms out.
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path),
// GET and HEAD /api/board stay the Board read; every other method on it is the pack route's (POST writes, the rest 405).
packsPath = packsServes(path, req.method);
packsPath = packsServes(path, req.method), templatesPath = TEMPLATES_ROUTE.test(path);
if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`))
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
let signer = preview, ssoSession = null;
@@ -956,7 +959,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!session && path !== '/healthz') {
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
if (requestPath) return requestRefuse(res, 'sign-in-required');
if (remotePath || systemTypesPath || autonomyPath || packsPath) return json(res, 401, { error: 'sign-in-required' });
if (remotePath || systemTypesPath || autonomyPath || packsPath || templatesPath) return json(res, 401, { error: 'sign-in-required' });
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
@@ -969,7 +972,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const instanceAction = originalPath === '/api/console/restart';
if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' });
if (session && !session.rootOwner && session.user === undefined) {
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = packsWrite = workflowsRead = upstream = null;
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = packsWrite = workflowsRead = templatesWrite = upstream = null;
}
if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
// #1873: proxy setup answers a grant holder the way the bus answers an agent.
@@ -977,7 +980,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null; packsWrite = null; workflowsRead = null;
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null; packsWrite = null; workflowsRead = null; templatesWrite = null;
chatGrants = sso.chatGrants?.(session) ?? [];
}
}
@@ -1008,7 +1011,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null,
projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null,
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, packsWrite = null, workflowsRead = null, upstream = null } = selected);
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null, upstream = null } = selected);
}
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
// agents, assistant, instances, model proxy). The routes below keep their own checks.
@@ -1090,6 +1093,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return packsHTTP(req, res, { path, owner, read: requestRead, write: send ? packsWrite : null, workflows: workflowsRead,
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin });
}
// Starter packs SP8 (#1873): the owner saves the selected organization's shape as a template and lists theirs.
if (templatesPath) {
const owner = !(tester || granted || !named || sso === null);
return templatesHTTP(req, res, { path, owner, read: requestRead, write: send ? templatesWrite : null,
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin });
}
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css', '/packs.js', '/packs.css', '/workflows.js'].includes(path)) return json(res, 403, { error: 'owner-only' });
// Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
+102
View File
@@ -0,0 +1,102 @@
// Starter packs SP8 (#1873, design section 5): the owner saves the selected organization's shape as a
// template under their own account, and lists the templates they saved. Owner Console transports only;
// no caller-supplied business, account, subject or origin reaches the bus.
//
// serve.mjs strips /api/orgs/<id> before the route is matched, so the paths below reach this module as
// /api/templates and /api/templates/preview.
//
// GET /api/orgs/<id>/templates {templates, writable}: template.list.console, the owner's saved templates
// POST /api/orgs/<id>/templates/preview {name, project?}: template.save.console with preview true. Writes nothing;
// answers the template JSON, every free-text field and the digest
// POST /api/orgs/<id>/templates {name, digest, project?}: template.save.console with the preview's digest
//
// `read` is the owner's Console call (consoleCall), used for the list and the preview, which write nothing.
// `write` is the owner's Console call fenced to template.save.console (templatesCaller), present only for
// the owner's own session under --send. The broker answers both .console verbs to the business human's
// console cap alone and runs the capture, the held-credential scan and the digest check itself.
export const TEMPLATES_ROUTE = /^\/api\/templates(?:\/(preview))?$/;
export const TEMPLATE_WRITE_VERBS = Object.freeze(['template.save.console']);
export const templatesCaller = call => async (verb, args, options) => {
if (!TEMPLATE_WRITE_VERBS.includes(verb)) throw new Error(`the template writer does not call ${verb}`);
if (args?.preview !== undefined) throw new Error('the template writer does not preview');
return call(verb, args, options);
};
// A template or project name: the bus's own rule (template-save.mjs checkTemplateName, vocabulary KIND_NAME).
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const DIGEST = /^[a-f0-9]{64}$/;
const LIMIT = 4096;
const codes = {
'invalid-request': 400, 'template-secret-suspected': 400, 'starter-pack-invalid': 400,
'project-not-found': 404,
'pack-plan-stale': 409, 'starter-pack-template-changed': 409, 'organization-busy': 409,
'human-required': 403, 'read-only': 403,
'not-configured': 503, 'no-bus-host': 503,
};
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
function json(res, status, value) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(value));
}
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
// The body names exactly the required keys and any of the optional ones; nothing else reaches the bus.
async function body(req, required, optional) {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > LIMIT) return 'too-large'; chunks.push(chunk); }
let value;
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
if (!plain(value) || !required.every(k => Object.hasOwn(value, k)) || Object.keys(value).some(k => !required.includes(k) && !optional.includes(k))) return null;
return value;
}
export async function templatesHTTP(req, res, { path, owner, read, write, session, live, origin }) {
const refuse = (status, error) => json(res, status, { error });
const fail = error => { const code = codes[error?.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); };
const match = TEMPLATES_ROUTE.exec(path);
if (!match) return refuse(404, 'not-found');
const preview = match[1] === 'preview';
const list = !preview && req.method === 'GET';
const allowed = preview ? ['POST'] : ['GET', 'POST'];
if (!allowed.includes(req.method)) return refuse(405, 'method-not-allowed');
// A method the route never serves answers 405 first; every served method is the root owner's.
if (!owner) return refuse(403, 'owner-only');
if (!session) return refuse(401, 'sign-in-required');
if (!read) return refuse(503, 'not-configured');
const writes = !list && !preview;
if (writes && !write) return refuse(403, 'read-only');
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
if (req.method !== 'GET' ? from !== origin || site !== 'same-origin'
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
return refuse(403, 'cross-origin');
let verb, args = {};
if (list) verb = 'template.list.console';
else {
const value = await body(req, preview ? ['name'] : ['name', 'digest'], ['project']);
if (value === 'too-large') return refuse(413, 'invalid-request');
if (!value || typeof value.name !== 'string' || !NAME.test(value.name)
|| (!preview && (typeof value.digest !== 'string' || !DIGEST.test(value.digest)))
|| (value.project !== undefined && (typeof value.project !== 'string' || !NAME.test(value.project))))
return refuse(400, 'invalid-request');
verb = 'template.save.console';
args = preview ? { ...value, preview: true } : value;
}
// The session is checked again after the body is read: a sign-out meanwhile dispatches nothing.
if (live() !== session) return refuse(401, 'sign-in-required');
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
let result;
try { result = writes ? await write(verb, args, { subject }) : await read(verb, args, { subject }); }
catch (error) { return fail(error); }
if (!plain(result)) return refuse(503, 'outcome-unknown');
if (list) {
if (!Array.isArray(result.templates)) return refuse(503, 'outcome-unknown');
return json(res, 200, { templates: result.templates, writable: Boolean(write) });
}
if (!DIGEST.test(result.digest ?? '')) return refuse(503, 'outcome-unknown');
if (preview) return json(res, 200, result);
// A save admitted may already have written its file: the receipt is returned even if the session
// ended meanwhile, never reported as not done.
return json(res, 200, { name: result.name, scope: result.scope, digest: result.digest });
}
+173
View File
@@ -0,0 +1,173 @@
// Starter packs SP8 (#1873): the owner's template routes on a selected organization, end to end. An
// ephemeral organization with the real runtime and the broker's console socket for every call: the read
// and the fenced write both bind the business human's console cap, as the Console's consoleCall does.
// The SSO session is a fixture authority.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { busOrganization } from '../../bus/src/organization-context.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { serve } from '../../bus/src/server.mjs';
import { Client } from '../../bus/src/client.mjs';
import { startServer } from '../src/serve.mjs';
import { TEMPLATE_WRITE_VERBS, TEMPLATES_ROUTE, templatesCaller } from '../src/template-http.mjs';
import { close } from './fixture.mjs';
async function fixture(t, { send = true } = {}) {
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
const root = mkdtempSync(join(tmpdir(), 'th-'));
const rolesDir = rolesCopy(root), system = systemFor(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { ...(doc.plugins ?? {}), harness: ['pi'] };
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = loadBusiness('demo', { dir: root, rolesDir });
const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })]));
undo.push(() => rmSync(root, { recursive: true, force: true }));
const runtime = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(business, resolved) }, organization: { dir: root, rolesDir, system } });
undo.push(() => runtime.close());
const path = join(root, 'b.sock');
const socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} },
authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) });
undo.push(() => socketServer.close());
const calls = [];
const consoleCall = kind => async (verb, args, ctx) => { calls.push([kind, verb, Object.keys(args).sort()]); return new Client({ path, console: { subject: ctx.subject } }).call(verb, args); };
const requestRead = consoleCall('read');
const templatesWrite = templatesCaller(consoleCall('write'));
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true };
let active = true;
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
const organizations = {
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, templatesWrite: send ? templatesWrite : null,
reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } },
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), organizations, log: () => {} });
const base = `http://127.0.0.1:${web.address().port}`;
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
undo.push(() => close(web));
const saved = name => join(root, 'templates', 'jason', `${name}.json`);
return { base, file, root, calls, saved, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
}
function call(f, path, method = 'GET', body, headers = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
});
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
});
}
const ORG = '/api/orgs/demo';
test('SP8 the owner previews a template (nothing written), saves it with the digest, and lists it', async t => {
const f = await fixture(t);
const [empty, none] = await call(f, `${ORG}/templates`);
assert.deepEqual([empty, none], [200, { templates: [], writable: true }]);
const before = readFileSync(f.file);
const [previewed, preview] = await call(f, `${ORG}/templates/preview`, 'POST', { name: 'demo-shape' });
assert.equal(previewed, 200, JSON.stringify(preview));
assert.match(preview.digest, /^[a-f0-9]{64}$/);
assert.ok(typeof preview.json === 'string' && Array.isArray(preview.freeText));
assert.ok(!existsSync(f.saved('demo-shape')), 'preview writes nothing');
const [stale, staleBody] = await call(f, `${ORG}/templates`, 'POST', { name: 'demo-shape', digest: '0'.repeat(64) });
assert.deepEqual([stale, staleBody.error], [409, 'pack-plan-stale']);
const [savedStatus, out] = await call(f, `${ORG}/templates`, 'POST', { name: 'demo-shape', digest: preview.digest });
assert.deepEqual([savedStatus, out], [200, { name: 'demo-shape', scope: 'org', digest: preview.digest }]);
assert.equal(readFileSync(f.saved('demo-shape'), 'utf8'), preview.json);
assert.deepEqual(readFileSync(f.file), before, 'the organization file is unchanged');
const [again, againBody] = await call(f, `${ORG}/templates`, 'POST', { name: 'demo-shape', digest: preview.digest });
assert.deepEqual([again, againBody.error], [400, 'invalid-request']);
const [, list] = await call(f, `${ORG}/templates`);
assert.deepEqual(list.templates.map(x => [x.name, x.scope]), [['demo-shape', 'org']]);
// The preview and the list went over the read; only the saves used the fenced writer.
assert.deepEqual(f.calls.map(([kind, verb, keys]) => `${kind} ${verb} ${keys.join(',')}`), [
'read template.list.console ', 'read template.save.console name,preview',
'write template.save.console digest,name', 'write template.save.console digest,name', 'write template.save.console digest,name',
'read template.list.console ',
]);
});
test('SP8 the admission checks refuse before any bus call; the writer is fenced to a digest save', async t => {
const f = await fixture(t);
const digest = 'a'.repeat(64);
for (const [label, path, method, body, headers, expected] of [
['account field', `${ORG}/templates/preview`, 'POST', { name: 'x', account: 'alex' }, {}, [400, 'invalid-request']],
['preview field on save', `${ORG}/templates`, 'POST', { name: 'x', digest, preview: true }, {}, [400, 'invalid-request']],
['save without digest', `${ORG}/templates`, 'POST', { name: 'x' }, {}, [400, 'invalid-request']],
['bad digest', `${ORG}/templates`, 'POST', { name: 'x', digest: 'zz' }, {}, [400, 'invalid-request']],
['dot-dot name', `${ORG}/templates/preview`, 'POST', { name: '..' }, {}, [400, 'invalid-request']],
['path name', `${ORG}/templates/preview`, 'POST', { name: 'a/b' }, {}, [400, 'invalid-request']],
['bad project', `${ORG}/templates/preview`, 'POST', { name: 'x', project: '../p' }, {}, [400, 'invalid-request']],
['not JSON', `${ORG}/templates/preview`, 'POST', '{', {}, [400, 'invalid-request']],
['too large', `${ORG}/templates/preview`, 'POST', JSON.stringify({ name: 'x', project: 'p'.repeat(5000) }), {}, [413, 'invalid-request']],
['form post', `${ORG}/templates`, 'POST', { name: 'x', digest }, { 'content-type': 'text/plain' }, [400, 'invalid-request']],
['cross site', `${ORG}/templates`, 'POST', { name: 'x', digest }, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']],
['cross site read', `${ORG}/templates`, 'GET', undefined, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']],
['preview by GET', `${ORG}/templates/preview`, 'GET', undefined, {}, [405, 'method-not-allowed']],
['list by DELETE', `${ORG}/templates`, 'DELETE', { name: 'x' }, {}, [405, 'method-not-allowed']],
]) {
const [status, result] = await call(f, path, method, body, headers);
assert.deepEqual([status, result.error], expected, label);
}
assert.equal(f.calls.length, 0, 'no refused request reached the bus');
assert.ok(!existsSync(join(f.root, 'templates')));
assert.deepEqual(TEMPLATE_WRITE_VERBS, ['template.save.console']);
await assert.rejects(templatesCaller(async () => ({}))('template.list.console', {}, {}), /does not call template\.list\.console/);
await assert.rejects(templatesCaller(async () => ({}))('template.save.console', { name: 'x', preview: true }, {}), /does not preview/);
assert.ok(TEMPLATES_ROUTE.test('/api/templates') && TEMPLATES_ROUTE.test('/api/templates/preview') && !TEMPLATES_ROUTE.test('/api/templates/x'));
f.signOut();
assert.deepEqual((await call(f, `${ORG}/templates`))[0], 401);
});
test('SP8 without --send the save is read-only; the list and preview still answer', async t => {
const f = await fixture(t, { send: false });
const [listed, list] = await call(f, `${ORG}/templates`);
assert.deepEqual([listed, list.writable], [200, false]);
const [previewed, preview] = await call(f, `${ORG}/templates/preview`, 'POST', { name: 'demo-shape' });
assert.equal(previewed, 200, JSON.stringify(preview));
const [status, result] = await call(f, `${ORG}/templates`, 'POST', { name: 'demo-shape', digest: preview.digest });
assert.deepEqual([status, result.error], [403, 'read-only']);
assert.ok(!existsSync(join(f.root, 'templates')));
});
test('SP8 a session that is not the root owner reaches no template route; neither does a Console without sign-in', async t => {
const f = await fixture(t);
f.revokeOwner();
for (const [path, method, body] of [[`${ORG}/templates`, 'GET'], [`${ORG}/templates/preview`, 'POST', { name: 'x' }], [`${ORG}/templates`, 'POST', { name: 'x', digest: 'a'.repeat(64) }]]) {
const [status, result] = await call(f, path, method, body);
assert.deepEqual([status, result.error], [403, 'owner-only'], `${method} ${path}`);
}
assert.equal(f.calls.length, 0);
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(web));
const bare = { base: `http://127.0.0.1:${web.address().port}` };
for (const [path, method, body] of [['/api/templates', 'GET'], ['/api/templates/preview', 'POST', { name: 'x' }]]) {
const [status, result] = await call(bare, path, method, body);
assert.deepEqual([status, result.error], [403, 'owner-only'], `${method} ${path}`);
}
});
test('SP8 a sign-out while the body is still arriving dispatches nothing', async t => {
const f = await fixture(t);
const data = JSON.stringify({ name: 'demo-shape', digest: 'a'.repeat(64) });
const answer = new Promise((resolve, reject) => {
const req = request(`${f.base}${ORG}/templates`, { method: 'POST', headers: { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
});
req.on('error', reject);
req.write(data.slice(0, 5));
setTimeout(() => { f.signOut(); req.end(data.slice(5)); }, 150);
});
const [status, body] = await answer;
assert.deepEqual([status, body.error], [401, 'sign-in-required']);
assert.equal(f.calls.length, 0);
});