Files
stack/packages/cli/src/notify-config.mjs
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
PR #1916 rule: a refusal names the field or file, never a config value
or a host path. readJsonFile's three refusals and parseJsonFileText now
name the file by its `what` label only. Callers whose label alone is
ambiguous add a name that already passed a strict pattern (business id,
role name, org id, turn id, project key) or a fixed path under the data
root. Exit codes, refusal codes and check order are unchanged.

organization: util, access, project, config-command, library-seats,
org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and
organization-config.mjs change only the readJsonFile label.
cli: access lock, accounts owner, host state/lock, notifier journal,
notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console.
discord: turn record label names the turn id.

Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each
changed refusal and fail on BASE; old path pins updated.
2026-11-11 01:04:15 -06:00

35 lines
1.7 KiB
JavaScript

import { lstatSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { BINDING_NAME } from "../../discord/src/binding.mjs";
import { CliError } from "./errors.mjs";
// The notifier's private config: `<dataRoot>/notify/<business>/notify.json`,
// 0600, `{"notifyVersion": 1, "binding": "<discord binding>" | null}`.
// Missing refuses, so a host never starts without someone deciding whether
// it DMs; null runs the host without a notifier. Refusals name the business
// and the path under the data root, never the host path (PR #1916).
export function readNotifyConfig(dataRoot, business) {
const file = join(dataRoot, "notify", business, "notify.json");
let st;
try {
st = lstatSync(file);
} catch {
throw new CliError(`no notifier config for ${business}: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/${business}/notify.json under the data root, mode 0600, or "binding": null to run without DMs`, 3);
}
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notifier config for ${business} must be a regular file, mode 0600, owned by this user`, 3);
}
let doc;
try {
doc = JSON.parse(readFileSync(file, "utf8"));
} catch {
throw new CliError(`notifier config for ${business} is not JSON`, 3);
}
const keys = doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc).sort().join(",") : "";
if (keys !== "binding,notifyVersion" || doc.notifyVersion !== 1 || (doc.binding !== null && !(typeof doc.binding === "string" && BINDING_NAME.test(doc.binding)))) {
throw new CliError(`notifier config for ${business} must be exactly {"notifyVersion": 1, "binding": <binding name> | null}`, 3);
}
return doc.binding;
}