git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
478 lines
29 KiB
JavaScript
478 lines
29 KiB
JavaScript
// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition,
|
|
// vars and launch membership. It is human-only: the broker's instances and instances.set verbs
|
|
// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918).
|
|
// holder, credentials and tracker stay hand edits. Row 194 (#1692) adds delete, the human's veto
|
|
// lists and the chief's hire and fire. Every write goes through the organization writer, which
|
|
// logs the actor and the old and new values.
|
|
import { existsSync, readdirSync } from "node:fs";
|
|
import { organizationWriter, OrganizationError } from "./organization.mjs";
|
|
import { validateBusinessDocument, businessFilePath, checkBot } from "./organization-config.mjs";
|
|
import { DATE_KEY } from "./credentials.mjs";
|
|
import { checkRoutes, creativeHumanOnly } from "./launch-settings.mjs";
|
|
import { loadProject, projectFilePath } from "./project.mjs";
|
|
import { resolveInstance } from "./resolve.mjs";
|
|
import { loadRole } from "./role.mjs";
|
|
import { refuse } from "./errors.mjs";
|
|
import { isPlainObject, canonicalJson } from "./util.mjs";
|
|
import { CREATIVE_DEFINITIONS, CREATIVE_SERVICE, END_PROTECTED, HOST_SERVICE, ID_PATTERN, checkGrantScope, grantScopeOf } from "./vocabulary.mjs";
|
|
|
|
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch", "tracker"];
|
|
const VETO_KEYS = ["revision", "op", "name"];
|
|
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
|
|
const SCOPE_KEYS = ["revision", "scope"];
|
|
const id = value => typeof value === "string" && ID_PATTERN.test(value);
|
|
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
|
|
// Every limits.* key merges by intersection, so it can only narrow.
|
|
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
|
|
|
|
// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative
|
|
// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a
|
|
// change. The assistant is refused for every actor, the human included. The listing's
|
|
// endProtected is this same check for its reader, so the Agents page offers no Delete it would refuse.
|
|
function protectedRefusal(document, instance, actor) {
|
|
const { definition } = document.roles[instance];
|
|
if (!END_PROTECTED.includes(definition)) return null;
|
|
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
|
|
if (actor === document.human) return null;
|
|
return { code: "organization-creative-human-only", message: `${instance} is a ${definition} instance, which only the human deletes` };
|
|
}
|
|
|
|
// Row 194 (#1692, V5): why an instance can't be deleted by actor, checked in the design's order, or
|
|
// null. The last-listed check runs when the proposed file is validated (deletedDocument).
|
|
export function deleteRefusal(document, rolesDir, instance, actor) {
|
|
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
|
|
const entry = document.roles[instance];
|
|
const refused = protectedRefusal(document, instance, actor);
|
|
if (refused) return refused;
|
|
const references = [];
|
|
if (document.launch?.by === instance) references.push("launch.by");
|
|
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
|
|
if (references.length > 0) {
|
|
return { code: "organization-instance-referenced", message: `${instance} is ${references.join(" and ")}; change that first` };
|
|
}
|
|
if (document.launch?.keep?.includes(instance)) {
|
|
return { code: "organization-instance-kept", message: `${instance} is kept; run veto unkeep first` };
|
|
}
|
|
// Row 194 (brief item 8): delete revokes the agent's store credentials before the write, so an
|
|
// instance whose every reference is `store` can go, its tracker bot included (sage-1610 b). A
|
|
// file or env reference stays a hand edit.
|
|
const refs = Object.values(entry.credentials ?? {});
|
|
if (refs.some(ref => ref?.store !== true)) {
|
|
return { code: "organization-credentials-out-of-scope", message: `${instance} holds a file or env credential; deleting it is a hand edit` };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The document with `instance` deleted (V5 step 2). It removes roles.<instance> and the references
|
|
// that exist only for it, launch.instances and launch.limits.tokens.instances, and adds the name to
|
|
// retired. Every other reference (launch.by, the arbiters, launch.keep) refuses in deleteRefusal.
|
|
// A business whose only listed instance is this one refuses with a BusinessError.
|
|
export function deletedDocument(document, instance) {
|
|
const next = structuredClone(document);
|
|
delete next.roles[instance];
|
|
if (next.launch) {
|
|
if (next.launch.instances.length === 1 && next.launch.instances[0] === instance) {
|
|
refuse(`${instance} is the last name in launch.instances; a business needs one instance the chief may launch`);
|
|
}
|
|
next.launch.instances = next.launch.instances.filter(name => name !== instance);
|
|
const tokens = next.launch.limits?.tokens?.instances;
|
|
if (tokens && Object.hasOwn(tokens, instance)) delete tokens[instance];
|
|
}
|
|
next.retired = [...(next.retired ?? []), instance];
|
|
return next;
|
|
}
|
|
|
|
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
|
|
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
|
|
// which stay out of this row (sage-0918); the refusal names that, so a later row lifts it alone.
|
|
// Row 213 (C2-08, M10): a creative definition is END_PROTECTED, yet this writer is how the human
|
|
// adds and changes one, so it isn't closed here. The caller runs creativeHumanOnly first.
|
|
const closed = name => END_PROTECTED.includes(name) && !CREATIVE_DEFINITIONS.includes(name);
|
|
// Row 285 (mos-1586): derive creative from the reviewed authority data, not another role-name list.
|
|
const creativeDefinition = definition => definition.authority.withinRole.includes("library.write");
|
|
const credentialServices = definition => definition.credentials.map(c => c.service).sort();
|
|
const sameCredentialServices = (before, after) => credentialServices(before).join(",") === credentialServices(after).join(",");
|
|
|
|
// Row 194 (brief item 7): the human's add passes credentials true and lifts it. The tester's add,
|
|
// change and hire keep it.
|
|
function definitionFor(rolesDir, name, { credentials = false } = {}) {
|
|
if (closed(name)) throw new OrganizationError("organization-definition-refused");
|
|
const definition = loadRole(rolesDir, name);
|
|
if (definition.roleVersion !== 2) throw new OrganizationError("invalid-request", `role definition ${name} is version 1`);
|
|
if (!credentials && definition.credentials.length > 0) {
|
|
throw new OrganizationError("organization-credentials-out-of-scope",
|
|
`${name} declares ${definition.credentials.map(c => c.service).join(" and ")} credentials; binding them is a hand edit`);
|
|
}
|
|
return definition;
|
|
}
|
|
|
|
// Row 194 (brief item 7, sage-1605, sage-1610 a): the human's add of a credentialed definition.
|
|
// Each declared service gets a store reference dated the create day (UTC), and no file: the agent
|
|
// needs a bus restart, then row 199's issue. A vikunja definition needs args.tracker, an existing
|
|
// Vikunja bot the human names. The add makes no Vikunja account. Any other definition refuses it.
|
|
function createdCredentials(old, instance, definition, args) {
|
|
// webby-0094 N2: a host grant is no token reference (organization-config.mjs checkRoles drops it too).
|
|
const services = definition.credentials.map(c => c.service).filter(service => service !== HOST_SERVICE);
|
|
const out = {};
|
|
if (services.includes("vikunja")) {
|
|
if (!Object.hasOwn(args, "tracker")) {
|
|
throw new OrganizationError("organization-tracker-required", `${definition.name} uses vikunja; add needs tracker {bot, botId}, an existing Vikunja bot`);
|
|
}
|
|
let tracker;
|
|
try {
|
|
tracker = checkBot(args.tracker, "tracker");
|
|
} catch (e) {
|
|
if (e.name === "BusinessError") throw new OrganizationError("invalid-request", e.message);
|
|
throw e;
|
|
}
|
|
// organization-config.mjs checks the same after the write: no two instances and not the sync bot.
|
|
const taken = Object.entries(old.roles).filter(([name, entry]) => name !== instance && entry.tracker).map(([name, entry]) => [name, entry.tracker]);
|
|
if (old.tracker?.sync) taken.push(["tracker.sync", old.tracker.sync]);
|
|
for (const [name, other] of taken) {
|
|
if (other.bot === tracker.bot || other.botId === tracker.botId) {
|
|
throw new OrganizationError("organization-tracker-bot-taken", `${name} already uses bot ${other.bot} (botId ${other.botId})`);
|
|
}
|
|
}
|
|
out.tracker = tracker;
|
|
} else if (Object.hasOwn(args, "tracker")) {
|
|
throw new OrganizationError("invalid-request", `${definition.name} uses no vikunja credential, so it takes no tracker`);
|
|
}
|
|
if (services.length > 0) {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
out.credentials = Object.fromEntries(services.map(service => [service, { store: true, [DATE_KEY[service]]: today }]));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// levels(name): the thinking levels harness module `name` applies, or null (launch-settings.mjs
|
|
// checkRoutes, row 65). Row 194 (brief item 8, row 199 D13): revoke(business, instance, how,
|
|
// services) revokes the instance's credentials for a fire or a delete and throws
|
|
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
|
|
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
|
|
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
|
|
|
|
// The definitions in rolesDir and whether add and set accept each one.
|
|
function definitions() {
|
|
const out = {};
|
|
for (const file of readdirSync(rolesDir).filter(f => f.endsWith(".json")).sort()) {
|
|
const name = file.slice(0, -5);
|
|
let definition;
|
|
try {
|
|
definition = loadRole(rolesDir, name);
|
|
} catch (e) {
|
|
if (e.name === "BusinessError") continue;
|
|
throw e;
|
|
}
|
|
if (definition.roleVersion !== 2) continue;
|
|
const services = definition.credentials.map(c => c.service);
|
|
// mos-1586: only creative gitea-store adds join the base no-credential listing.
|
|
const creativeAdd = creativeDefinition(definition) && services.every(service => service === CREATIVE_SERVICE);
|
|
out[name] = { services, accepted: !closed(name) && (services.length === 0 || creativeAdd) };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Row 256: actor is the reader, so endProtected is the delete check for that reader. A read with
|
|
// no actor (the bus's reloads) answers as for any non-human.
|
|
function read(business, actor) {
|
|
const { document, revision } = writer.read(business);
|
|
validateBusinessDocument(document, businessFilePath(business, dir), { rolesDir });
|
|
const launch = document.launch ?? null;
|
|
const instances = Object.fromEntries(Object.entries(document.roles).map(([name, entry]) => [name, {
|
|
definition: entry.definition,
|
|
vars: entry.vars ?? {},
|
|
launched: launch ? launch.instances.includes(name) : false,
|
|
launcher: launch ? launch.by === name : false,
|
|
protected: closed(entry.definition),
|
|
kept: (launch?.keep ?? []).includes(name),
|
|
endProtected: protectedRefusal(document, name, actor) !== null,
|
|
}]));
|
|
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
|
|
return {
|
|
business, revision, launch: launch !== null, instances, definitions: definitions(),
|
|
noHire: [...(launch?.noHire ?? [])], retired: [...(document.retired ?? [])],
|
|
};
|
|
}
|
|
|
|
// args: { revision, instance, op: "add" | "change" | "delete", definition?, vars?, launch?: boolean }.
|
|
// add needs definition; change needs at least one of definition, vars and launch. vars replaces
|
|
// the instance's vars whole. Row 194 (D3): the writer's restart is false. The bus reloads the
|
|
// business after the write and sets restart from that reload (runtime.mjs).
|
|
function set(business, args, actor, catalogue = null) {
|
|
if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) {
|
|
throw new OrganizationError("invalid-request");
|
|
}
|
|
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
|
|
throw new OrganizationError("invalid-request");
|
|
}
|
|
const { instance, op } = args;
|
|
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
|
|
// The broker refuses a business whose roles name human, tester or user (broker.mjs, the load
|
|
// check), so an instance under any of them would stop the bus at its next start (row 165 r2,
|
|
// Dewey F1; FED R3, dewey-0777).
|
|
if (instance === "human" || instance === "tester" || instance === "user") {
|
|
throw new OrganizationError("invalid-request", `${instance} names the bus's own sessions, never a role instance`);
|
|
}
|
|
if (Object.hasOwn(args, "definition") && !id(args.definition)) throw new OrganizationError("invalid-request");
|
|
if (Object.hasOwn(args, "vars") && !isPlainObject(args.vars)) throw new OrganizationError("invalid-request");
|
|
if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request");
|
|
|
|
const old = writer.read(business).document;
|
|
if (op === "delete") {
|
|
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
|
|
const services = Object.keys(old.roles[instance]?.credentials ?? {});
|
|
const beforeWrite = revoke ? () => revoke(business, instance, "delete", services) : null;
|
|
const result = commit(business, old, planDelete(old, instance, actor), { revision: args.revision, actor, beforeWrite, catalogue });
|
|
// sage-1610 (b): the bot user and any token it holds live in Vikunja, which this write
|
|
// can't reach (row 199 A5).
|
|
const bot = old.roles[instance]?.tracker?.bot;
|
|
return bot ? { ...result, tracker: { bot, removeByHand: `remove the Vikunja bot user ${bot} and any token it holds by hand in Vikunja` } } : result;
|
|
}
|
|
const next = structuredClone(old);
|
|
let creativeChange = null;
|
|
const exists = Object.hasOwn(next.roles, instance);
|
|
if (op === "add") {
|
|
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
|
|
if (exists) throw new OrganizationError("organization-instance-exists");
|
|
creativeHumanOnly(old, actor, instance, args.definition);
|
|
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
|
|
// Only the human's add binds credentials. The tester keeps the refusal (row 175 W2): the bus
|
|
// names the tester's actor "tester", which no human or role may be called.
|
|
const definition = definitionFor(rolesDir, args.definition, { credentials: actor !== "tester" });
|
|
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
|
|
Object.assign(next.roles[instance], createdCredentials(old, instance, definition, args));
|
|
} else {
|
|
if (Object.hasOwn(args, "tracker")) throw new OrganizationError("invalid-request", "tracker is set only by add");
|
|
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
|
|
if (!exists) throw new OrganizationError("organization-unknown-instance");
|
|
const entry = next.roles[instance];
|
|
// An assistant's settings change only through limits.set's assistants key (row 155), so
|
|
// nothing here touches a protected instance, its vars and launch membership included. A
|
|
// creative instance is the exception: the human changes it here, and only the human (M10).
|
|
creativeHumanOnly(old, actor, instance, entry.definition);
|
|
if (closed(entry.definition)) throw new OrganizationError("organization-definition-refused");
|
|
if (Object.hasOwn(args, "definition") && args.definition !== entry.definition) {
|
|
creativeHumanOnly(old, actor, instance, args.definition);
|
|
const before = loadRole(rolesDir, entry.definition);
|
|
// mos-1585: a declaration alone is no binding (the chief without a library holds none).
|
|
const holdsCredentials = Object.keys(entry.credentials ?? {}).length > 0;
|
|
const creative = holdsCredentials && creativeDefinition(before);
|
|
const after = definitionFor(rolesDir, args.definition, { credentials: creative });
|
|
if (holdsCredentials && !creative) {
|
|
throw new OrganizationError("organization-credentials-out-of-scope",
|
|
`${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`);
|
|
}
|
|
if (creative) creativeChange = { before, after };
|
|
entry.definition = args.definition;
|
|
}
|
|
if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars);
|
|
}
|
|
if (Object.hasOwn(args, "launch")) {
|
|
if (!next.launch) throw new OrganizationError("organization-launch-not-configured");
|
|
const listed = next.launch.instances;
|
|
if (!args.launch) next.launch.instances = listed.filter(name => name !== instance);
|
|
else if (!listed.includes(instance)) listed.push(instance);
|
|
}
|
|
if (creativeChange) {
|
|
// mos-1586: validate the complete proposal first, preserving base BusinessError refusals.
|
|
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
|
|
if (!creativeDefinition(creativeChange.after) || !sameCredentialServices(creativeChange.before, creativeChange.after)) {
|
|
throw new OrganizationError("organization-credentials-out-of-scope",
|
|
`${instance} may retain credentials only between creative definitions with exactly the same services`);
|
|
}
|
|
}
|
|
return commit(business, old, next, { revision: args.revision, actor, catalogue });
|
|
}
|
|
|
|
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
|
|
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
|
|
function planDelete(document, instance, actor) {
|
|
const refusal = deleteRefusal(document, rolesDir, instance, actor);
|
|
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
|
|
return deletedDocument(document, instance);
|
|
}
|
|
|
|
function checkDelete(business, instance, actor) {
|
|
const { document, revision } = writer.read(business);
|
|
const next = planDelete(document, instance, actor);
|
|
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
|
|
return { revision };
|
|
}
|
|
|
|
// Row 194 (V6): args { revision, op: keep | unkeep | nohire | hire, name }. keep needs a declared
|
|
// instance that isn't END_PROTECTED. nohire needs only the id pattern. Human-only at the broker.
|
|
function veto(business, args, actor) {
|
|
if (!isPlainObject(args) || Object.keys(args).some(key => !VETO_KEYS.includes(key))) throw new OrganizationError("invalid-request");
|
|
if (!revisionOk(args.revision) || !VETO_OPS.includes(args.op) || !id(args.name)) throw new OrganizationError("invalid-request");
|
|
const old = writer.read(business).document;
|
|
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
|
const next = structuredClone(old);
|
|
const { op, name } = args;
|
|
const key = op === "keep" || op === "unkeep" ? "keep" : "noHire";
|
|
const list = next.launch[key] ?? [];
|
|
if (op === "keep") {
|
|
if (!Object.hasOwn(next.roles, name)) throw new OrganizationError("organization-unknown-instance");
|
|
if (END_PROTECTED.includes(next.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
|
|
}
|
|
const add = op === "keep" || op === "nohire";
|
|
if (add === list.includes(name)) throw new OrganizationError("organization-no-change");
|
|
next.launch[key] = add ? [...list, name] : list.filter(entry => entry !== name);
|
|
return commit(business, old, next, { revision: args.revision, actor });
|
|
}
|
|
|
|
// Row 298 (#1804, Q228): the chief's grant scope as the file stands. scope is the one the broker
|
|
// applies, so a bad value reads as {} with fallback true.
|
|
function grantScopeRead(business) {
|
|
const { document, revision } = writer.read(business);
|
|
if (!document.launch) throw new OrganizationError("organization-launch-not-configured");
|
|
return { business, revision, ...grantScopeOf(document.launch.grantScope) };
|
|
}
|
|
|
|
// Row 298: args { revision, scope }. Human-only at the broker (grants.scope.set), and the only
|
|
// writer of launch.grantScope: SET_KEYS and the organization.set keys don't name it. The scope is
|
|
// checked strictly here; {} removes the key. old is what the broker applied before the write,
|
|
// the fallback included.
|
|
function grantScope(business, args, actor) {
|
|
if (!isPlainObject(args) || Object.keys(args).some(key => !SCOPE_KEYS.includes(key))) throw new OrganizationError("invalid-request");
|
|
if (!revisionOk(args.revision)) throw new OrganizationError("invalid-request");
|
|
const scope = checkGrantScope(args.scope);
|
|
if (scope === null) throw new OrganizationError("invalid-request", "scope is not a grant scope");
|
|
const old = writer.read(business).document;
|
|
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
|
const next = structuredClone(old);
|
|
if (Object.keys(scope).length === 0) delete next.launch.grantScope;
|
|
else next.launch.grantScope = scope;
|
|
const before = grantScopeOf(old.launch.grantScope).scope;
|
|
return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before };
|
|
}
|
|
|
|
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
|
|
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
|
|
// (declared, not listed) is listed again only with its own definition and no vars. The writer
|
|
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
|
|
function prepareHire(business, args) {
|
|
const { document: old, revision } = writer.read(business);
|
|
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
|
const { name, definition } = args;
|
|
// FED R3 r4 (sage-2557): and user, the granted user's principal, which the broker's load check refuses.
|
|
if (!id(name) || !id(definition) || name === "human" || name === "tester" || name === "user") throw new OrganizationError("invalid-request");
|
|
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
|
|
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
|
|
}
|
|
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
|
|
// Row 213 (M10): definitionFor lets a creative definition through for the human's add and
|
|
// change. A hire is the chief's, so it refuses one here.
|
|
if (CREATIVE_DEFINITIONS.includes(definition)) {
|
|
throw new OrganizationError("definition-not-hireable", `${definition} is a creative definition, which only the human adds`);
|
|
}
|
|
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
|
|
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
|
|
const next = structuredClone(old);
|
|
if (Object.hasOwn(old.roles, name)) {
|
|
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
|
|
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
|
|
throw new OrganizationError("organization-instance-exists");
|
|
}
|
|
} else {
|
|
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
|
|
}
|
|
// mos-1585: duplicates refuse before definition eligibility, with no file or credential effect.
|
|
definitionFor(rolesDir, definition);
|
|
next.launch.instances.push(name);
|
|
return { old, next, revision };
|
|
}
|
|
// Trusted read-only lifecycle preparation; the writer uses the same checks again.
|
|
function checkHire(business, args) {
|
|
return { revision: prepareHire(business, args).revision };
|
|
}
|
|
function hire(business, args, { actor, context }) {
|
|
const { old, next, revision } = prepareHire(business, args);
|
|
return commit(business, old, next, { revision, actor, context });
|
|
}
|
|
|
|
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
|
|
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
|
|
function fire(business, args, { actor, context }) {
|
|
const { document: old, revision } = writer.read(business);
|
|
const { name } = args;
|
|
fireChecks(old, name);
|
|
// Item 8: fire revokes each service of the entry and keeps the store directory (sage-1593).
|
|
const services = Object.keys(old.roles[name].credentials ?? {});
|
|
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
|
|
// its tokens revoked, with no write. A revoke is safe to repeat.
|
|
if (!old.launch.instances.includes(name)) {
|
|
const revoked = revoke ? revoke(business, name, "fire", services) : null;
|
|
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
|
|
}
|
|
const next = structuredClone(old);
|
|
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
|
|
let revoked = null;
|
|
const beforeWrite = revoke ? () => { revoked = revoke(business, name, "fire", services); } : null;
|
|
const result = commit(business, old, next, { revision, actor, context, beforeWrite });
|
|
return { ...result, delisted: true, ...(revoked ? { credentials: revoked } : {}) };
|
|
}
|
|
|
|
// Row 194 b6 (webby-0094 B1): the broker runs fire's checks against the file before step A stops
|
|
// anything, so a keep its own copy hasn't loaded (a refused reload) refuses there and not at
|
|
// step B, which would leave the instance firing. No write.
|
|
function checkFire(business, name) {
|
|
const { document, revision } = writer.read(business);
|
|
fireChecks(document, name);
|
|
return { revision };
|
|
}
|
|
|
|
function fireChecks(old, name) {
|
|
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
|
if (!id(name) || !Object.hasOwn(old.roles, name)) throw new OrganizationError("organization-unknown-instance");
|
|
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
|
|
if (END_PROTECTED.includes(old.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
|
|
if (old.launch.by === name) throw new OrganizationError("organization-instance-referenced", `${name} is launch.by`);
|
|
if (old.launch.instances.includes(name) && old.launch.instances.length === 1) {
|
|
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
|
|
}
|
|
}
|
|
|
|
// The checks every write runs, then the write, which runs beforeWrite (a fire's or a delete's
|
|
// revoke) under its lock (row 239). A check that refuses revokes nothing, and a revoke that throws
|
|
// writes nothing. beforeWrite must not write this business: the writer holds its lock.
|
|
function commit(business, old, next, { revision, actor, context, beforeWrite = null, catalogue = null }) {
|
|
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
|
|
|
|
// The checks mosaic business validate runs, before the writer publishes anything, then the
|
|
// route checks every write of the route vars runs (row 65). A file that no longer validates
|
|
// has every route checked.
|
|
const proposed = validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
|
|
let before = null;
|
|
try {
|
|
before = validateBusinessDocument(old, businessFilePath(business, dir), { rolesDir });
|
|
} catch (e) {
|
|
if (e.name !== "BusinessError") throw e;
|
|
}
|
|
checkRoutes({ proposed, old: before, levels, catalogue });
|
|
for (const [project, entry] of Object.entries(proposed.projects)) {
|
|
if (entry.kind === 'library') continue;
|
|
if (!existsSync(projectFilePath(entry.root))) continue;
|
|
const loaded = loadProject(entry.root);
|
|
if (loaded.id !== project) throw new OrganizationError("organization-project-mismatch");
|
|
for (const name of Object.keys(proposed.roles)) {
|
|
try {
|
|
resolveInstance({ system, business: proposed, project: loaded, instance: name });
|
|
} catch (e) {
|
|
if (e.name === "BusinessError") throw new OrganizationError("organization-unresolvable-instance", e.message);
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
// Row 194 N1 (sage-1634): a delete writes on the revision its request read when it was raised,
|
|
// so a write since then makes it stale. This early check refuses it without taking the lock. The
|
|
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
|
|
// a stale fire or delete from revoking.
|
|
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
|
|
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
|
|
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
|
|
}
|
|
|
|
return { writer, read, set, checkDelete, checkFire, checkHire, veto, hire, fire, grantScope, grantScopeRead };
|
|
}
|