Review P1: the CLI wrote definitions with a no-op secret check. setDefinition now refuses definition-secret-check-required when a write that adds or changes an entry has no assertClean. New bus verbs definition.import|invent|edit|remove run the writes for the business's human at the cli with the credential store's assertClean; the tester, agents and readers refuse. The CLI's definition writes call the bus and map its codes to messages that never quote the definition.
543 lines
27 KiB
JavaScript
543 lines
27 KiB
JavaScript
// Trusted host write path (REQ-ROLE-3). Callers supply a verified actor;
|
|
// neither this module nor migration authenticates a new channel.
|
|
import {
|
|
constants, openSync, closeSync, fstatSync, readFileSync, writeFileSync,
|
|
fsyncSync, lstatSync, mkdirSync, renameSync, rmSync,
|
|
} from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { businessFilePath, validateBusinessDocument } from "./organization-config.mjs";
|
|
import { requireId, sha256, canonicalJson, parseJsonFileText } from "./util.mjs";
|
|
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
|
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
|
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
|
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
|
import { withRegistryWriter } from "./org-registry.mjs";
|
|
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
|
import { checkDefinition, checkDefinitionName, instancesNaming } from "./definitions.mjs";
|
|
|
|
export class OrganizationError extends Error {
|
|
constructor(code, message = code) {
|
|
super(message);
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const fail = (code, message) => {
|
|
throw new OrganizationError(code, message);
|
|
};
|
|
|
|
function regular(file) {
|
|
let fd;
|
|
try {
|
|
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
const st = fstatSync(fd);
|
|
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o022)) {
|
|
fail("organization-refused", "organization file must be regular, owned and not group/world writable");
|
|
}
|
|
return readFileSync(fd, "utf8");
|
|
} finally {
|
|
if (fd !== undefined) closeSync(fd);
|
|
}
|
|
}
|
|
|
|
function directory(path) {
|
|
try {
|
|
mkdirSync(path, { mode: 0o700 });
|
|
syncDir(dirname(path));
|
|
} catch (e) {
|
|
if (e.code !== "EEXIST") throw e;
|
|
}
|
|
const st = lstatSync(path);
|
|
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077)) {
|
|
fail("organization-refused", "organization evidence directory is unsafe");
|
|
}
|
|
}
|
|
|
|
function syncDir(dir) {
|
|
const fd = openSync(dir, constants.O_RDONLY | constants.O_DIRECTORY);
|
|
try {
|
|
fsyncSync(fd);
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
|
|
function durable(file, bytes) {
|
|
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
|
|
const fd = openSync(file, flags, 0o600);
|
|
try {
|
|
writeFileSync(fd, bytes);
|
|
fsyncSync(fd);
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
|
|
function changes(old, next, path = "") {
|
|
if (canonicalJson(old) === canonicalJson(next)) return [];
|
|
const objects = old && next && typeof old === "object" && typeof next === "object";
|
|
if (objects && !Array.isArray(old) && !Array.isArray(next)) {
|
|
const keys = [...new Set([...Object.keys(old), ...Object.keys(next)])];
|
|
return keys.flatMap(k => changes(old[k], next[k], path ? path + "." + k : k));
|
|
}
|
|
return [{
|
|
path, old: old ?? null, new: next ?? null,
|
|
oldPresent: old !== undefined, newPresent: next !== undefined,
|
|
}];
|
|
}
|
|
|
|
// P5/Q222: every writer protects per-model-key quotas, including removal/reset.
|
|
function modelQuotaFence(old, document, actor) {
|
|
const keys = doc => Object.fromEntries(Object.entries(doc?.launch?.limits?.tokens?.keys ?? {})
|
|
.filter(([key, entry]) => isDailyModelQuota(key, entry)));
|
|
if (canonicalJson(keys(old)) === canonicalJson(keys(document))) return;
|
|
const agents = Object.entries(old.roles ?? {}).flatMap(([instance, entry]) => [instance, `${entry?.definition}.${instance}`]);
|
|
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
|
|
fail("organization-model-quota-human-only");
|
|
}
|
|
|
|
// P5: the optional queue reader and polling setting are human changes even on
|
|
// the tester organization path. This guards every writer, not only the UI verb.
|
|
function usageFence(old, document, actor) {
|
|
const setting = doc => ({ plugin: doc?.plugins?.usage ?? null, usage: doc?.usage ?? null });
|
|
if (canonicalJson(setting(old)) === canonicalJson(setting(document))) return;
|
|
const agents = Object.entries(old.roles ?? {}).flatMap(([instance, entry]) => [instance, `${entry?.definition}.${instance}`]);
|
|
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
|
|
fail("organization-usage-human-only");
|
|
}
|
|
|
|
function systemTypesFence(old, document, actor) {
|
|
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
|
|
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
|
|
if (actor === old.human && actor !== 'tester' && !agents.includes(actor)) return;
|
|
fail('organization-system-types-human-only');
|
|
}
|
|
|
|
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
|
|
// the business's human as the file stood before the write, and no agent or the tester under that
|
|
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
|
|
function replyLimitsFence(old, document, actor) {
|
|
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
|
|
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
|
|
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
|
|
// The reason names each changed key and its value in force (the brief's M11).
|
|
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
const before = old.replyLimits, after = document?.replyLimits;
|
|
const named = plain(before) && plain(after)
|
|
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
|
|
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
|
|
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
|
|
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
|
|
}
|
|
|
|
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
|
|
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
|
|
// M11 fence refuses any actor but the human.
|
|
export function setReplyLimits(writer, id, args, actor) {
|
|
const replyLimits = checkReplyLimits(args?.replyLimits);
|
|
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
|
|
const { document } = writer.read(id);
|
|
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
|
|
}
|
|
|
|
export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = () => {} }) {
|
|
const evidence = join(dir, "businesses", ".history");
|
|
const logFile = id => join(evidence, id, "changes.jsonl");
|
|
// Keep the writer's established busy refusal when the shared registry lock
|
|
// is held, including when another document hold is already in progress.
|
|
function withWriterRegistry(fn) {
|
|
try { return withRegistryWriter(dir, fn); }
|
|
catch (e) { if (e.code === "org-locked") fail("organization-busy", "organization writer is busy"); throw e; }
|
|
}
|
|
|
|
// Reads and launches depend on the whole document, not transaction evidence.
|
|
function read(id) {
|
|
const file = businessFilePath(id, dir);
|
|
const bytes = regular(file);
|
|
const document = parseJsonFileText(bytes, file, "organization file");
|
|
validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
|
return { document, revision: sha256(bytes) };
|
|
}
|
|
|
|
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
|
|
// file that fails validation still yields its limits. { readable: false } when the file is missing,
|
|
// unreadable, not JSON or not an object.
|
|
function readRaw(id) {
|
|
try {
|
|
const document = JSON.parse(regular(businessFilePath(id, dir)));
|
|
return document !== null && typeof document === "object" && !Array.isArray(document)
|
|
? { readable: true, document } : { readable: false };
|
|
} catch {
|
|
return { readable: false };
|
|
}
|
|
}
|
|
|
|
function withLock(id, fn) {
|
|
const parent = join(dir, "businesses");
|
|
directory(evidence);
|
|
const home = join(evidence, id);
|
|
directory(home);
|
|
const lock = join(home, "write.lock");
|
|
try {
|
|
mkdirSync(lock, { mode: 0o700 });
|
|
durable(join(lock, "owner.json"), JSON.stringify({ pid: process.pid }));
|
|
syncDir(home);
|
|
} catch (e) {
|
|
if (e.code !== "EEXIST") throw e;
|
|
const st = lstatSync(lock);
|
|
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077)) {
|
|
fail("organization-refused", "unsafe writer lock");
|
|
}
|
|
let owner;
|
|
try {
|
|
owner = JSON.parse(regular(join(lock, "owner.json")));
|
|
} catch {
|
|
fail("organization-uncertain", "writer lock has no verifiable owner");
|
|
}
|
|
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1) fail("organization-uncertain");
|
|
try {
|
|
process.kill(owner.pid, 0);
|
|
fail("organization-busy");
|
|
} catch (x) {
|
|
if (x.code !== "ESRCH") throw x;
|
|
}
|
|
fail("organization-uncertain", "stale writer lock: confirm the old writer is gone before removing it");
|
|
}
|
|
const release = () => {
|
|
rmSync(lock, { recursive: true });
|
|
syncDir(home);
|
|
};
|
|
let pending = false;
|
|
try {
|
|
const out = fn({ home, parent });
|
|
if (out !== null && typeof out === "object" && typeof out.then === "function") {
|
|
pending = true;
|
|
return Promise.resolve(out).finally(release);
|
|
}
|
|
return out;
|
|
} finally {
|
|
if (!pending) release();
|
|
}
|
|
}
|
|
|
|
function log(id, event) {
|
|
let fd;
|
|
try {
|
|
const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW;
|
|
fd = openSync(logFile(id), flags, 0o600);
|
|
const st = fstatSync(fd);
|
|
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail("organization-refused");
|
|
writeFileSync(fd, JSON.stringify(event) + "\n");
|
|
fsyncSync(fd);
|
|
} finally {
|
|
if (fd !== undefined) closeSync(fd);
|
|
}
|
|
}
|
|
|
|
// Only writes reconcile receipts. Uncertain evidence cannot stop a bus start or launch.
|
|
function recover(id) {
|
|
let text;
|
|
try {
|
|
text = regular(logFile(id));
|
|
} catch (e) {
|
|
if (e.code === "ENOENT") return;
|
|
throw e;
|
|
}
|
|
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
|
|
const entries = text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : [];
|
|
const pending = new Map();
|
|
for (const e of entries) {
|
|
if (e.state === "prepared") pending.set(e.transaction, e);
|
|
else pending.delete(e.transaction);
|
|
}
|
|
for (const e of pending.values()) {
|
|
const hash = sha256(regular(businessFilePath(id, dir)));
|
|
if (hash !== e.before && hash !== e.after) {
|
|
fail("organization-uncertain", "organization bytes do not match prepared receipt");
|
|
}
|
|
log(id, {
|
|
...e, state: hash === e.after ? "committed" : "aborted",
|
|
recovered: true, at: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
|
|
// date, reason), never a value; the other setters pass none. context, when given, is logged with
|
|
// the change: row 194's hire and fire carry the chief's run and the decision there (D2.2).
|
|
// Row 239 (#1745): beforeWrite (a fire's or a delete's credential revoke) runs under the lock,
|
|
// after the revision check and the validations, so a refusal there revokes nothing. An error
|
|
// after it returns carries revoked: true, the lock's release included (duckworth-0165 C2), and the
|
|
// bus names it so the human reads that the tokens may already be revoked. beforeWrite must not
|
|
// write this business itself: it would find its own lock and refuse organization-busy.
|
|
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
|
|
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
|
|
// the old document, for a writer that builds what it publishes from what it read under the lock.
|
|
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
|
|
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
|
|
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard, pathsOnly = false }) {
|
|
requireId(actor, "authenticated actor");
|
|
requireId(id, "organization id");
|
|
let revoked = false;
|
|
try {
|
|
return withWriterRegistry((registry, publishRegistry) => withLock(id, ({ home, parent }) => {
|
|
recover(id);
|
|
const file = businessFilePath(id, dir);
|
|
const oldBytes = regular(file);
|
|
const before = sha256(oldBytes);
|
|
const entry = registry?.orgs?.[id];
|
|
if (entry && (entry.state !== 'active' || entry.digest !== before || !Number.isSafeInteger(entry.revision + 1))) {
|
|
fail('org-mismatch', 'organization document and registry disagree');
|
|
}
|
|
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
|
|
const old = parseJsonFileText(oldBytes, file, "organization file");
|
|
if (typeof document === "function") document = document(old);
|
|
guard(old, document);
|
|
replyLimitsFence(old, document, actor);
|
|
modelQuotaFence(old, document, actor);
|
|
usageFence(old, document, actor);
|
|
systemTypesFence(old, document, actor);
|
|
validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
|
const bytes = JSON.stringify(document, null, 2) + "\n";
|
|
// Check and log exactly what lands on disk, for every setter: a value JSON
|
|
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
|
|
const written = JSON.parse(bytes);
|
|
guard(old, written);
|
|
replyLimitsFence(old, written, actor);
|
|
modelQuotaFence(old, written, actor);
|
|
usageFence(old, written, actor);
|
|
systemTypesFence(old, written, actor);
|
|
validateBusinessDocument(written, file, { rolesDir, dataRoot });
|
|
if (beforeWrite) {
|
|
beforeWrite();
|
|
revoked = true;
|
|
}
|
|
const after = sha256(bytes);
|
|
const transaction = randomUUID();
|
|
const tx = join(home, transaction);
|
|
directory(tx);
|
|
const previous = join(tx, "previous.json");
|
|
durable(previous, oldBytes);
|
|
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
|
|
durable(candidate, bytes);
|
|
syncDir(tx);
|
|
const event = {
|
|
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
|
|
changes: pathsOnly ? changes(old, written).map(({ path, oldPresent, newPresent }) => ({ path, oldPresent, newPresent })) : changes(old, written),
|
|
...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
|
};
|
|
log(id, { ...event, state: "prepared" });
|
|
syncDir(home);
|
|
barrier("prepared");
|
|
if (sha256(regular(file)) !== before) {
|
|
log(id, { ...event, state: "aborted" });
|
|
rmSync(candidate);
|
|
fail("revision-conflict", "organization revision changed before publication");
|
|
}
|
|
renameSync(candidate, file);
|
|
syncDir(parent);
|
|
if (entry) { entry.digest = after; entry.revision++; publishRegistry(); }
|
|
barrier("renamed");
|
|
log(id, { ...event, state: "committed" });
|
|
return { revision: after, transaction, previous };
|
|
}));
|
|
} catch (e) {
|
|
if (revoked && e instanceof Error) e.revoked = true;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// C2-04 M9: every organization setter preserves the whole project mapping, including library
|
|
// playgrounds and hybrid universes.
|
|
const projectsProtected = (old, document) => {
|
|
if (canonicalJson(document?.projects) !== canonicalJson(old.projects)) fail("organization-projects-protected");
|
|
};
|
|
// #1873 T1: definitions change only through setDefinition, which bumps rev and checks the entry.
|
|
const definitionsProtected = (old, document) => {
|
|
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
|
|
};
|
|
function write(id, document, options) {
|
|
return commit(id, document, { ...options, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
|
|
}
|
|
|
|
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
|
|
// rev, or null to remove it; current is the entry or null. Nothing else in the document may change.
|
|
// An agent or the tester never writes one. A write bumps rev (1 for a new name); `rev`, when given,
|
|
// must be the current one (definition-rev-conflict). Editing or removing a definition an instance
|
|
// names refuses definition-in-use: changing a hired agent's role is a change decision (A4).
|
|
// assertClean is the credential store's held-secret check (the bus's credentials.assertClean). A
|
|
// write that adds or changes an entry needs it: with no check to run, the write refuses (R1).
|
|
function setDefinition(id, name, next, { revision, rev, actor, detail, assertClean }) {
|
|
checkDefinitionName(name, "definition name");
|
|
const logged = { ...detail, definition: name, rev: null };
|
|
const guard = (old, document) => {
|
|
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
|
if (actor === "tester" || agents.includes(actor) || (old.retired ?? []).includes(actor)) fail("human-required");
|
|
const { definitions: a = {}, ...restOld } = old;
|
|
const { definitions: b = {}, ...restNew } = document ?? {};
|
|
if (canonicalJson(restNew) !== canonicalJson(restOld)) fail("organization-definitions-only");
|
|
const others = (d) => canonicalJson(Object.fromEntries(Object.entries(d).filter(([k]) => k !== name)));
|
|
if (others(a) !== others(b)) fail("organization-definitions-only");
|
|
const current = Object.hasOwn(a, name) ? a[name] : null;
|
|
const sansRev = (e) => canonicalJson({ ...e, rev: 0 });
|
|
if (current && Object.hasOwn(b, name) && sansRev(current) === sansRev(b[name])) fail("definition-unchanged", `definition ${name} is unchanged`);
|
|
if (current) {
|
|
const users = instancesNaming(old, name);
|
|
if (users.length) fail("definition-in-use", `definition ${name} is named by ${users.join(", ")}; a change to a hired agent's role is a change decision`);
|
|
}
|
|
if (Object.hasOwn(b, name)) {
|
|
const want = current ? current.rev + 1 : 1;
|
|
if (b[name]?.rev !== want) fail("definition-rev-conflict", `definition ${name} must be written at rev ${want}`);
|
|
if (typeof assertClean !== "function") fail("definition-secret-check-required", "a definition write needs the credential store's secret check; write it through the bus");
|
|
checkDefinition(name, b[name], { assertClean });
|
|
}
|
|
};
|
|
const out = commit(id, (old) => {
|
|
const definitions = { ...(old.definitions ?? {}) };
|
|
const current = Object.hasOwn(definitions, name) ? definitions[name] : null;
|
|
if (rev !== undefined && (current?.rev ?? null) !== rev) fail("definition-rev-conflict", `definition ${name} is not at rev ${rev}; read again`);
|
|
const entry = next(current);
|
|
if (entry === null) {
|
|
if (!current) fail("definition-missing", `definitions declares no ${name}`);
|
|
delete definitions[name];
|
|
} else {
|
|
definitions[name] = { ...entry, rev: current ? current.rev + 1 : 1 };
|
|
logged.rev = definitions[name].rev;
|
|
}
|
|
const { definitions: _, ...rest } = old;
|
|
return Object.keys(definitions).length ? { ...rest, definitions } : rest;
|
|
}, { revision, actor, guard: (old, document) => { projectsProtected(old, document); guard(old, document); }, detail: logged });
|
|
return { ...out, rev: logged.rev };
|
|
}
|
|
|
|
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
|
|
// addition: one new key under the managed product layout, a receipt naming the request and plan, and
|
|
// no other change. It compares both the document it built and the bytes it will publish. A stale
|
|
// revision refuses revision-conflict: the owner confirms a fresh preview, never a silent rebase.
|
|
function addProject(id, { revision, project, entry, receipt }, actor) {
|
|
const guard = (old, document) => {
|
|
const problem = additionProblem({ old, next: document, project, entry, receipt });
|
|
if (problem) fail("organization-project-add-refused", problem);
|
|
};
|
|
return commit(id, old => ({ ...old, projects: { ...old.projects, [project]: entry } }), {
|
|
revision, actor, guard,
|
|
detail: { verb: ADDITION_VERB, request: receipt?.request, plan_digest: receipt?.plan_digest, project },
|
|
});
|
|
}
|
|
|
|
// The only exception to the projects fence for an existing software mapping.
|
|
// Build from the locked document and check both the candidate and serialized bytes.
|
|
function setProjectRemote(id, { revision, project, remote }, actor) {
|
|
const guard = (old, document) => {
|
|
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
|
|
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
|
|
const problem = remoteProblem(old, document, project, remote);
|
|
if (problem) fail(problem);
|
|
};
|
|
return commit(id, old => {
|
|
remoteTarget(old, project, remote);
|
|
return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
|
|
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
|
|
}
|
|
|
|
// #1873 A2 (design section 7): the only writer of `projects.<p>.agents`, one entry per call. `entry`
|
|
// is a define ({definition, vars?}), a narrow ({vars}), an exclude ({exclude: true}) or null to
|
|
// remove the entry. The human owner only: every agent actor, the tester included, refuses
|
|
// human-required, and so does any actor that is not the organization's human. The guard checks the
|
|
// candidate and the serialized bytes: nothing but that entry changes, the project exists and is not
|
|
// a library, and the project's entries and union pass A1's checks against the current root level.
|
|
// The change log names paths only; the detail names the project, the agent and the form.
|
|
function setProjectAgent(id, { revision, project, name, entry }, actor) {
|
|
requireId(id, "organization id");
|
|
requireId(project, "project id");
|
|
requireId(name, "agent name");
|
|
if (entry !== null && (entry === undefined || typeof entry !== "object" || Array.isArray(entry))) fail("project-agents-invalid", "an entry is an object, or null to remove it");
|
|
const root = rootForProjectCheck(dir, rolesDir);
|
|
const guard = (old, document) => {
|
|
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
|
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
|
|
if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`);
|
|
const problem = projectAgentProblem(old, document, { orgId: id, project, name, entry, rolesDir, root });
|
|
if (problem) fail(...problem);
|
|
};
|
|
return commit(id, old => {
|
|
const target = old.projects?.[project];
|
|
if (target === null || typeof target !== "object" || Array.isArray(target)) return old;
|
|
const agents = { ...(target.agents ?? {}) };
|
|
if (entry === null) delete agents[name]; else agents[name] = entry;
|
|
const { agents: _drop, ...rest } = target;
|
|
return { ...old, projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
|
|
}, { revision, actor, guard, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry) } });
|
|
}
|
|
|
|
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
|
|
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
|
|
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
|
|
function hold(id, fn) {
|
|
requireId(id, "organization id");
|
|
return withWriterRegistry(() => withLock(id, () => {
|
|
recover(id);
|
|
const { document, revision } = read(id);
|
|
const out = fn({ document, revision });
|
|
if (out !== null && typeof out === "object" && typeof out.then === "function") {
|
|
out.catch?.(() => {});
|
|
fail("organization-hold-sync", "a held check must be synchronous");
|
|
}
|
|
return out;
|
|
}));
|
|
}
|
|
|
|
// Like hold, but the lock stays on disk until an asynchronous effect settles, including on failure.
|
|
// The effect must not call an organization writer: it would find this same lock and refuse busy.
|
|
function holdAsync(id, fn) {
|
|
requireId(id, "organization id");
|
|
return withWriterRegistry(() => withLock(id, () => {
|
|
recover(id);
|
|
return fn(read(id));
|
|
}));
|
|
}
|
|
|
|
// The committed addition of this request and plan, if there is one. A prepared receipt whose
|
|
// `after` is the file's current hash counts: the rename landed and the commit line did not.
|
|
function findAddition(id, { request, plan_digest, project }) {
|
|
let text;
|
|
try {
|
|
text = regular(logFile(id));
|
|
} catch (e) {
|
|
if (e.code === "ENOENT") return null;
|
|
throw e;
|
|
}
|
|
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
|
|
const last = new Map();
|
|
for (const e of text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : []) last.set(e.transaction, e);
|
|
const hash = sha256(regular(businessFilePath(id, dir)));
|
|
for (const e of last.values()) {
|
|
const d = e.detail;
|
|
if (d?.verb !== ADDITION_VERB || d.request !== request || d.plan_digest !== plan_digest || d.project !== project) continue;
|
|
if (e.state === "committed" || (e.state === "prepared" && e.after === hash))
|
|
return { after: e.after, before: e.before, transaction: e.transaction };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
|
|
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
|
|
function record(id, { actor, detail }) {
|
|
requireId(actor, "authenticated actor");
|
|
requireId(id, "organization id");
|
|
return withLock(id, () => {
|
|
recover(id);
|
|
const hash = sha256(regular(businessFilePath(id, dir)));
|
|
const transaction = randomUUID();
|
|
log(id, {
|
|
transaction, actor, business: id, before: hash, after: hash, changes: [], detail,
|
|
at: new Date().toISOString(), state: "committed",
|
|
});
|
|
return { revision: hash, transaction };
|
|
});
|
|
}
|
|
|
|
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
|
}
|