Files
stack/packages/business/src/plugin-settings.mjs
T
jason.woltjeandClaude Opus 5.5 fba879f6a1 mosaic plugins set: a patch changes only the kinds it names; null removes a kind (vikunja:19/309, row 145)
Jason-approved merge, rebased: git diff f4d7b91a9..0e7d1d8b8 from
work/vikunja-19-309, stacked on 305. Business reviewer verdict dfd44af2
approve on 0e7d1d8b8; Jason resolved merge decision 12a20e47 via the CLI.

No conflict. packages/plugins/README.md, which refactor also changed,
merged with the source commit's +/- lines; the other four paths are the
source blobs. Filbert reviews the rebased diff.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:50:59 -05:00

66 lines
3.6 KiB
JavaScript

// Host setter for the organization file's `plugins` switch (REQ-PLUG-1,
// REQ-ROLE-3). It is the only writer of that key: no broker verb, mosaic
// command, Settings form or chief-of-staff write reaches it (sage-0301).
// Writes go through the organization writer, which logs old and new names
// like a limit change. A write is a patch per kind (see set()): kinds it doesn't name are kept. A refused write throws a code and logs nothing.
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { isPlainObject } from "./util.mjs";
import { KINDS, PluginError, checkPlugins } from "../../plugins/src/kinds.mjs";
// `exists(kind, name)`, when given, refuses a name no module answers to (the host passes the
// loader's hasModule). Without it the setter checks only the shape, as before.
export function pluginSettings({ dir, rolesDir, exists = null }) {
const writer = organizationWriter({ dir, rolesDir });
function read(id) {
const { document, revision } = writer.read(id);
return { business: id, revision, plugins: structuredClone(document.plugins ?? {}) };
}
function set(id, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => key !== "revision" && key !== "plugins")) {
throw new OrganizationError("invalid-request");
}
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
throw new OrganizationError("invalid-request");
}
// A Date or a Map has no own keys and would pass checkPlugins, then serialize
// as something else (Filbert F1, sage-0314). Only a JSON-shaped object passes.
const shaped = args.plugins !== null && typeof args.plugins === "object";
const proto = shaped ? Object.getPrototypeOf(args.plugins) : undefined;
if (proto !== Object.prototype && proto !== null) {
throw new OrganizationError("invalid-plugins", "plugins must be a plain object with one key per kind");
}
// The patch names the kinds it changes (vikunja:19/309): a kind with a value is switched to it,
// a kind with null is removed, and every kind it doesn't name keeps its setting. An empty patch
// changes nothing and is refused rather than written as a no-op.
const patch = Object.entries(args.plugins);
if (patch.length === 0) throw new OrganizationError("invalid-plugins", "the patch names no kind; to remove a kind give it null");
const removals = patch.filter(([, setting]) => setting === null).map(([kind]) => kind);
const sets = Object.fromEntries(patch.filter(([, setting]) => setting !== null));
for (const kind of removals) {
if (!Object.hasOwn(KINDS, kind)) throw new OrganizationError("invalid-plugins", `plugins names unknown kind ${JSON.stringify(kind)}; adding a kind is a core change`);
}
try {
checkPlugins(sets, "plugins");
} catch (e) {
if (e instanceof PluginError) throw new OrganizationError("invalid-plugins", e.message);
throw e;
}
if (exists) {
for (const [kind, setting] of Object.entries(sets)) {
for (const name of Array.isArray(setting) ? setting : [setting]) {
if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`);
}
}
}
const next = structuredClone(writer.read(id).document);
next.plugins = { ...structuredClone(next.plugins ?? {}), ...structuredClone(sets) };
for (const kind of removals) delete next.plugins[kind];
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...read(id), transaction: receipt.transaction };
}
return { writer, read, set };
}