// admins-write — single chokepoint for the dual-file admin write // (users.json device-level PIN auth + account.json account-level role). // // background: earlier the two writers — `admin-add` // (platform/plugins/admin/mcp/src/index.ts) and `set-pin` POST // (platform/ui/server/routes/onboarding.ts) — each performed the dual write // inline. They drifted: admin-add returned `is_error: true` with a per-leg // `[admin-auth-store]` line on partial failure; set-pin logged on stderr but // otherwise rolled forward. This module collapses both into one routed write // so the static check `grep -rnE 'admins\.push|config\.admins\s*=' platform/` // returns 0 outside this file (excluding tests, scripts, and the reader paths // which never push). // // Atomicity contract — single file: write-temp + rename, durable. // Atomicity contract — across files: NOT atomic. POSIX has no two-file // transaction. The helper writes users.json first, then account.json. On // account.json failure with users.json already written, the result names the // partial state. Callers MUST surface partial state to the operator (admin-add // returns is_error: true with the [admins-write] line; set-pin returns 500 // with the same). Same trust model as the [admin-auth-store] pair. import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, statSync, appendFileSync } from "node:fs"; import { dirname, join } from "node:path"; export interface UserEntry { userId: string; pin: string; // SHA-256 hash } /** * Identity + destination for one credential-store audit line. The writer is * pure (it only knows the file paths); the caller is the only party that knows * WHO is acting and WHERE the audit log lives, so it is threaded in. `actor` is * an admin userId or a non-user sentinel (`onboarding` | `install` | `boot` | * `unknown` when an MCP caller has no resolvable USER_ID); `session` is the * spawning agent session (SESSION_NODE_ID) when one drove the write. `logFile` * is the absolute path to the append-only users-audit log, distinct from * server.log. Mirrored in platform/lib/admin-access-password. */ export interface AuditContext { actor: string; session?: string; logFile: string; /** Task 1573 — the row being written, recorded on every credential write so * `actor != target` is the standing cross-identity signature. Omitted => * the line carries no target field (a self write not routed through the * identity-resolving tools). */ target?: string; /** Task 1573 — true when the caller passed an explicit `userId` (a deliberate * cross-identity or self write); false/absent for an implicit self write. */ explicitUserId?: boolean; } /** First 8 chars — the short-id convention used across the auth logs. */ function id8(value: string): string { return value.slice(0, 8); } /** Format a userId list as the comma-joined short-id set used in audit lines. * Exported for direct users.json writers that do not route through * writeAdminEntry (the MCP admin-update-pin path). */ export function formatAuditRowIds(userIds: string[]): string { return userIds.map(id8).join(","); } /** * Append one canonical `[users-audit]` line. Append failures never propagate — * an audit-write fault must not break the credential write it records; the * fault is surfaced once on stderr instead. */ function appendUsersAuditLine( audit: AuditContext, fields: { action: string; field: string; rowsBefore: string; rowsAfter: string }, ): void { const sess = audit.session ? ` session=${id8(audit.session)}` : ""; // userIds are uuids (shortened to id8); sentinels (boot|install|onboarding| // unknown) carry no dash and are written whole so attribution stays readable. const actor = audit.actor.includes("-") ? id8(audit.actor) : audit.actor; const targetFields = audit.target !== undefined ? ` target=${audit.target.includes("-") ? id8(audit.target) : audit.target}` + ` explicitUserId=${audit.explicitUserId ? "true" : "false"}` : ""; const line = `[users-audit] action=${fields.action} actor=${actor}${targetFields}${sess} ` + `field=${fields.field} rowsBefore=${fields.rowsBefore} rowsAfter=${fields.rowsAfter} ` + `ts=${new Date().toISOString()}\n`; try { mkdirSync(dirname(audit.logFile), { recursive: true, mode: 0o700 }); appendFileSync(audit.logFile, line, { mode: 0o600 }); } catch (err) { console.error( `[users-audit] op=write-failed logFile=${audit.logFile} error=${err instanceof Error ? err.message : String(err)}`, ); } } /** Public wrapper so a direct users.json writer that cannot route through * writeAdminEntry (the MCP admin-update-pin path) still emits the one * canonical `[users-audit]` line, keeping every write covered. */ export function logUsersAudit( audit: AuditContext, fields: { action: string; field: string; rowsBefore: string; rowsAfter: string }, ): void { appendUsersAuditLine(audit, fields); } export interface AdminEntry { userId: string; role: "owner" | "admin"; } export interface AdminWriteInput { userId: string; pin: string; // SHA-256 hash role: "owner" | "admin"; usersFile: string; // absolute path to users.json accountDir: string; // absolute path to account dir (containing account.json) caller: string; // identifier for the [admins-write] log line audit: AuditContext; // actor/session/logFile for the [users-audit] line } export interface AdminWriteResult { usersJsonResult: "ok" | "fail" | "noop"; accountJsonResult: "ok" | "fail" | "noop"; usersError?: string; accountError?: string; } function logLine(input: AdminWriteInput, result: AdminWriteResult): void { const userIdShort = input.userId.slice(0, 8); console.error( `[admins-write] caller=${input.caller} userId=${userIdShort} ` + `usersJsonResult=${result.usersJsonResult} accountJsonResult=${result.accountJsonResult}` + (result.usersError ? ` usersError=${result.usersError}` : "") + (result.accountError ? ` accountError=${result.accountError}` : ""), ); } function writeFileAtomic(filePath: string, contents: string, mode?: number): void { mkdirSync(dirname(filePath), { recursive: true }); const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; writeFileSync(tempPath, contents, mode !== undefined ? { mode } : undefined); renameSync(tempPath, filePath); } /** * Write a single admin entry across both auth stores (users.json + account.json admins[]). * * Idempotent on userId: if the userId already exists in users.json, the PIN * field is updated in place rather than duplicated; admins[] is checked for * presence before pushing. * * Always emits exactly one `[admins-write] caller=… userId= usersJsonResult=… accountJsonResult=…` * line — success and failure paths both fire it. Operators grep for this single * predicate when reconstructing a partial-write incident. */ export function writeAdminEntry(input: AdminWriteInput): AdminWriteResult { const result: AdminWriteResult = { usersJsonResult: "noop", accountJsonResult: "noop" }; // 1. users.json — read-modify-write atomically. try { let users: UserEntry[] = []; if (existsSync(input.usersFile)) { const raw = readFileSync(input.usersFile, "utf-8").trim(); if (raw) { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) { throw new Error("users.json is not an array"); } users = parsed as UserEntry[]; } } const rowsBefore = users.map(u => id8(u.userId)).join(","); const existing = users.findIndex(u => u.userId === input.userId); if (existing === -1) { users.push({ userId: input.userId, pin: input.pin }); } else { // Preserve sibling fields other writers may have set; overwrite pin. users[existing] = { ...users[existing], pin: input.pin }; } writeFileAtomic(input.usersFile, JSON.stringify(users, null, 2) + "\n", 0o600); result.usersJsonResult = "ok"; // A new userId is a row add; an existing userId is a pin rotation. appendUsersAuditLine(input.audit, { action: existing === -1 ? "add" : "set-pin", field: existing === -1 ? "row" : "pin", rowsBefore, rowsAfter: users.map(u => id8(u.userId)).join(","), }); } catch (err) { result.usersJsonResult = "fail"; result.usersError = err instanceof Error ? err.message : String(err); logLine(input, result); return result; } // 2. account.json — read-modify-write atomically. try { const accountJsonPath = join(input.accountDir, "account.json"); if (!existsSync(accountJsonPath)) { throw new Error(`account.json not found at ${accountJsonPath}`); } const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record; const admins = (config.admins ?? []) as AdminEntry[]; if (admins.some(a => a.userId === input.userId)) { result.accountJsonResult = "noop"; } else { admins.push({ userId: input.userId, role: input.role }); config.admins = admins; writeFileAtomic(accountJsonPath, JSON.stringify(config, null, 2) + "\n"); result.accountJsonResult = "ok"; } } catch (err) { result.accountJsonResult = "fail"; result.accountError = err instanceof Error ? err.message : String(err); } logLine(input, result); return result; } export interface AdminRemoveInput { userId: string; accountDir: string; caller: string; audit: AuditContext; // actor/session/logFile for the [users-audit] line } export interface AdminRemoveResult { accountJsonResult: "ok" | "fail" | "noop"; accountError?: string; } /** * Remove a single admin entry from account.json admins[] — does NOT touch * users.json (the device-level entry is preserved because the user may admin * other accounts). Single-file operation; the [admins-write] line records * `usersJsonResult=skip` so the grep-by-helper-call still picks it up. */ export function removeAdminFromAccount(input: AdminRemoveInput): AdminRemoveResult { const result: AdminRemoveResult = { accountJsonResult: "noop" }; const userIdShort = input.userId.slice(0, 8); try { const accountJsonPath = join(input.accountDir, "account.json"); if (!existsSync(accountJsonPath)) { throw new Error(`account.json not found at ${accountJsonPath}`); } const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record; const admins = (config.admins ?? []) as AdminEntry[]; const rowsBefore = admins.map(a => id8(a.userId)).join(","); const targetIndex = admins.findIndex(a => a.userId === input.userId); if (targetIndex === -1) { result.accountJsonResult = "noop"; } else { admins.splice(targetIndex, 1); config.admins = admins; writeFileAtomic(accountJsonPath, JSON.stringify(config, null, 2) + "\n"); result.accountJsonResult = "ok"; // Removal touches account.json admins[] only; the row-set is that store's. appendUsersAuditLine(input.audit, { action: "remove", field: "row", rowsBefore, rowsAfter: admins.map(a => id8(a.userId)).join(","), }); } } catch (err) { result.accountJsonResult = "fail"; result.accountError = err instanceof Error ? err.message : String(err); } console.error( `[admins-write] caller=${input.caller} userId=${userIdShort} ` + `usersJsonResult=skip accountJsonResult=${result.accountJsonResult}` + (result.accountError ? ` accountError=${result.accountError}` : ""), ); return result; } export interface InvariantCheckInput { /** Absolute path to users.json (persistent location). */ usersFile: string; /** Absolute path to the accounts root (e.g. /data/accounts). */ accountsDir: string; /** Tag for the log prefix — `install-invariant` or `admin-invariant`. */ tag: "install-invariant" | "admin-invariant"; } export interface InvariantCheckResult { divergences: number; /** Userids in account.json admins[] that have no row in users.json. */ accountWithoutUsers: Array<{ userId: string; source: string }>; /** Userids in users.json that no account.json admins[] references. */ usersWithoutAccount: Array<{ userId: string }>; } /** Pure divergence computation — read faults collected, never logged. */ export interface DivergenceResult { divergences: number; /** Userids in account.json admins[] that have no row in users.json. */ accountWithoutUsers: Array<{ userId: string; source: string }>; /** Userids in users.json that no account.json admins[] references. */ usersWithoutAccount: Array<{ userId: string }>; /** Read faults encountered during the walk (unreadable users/account file). */ errors: Array<{ source: string; detail: string }>; } /** * Walk every account.json under accountsDir and compare its admins[] to * users.json. PURE: returns the divergence sets and any read faults; does no * logging and never throws. Shared by `checkAdminAuthInvariant` (the * `[admin-invariant]` boot one-shot) and the `[users-reconcile]` standing tick * so the two never disagree on the predicate. An absent users.json or accounts * dir is a legitimate first-boot state → empty set, not a fault. */ export function computeAdminStoreDivergence(input: { usersFile: string; accountsDir: string }): DivergenceResult { const result: DivergenceResult = { divergences: 0, accountWithoutUsers: [], usersWithoutAccount: [], errors: [] }; // Read users.json userIds (persistent file). Absent file == empty set. const usersUserIds = new Set(); if (existsSync(input.usersFile)) { try { const raw = readFileSync(input.usersFile, "utf-8").trim(); if (raw) { const users = JSON.parse(raw) as UserEntry[]; for (const u of users) { if (typeof u.userId === "string") usersUserIds.add(u.userId); } } } catch (err) { result.errors.push({ source: input.usersFile, detail: err instanceof Error ? err.message : String(err) }); } } // Walk account dirs. Skip dotfiles (.trash/) and missing account.json. const accountUserIds = new Set(); if (existsSync(input.accountsDir)) { let entries: string[]; try { entries = readdirSync(input.accountsDir); } catch (err) { result.errors.push({ source: input.accountsDir, detail: err instanceof Error ? err.message : String(err) }); return result; } for (const entry of entries) { if (entry.startsWith(".")) continue; const accountDir = join(input.accountsDir, entry); try { if (!statSync(accountDir).isDirectory()) continue; } catch { continue; } const accountJsonPath = join(accountDir, "account.json"); if (!existsSync(accountJsonPath)) continue; let admins: AdminEntry[] = []; try { const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record; admins = (config.admins ?? []) as AdminEntry[]; } catch (err) { result.errors.push({ source: accountJsonPath, detail: err instanceof Error ? err.message : String(err) }); continue; } for (const a of admins) { if (typeof a.userId !== "string") continue; accountUserIds.add(a.userId); if (!usersUserIds.has(a.userId)) { result.accountWithoutUsers.push({ userId: a.userId, source: accountJsonPath }); result.divergences += 1; } } } } for (const uid of usersUserIds) { if (!accountUserIds.has(uid)) { result.usersWithoutAccount.push({ userId: uid }); result.divergences += 1; } } return result; } /** * `[admin-invariant]` / `[install-invariant]` boot one-shot. Wraps the pure * `computeAdminStoreDivergence` and emits the documented grep lines: * `direction=…`, the per-fault unreadable line, and the always-emitted * `check complete divergences=` summary. Log-only — does NOT throw, does NOT * refuse boot or install. The recurring standing check is the separate * `[users-reconcile]` tick in the admin server boot. */ export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCheckResult { const d = computeAdminStoreDivergence({ usersFile: input.usersFile, accountsDir: input.accountsDir }); for (const e of d.errors) { console.error(`[${input.tag}] unreadable source=${e.source} error=${e.detail}`); } for (const a of d.accountWithoutUsers) { console.error(`[${input.tag}] direction=account-without-users userId=${a.userId.slice(0, 8)} source=${a.source}`); } for (const u of d.usersWithoutAccount) { console.error(`[${input.tag}] direction=users-without-account userId=${u.userId.slice(0, 8)} source=${input.usersFile}`); } console.error(`[${input.tag}] check complete divergences=${d.divergences}`); return { divergences: d.divergences, accountWithoutUsers: d.accountWithoutUsers, usersWithoutAccount: d.usersWithoutAccount, }; }