/** * account-enumeration — single source of truth for "which accountIds are * provisioned on disk for this install?". * * Doctrine — `.docs/neo4j.md` "Account isolation invariant": every writer * that stamps `n.accountId` must verify the value against * `${DATA_ROOT}/accounts//account.json` before write. Read-side scoping * (`WHERE n.accountId = $accountId`) silently hides leaks from every UI; * an unenforced writer can leak indefinitely with zero downstream symptom. * * Consumers of the enumerated set: * - The cross-account resolver's injected validity check (the `memory` and * `filesystem` MCP plugins) — the check that gates a `targetAccountId`. * These call `isProvisionedAccount` (re-scan-on-miss), not the cached list * directly, so a sub-account created at runtime is not masked by the boot * snapshot (Task 1471). * - `resolvePlatformAccountId` (platform/ui/app/lib/whatsapp/platform-account-id.ts) * — WhatsApp writer-side helper; loud-throws on zero or multi accounts. * - Boot-time env-vs-disk validation (`validateAccountIdEnv`). * * NOTE: `writeNodeWithEdges` (platform/lib/graph-write/) does NOT enumerate. * Its account floor compares `props.accountId` against `process.env.ACCOUNT_ID` * (the spawning process's boot-validated id), deliberately staying out of the * `node:fs` import chain so the ESM payload bundle emits no `__require("fs")` * shim. So the write gate does not share the cache's staleness. * * Per-process cache keyed on `accountsDir`. The install-time invariant (accounts * created only at install) no longer holds: the runtime `account_create` * lifecycle tool provisions sub-accounts during a live session. `enumerate…` * keeps its cached-list semantics for the hit path; `isProvisionedAccount` * re-scans on a miss so a created-after-boot account resolves without restart. */ import { readdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const cache = new Map(); /** * Returns the list of valid account UUIDs found under `accountsDir`. A dir is * "valid" iff its name is a UUID AND it contains a parseable `account.json`. * Corruption-discipline: a present-but-unparseable account.json EXCLUDES the * dir (over-prune one suspect account beats under-prune the leak it might * be hiding). */ export function enumerateValidAccountIds(accountsDir: string): string[] { const cached = cache.get(accountsDir); if (cached !== undefined) return cached; let names: string[]; try { names = readdirSync(accountsDir); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { cache.set(accountsDir, []); return []; } throw err; } const valid: string[] = []; for (const name of names) { if (!UUID_RE.test(name)) continue; const configPath = resolve(accountsDir, name, "account.json"); try { JSON.parse(readFileSync(configPath, "utf-8")); valid.push(name); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") continue; // SyntaxError (parse failure) and other read errors fall through: // exclude this dir, try the next. } } cache.set(accountsDir, valid); return valid; } /** * Membership test with re-scan-on-miss: is `id` a currently-provisioned account * under `accountsDir`? * * Hit path — `id` is in the cached set — returns true with no I/O (the common * case; the cache still serves it). Miss path — `id` is absent from the cached * set, or the cache is cold — drops the stale entry and re-scans `accountsDir` * once (refreshing the cache) before answering. * * This closes the created-after-boot gap (Task 1471): a sub-account provisioned * by the runtime `account_create` lifecycle tool after a consumer process booted * is invisible to that process's boot-time cached set. Without the re-scan, the * cross-account resolver rejects the id as not-provisioned even though its dir * and `account.json` exist on disk. A single `readdirSync` on the miss finds it; * a genuinely-absent id pays the same one scan and correctly returns false. */ export function isProvisionedAccount(accountsDir: string, id: string): boolean { const cached = cache.get(accountsDir); if (cached?.includes(id)) return true; // Miss: `id` is not in the cached set (or the cache is cold). Drop the stale // snapshot and re-scan disk once so a real, on-disk account is not masked. cache.delete(accountsDir); return enumerateValidAccountIds(accountsDir).includes(id); } /** * Returns the single `role:"house"` account UUID under `accountsDir`. * * The registry is the enumerated set of valid account dirs (see * `enumerateValidAccountIds`); among them, exactly one must carry * `role:"house"`. Throws on zero-house or multiple-house — both are drift the * managed-service invariant forbids (`houses=` in the message is the failure * signature operators grep for). * * Deliberately NOT cached: house designation can change at runtime when the * operator provisions/archives accounts via the lifecycle tools, so the role * lookup reads `account.json` fresh each call. (`enumerateValidAccountIds` is * still cached for the dir-validity scan; that set is stable for the process.) * * Legacy tolerance is intentionally NOT here: a pre-migration account with no * `role` does not count as house. `setup-account.sh` stamps `role:"house"` on a * sole unlabelled account at seed time, so by manager boot the invariant holds. */ export function resolveHouseAccountId(accountsDir: string): string { const valid = enumerateValidAccountIds(accountsDir); const houses: string[] = []; for (const id of valid) { const configPath = resolve(accountsDir, id, "account.json"); try { const cfg = JSON.parse(readFileSync(configPath, "utf-8")) as { role?: unknown; }; if (cfg.role === "house") houses.push(id); } catch { // already excluded from `valid` if unparseable; a race here just skips it } } if (houses.length !== 1) { throw new Error( `[account-registry] resolveHouseAccountId: houses=${houses.length} under ${accountsDir} ` + `(${houses.join(", ") || "none"}) — exactly one role:"house" account is required. ` + `Loud-fail rather than picking one silently.`, ); } return houses[0]; } /** * Like `resolveHouseAccountId`, but tolerant of the pre-migration window: when * no account carries `role:"house"` AND exactly one valid account exists, that * sole account is returned (it is about to be stamped `role:"house"` by * `setup-account.sh`). This is the resolver every "the install's account" call * site uses (boot account dir, installer account id, channel persist), so a * legacy single-account install never throws while waiting for the seed-time * role stamp — the WhatsApp persist path in particular must not drop a customer * message in that window. * * Still loud-fails on genuine drift: zero accounts, multiple houses, or * zero-house with more than one account (`houses= total=` signature). */ export function resolveHouseOrSoleAccountId(accountsDir: string): string { const valid = enumerateValidAccountIds(accountsDir); const houses: string[] = []; for (const id of valid) { const configPath = resolve(accountsDir, id, "account.json"); try { const cfg = JSON.parse(readFileSync(configPath, "utf-8")) as { role?: unknown; }; if (cfg.role === "house") houses.push(id); } catch { // already excluded from `valid` if unparseable } } if (houses.length === 1) return houses[0]; if (houses.length === 0 && valid.length === 1) return valid[0]; throw new Error( `[account-registry] resolveHouseOrSoleAccountId: houses=${houses.length} total=${valid.length} ` + `under ${accountsDir} — expected exactly one role:"house" (or a single unlabelled account). ` + `Loud-fail rather than picking one silently.`, ); } /** * Resolves the canonical accounts dir from `MAXY_PLATFORM_ROOT`. Mirrors the * pattern at platform/ui/app/lib/claude-agent/account.ts:9 * (`ACCOUNTS_DIR = resolve(PLATFORM_ROOT, "..", "data/accounts")`). * * Loud-throws when the env var is unset — graph-write's gate must not fall * open silently. */ export function getAccountsDirFromEnv(): string { const root = process.env.MAXY_PLATFORM_ROOT; if (!root) { throw new Error( "[graph-write] MAXY_PLATFORM_ROOT not set — cannot enforce accountId gate. " + "Set MAXY_PLATFORM_ROOT in the spawning process or pass `accountsDir` explicitly.", ); } return resolve(root, "..", "data/accounts"); } /** * Test-only cache reset. Production callers must not invoke this — the cache * is a deliberate boot-time invariant (see module doc). */ export function _resetEnumerationCache(): void { cache.clear(); } /** * boot-time env-vs-disk validator. Compares `process.env.ACCOUNT_ID` * (stamped by the brand systemd unit's `Environment=ACCOUNT_ID=` line) against * the on-disk account set returned by `enumerateValidAccountIds`. The Hono * server calls this once at boot before binding the listener; on FATAL it * emits a structured `[graph-health] account-id-env FATAL` line and exits 1 * so systemd's restart loop surfaces the misconfiguration in journalctl. * * Pure function — no I/O, no env reads, no exits — caller passes both inputs. * Reasons enumerate the four observable boot states (success + three failures) * with no fallback path; the writeNodeWithEdges gate cannot trust an env that * does not match disk, and silently degrading would re-create the silent-leak * class the gate exists to close (`.docs/neo4j.md` "Account isolation invariant"). */ export type AccountIdEnvValidationFailureReason = | "missing" | "no-on-disk-account" | "mismatch"; export type AccountIdEnvValidation = | { ok: true; envId: string; diskIds: string[] } | { ok: false; reason: AccountIdEnvValidationFailureReason; envId: string | null; diskIds: string[]; }; export function validateAccountIdEnv( envValue: string | undefined, diskIds: string[], ): AccountIdEnvValidation { if (!envValue) { return { ok: false, reason: "missing", envId: null, diskIds }; } if (diskIds.length === 0) { return { ok: false, reason: "no-on-disk-account", envId: envValue, diskIds }; } if (!diskIds.includes(envValue)) { return { ok: false, reason: "mismatch", envId: envValue, diskIds }; } return { ok: true, envId: envValue, diskIds }; }