@rubytech/create-maxy-code 0.1.353 → 0.1.355

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/__tests__/preflight-port-classifier.test.js +52 -0
  2. package/dist/preflight-port-classifier.js +31 -2
  3. package/package.json +1 -1
  4. package/payload/platform/lib/admin-access-password/__tests__/index.test.ts +96 -14
  5. package/payload/platform/lib/admin-access-password/dist/index.d.ts +20 -4
  6. package/payload/platform/lib/admin-access-password/dist/index.d.ts.map +1 -1
  7. package/payload/platform/lib/admin-access-password/dist/index.js +43 -4
  8. package/payload/platform/lib/admin-access-password/dist/index.js.map +1 -1
  9. package/payload/platform/lib/admin-access-password/src/index.ts +67 -4
  10. package/payload/platform/lib/admins-write/__tests__/index.test.ts +182 -0
  11. package/payload/platform/lib/admins-write/dist/index.d.ts +65 -11
  12. package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -1
  13. package/payload/platform/lib/admins-write/dist/index.js +93 -31
  14. package/payload/platform/lib/admins-write/dist/index.js.map +1 -1
  15. package/payload/platform/lib/admins-write/src/index.ts +133 -36
  16. package/payload/platform/plugins/admin/PLUGIN.md +4 -0
  17. package/payload/platform/plugins/admin/mcp/dist/index.js +15 -3
  18. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  19. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +16 -1
  20. package/payload/platform/plugins/docs/references/admin-session.md +1 -1
  21. package/payload/platform/plugins/docs/references/troubleshooting.md +15 -0
  22. package/payload/platform/scripts/check-no-esm-require.mjs +4 -0
  23. package/payload/server/{chunk-QAKVFSEJ.js → chunk-7GNXBVGY.js} +38 -11
  24. package/payload/server/maxy-edge.js +1 -1
  25. package/payload/server/public/assets/AdminLoginScreens-4I5CdIWl.js +1 -0
  26. package/payload/server/public/assets/{AdminShell-CC-CuN6Y.js → AdminShell-Bc9F7X_N.js} +1 -1
  27. package/payload/server/public/assets/{Checkbox-sQBc9xSy.js → Checkbox-DoQV18Ow.js} +1 -1
  28. package/payload/server/public/assets/OperatorConversations-BJY9FZbr.js +9 -0
  29. package/payload/server/public/assets/OperatorConversations-C1I78hON.css +1 -0
  30. package/payload/server/public/assets/{admin-qW6jM6l0.js → admin-DLsMA09F.js} +1 -1
  31. package/payload/server/public/assets/{browser-BsW6KaxZ.js → browser-w6rY_KAO.js} +1 -1
  32. package/payload/server/public/assets/{calendar-BPAiN1cL.js → calendar-CvEdDFYd.js} +1 -1
  33. package/payload/server/public/assets/chat-BlOWMUVI.js +1 -0
  34. package/payload/server/public/assets/{data-D_cR_NCZ.js → data-p3T_EZFq.js} +1 -1
  35. package/payload/server/public/assets/{graph-CmpAy2WP.js → graph-BrwfYOr3.js} +1 -1
  36. package/payload/server/public/assets/{graph-labels-C0SLq_VP.js → graph-labels-BNZ8u8TE.js} +1 -1
  37. package/payload/server/public/assets/{operator-CrjtJcEY.js → operator-BNY1P_mH.js} +1 -1
  38. package/payload/server/public/assets/{page-Cf3fVDSo.js → page-CtygoIXt.js} +3 -3
  39. package/payload/server/public/assets/{public-BkNtvSYp.js → public-CDzw6yjH.js} +1 -1
  40. package/payload/server/public/browser.html +4 -4
  41. package/payload/server/public/calendar.html +4 -4
  42. package/payload/server/public/chat.html +5 -5
  43. package/payload/server/public/data.html +4 -4
  44. package/payload/server/public/graph.html +6 -6
  45. package/payload/server/public/index.html +6 -6
  46. package/payload/server/public/operator.html +7 -7
  47. package/payload/server/public/public.html +5 -5
  48. package/payload/server/server.js +1289 -1150
  49. package/payload/server/public/assets/AdminLoginScreens-CzdOQKyO.js +0 -1
  50. package/payload/server/public/assets/OperatorConversations-CA_2c3sp.js +0 -9
  51. package/payload/server/public/assets/OperatorConversations-DPVKGsRS.css +0 -1
  52. package/payload/server/public/assets/chat-DVD9yoVL.js +0 -1
@@ -19,7 +19,7 @@
19
19
  // returns is_error: true with the [admins-write] line; set-pin returns 500
20
20
  // with the same). Same trust model as the [admin-auth-store] pair.
21
21
 
22
- import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, statSync } from "node:fs";
22
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, statSync, appendFileSync } from "node:fs";
23
23
  import { dirname, join } from "node:path";
24
24
 
25
25
  export interface UserEntry {
@@ -27,6 +27,71 @@ export interface UserEntry {
27
27
  pin: string; // SHA-256 hash
28
28
  }
29
29
 
30
+ /**
31
+ * Identity + destination for one credential-store audit line. The writer is
32
+ * pure (it only knows the file paths); the caller is the only party that knows
33
+ * WHO is acting and WHERE the audit log lives, so it is threaded in. `actor` is
34
+ * an admin userId or a non-user sentinel (`onboarding` | `install` | `boot` |
35
+ * `unknown` when an MCP caller has no resolvable USER_ID); `session` is the
36
+ * spawning agent session (SESSION_NODE_ID) when one drove the write. `logFile`
37
+ * is the absolute path to the append-only users-audit log, distinct from
38
+ * server.log. Mirrored in platform/lib/admin-access-password.
39
+ */
40
+ export interface AuditContext {
41
+ actor: string;
42
+ session?: string;
43
+ logFile: string;
44
+ }
45
+
46
+ /** First 8 chars — the short-id convention used across the auth logs. */
47
+ function id8(value: string): string {
48
+ return value.slice(0, 8);
49
+ }
50
+
51
+ /** Format a userId list as the comma-joined short-id set used in audit lines.
52
+ * Exported for direct users.json writers that do not route through
53
+ * writeAdminEntry (the MCP admin-update-pin path). */
54
+ export function formatAuditRowIds(userIds: string[]): string {
55
+ return userIds.map(id8).join(",");
56
+ }
57
+
58
+ /**
59
+ * Append one canonical `[users-audit]` line. Append failures never propagate —
60
+ * an audit-write fault must not break the credential write it records; the
61
+ * fault is surfaced once on stderr instead.
62
+ */
63
+ function appendUsersAuditLine(
64
+ audit: AuditContext,
65
+ fields: { action: string; field: string; rowsBefore: string; rowsAfter: string },
66
+ ): void {
67
+ const sess = audit.session ? ` session=${id8(audit.session)}` : "";
68
+ // userIds are uuids (shortened to id8); sentinels (boot|install|onboarding|
69
+ // unknown) carry no dash and are written whole so attribution stays readable.
70
+ const actor = audit.actor.includes("-") ? id8(audit.actor) : audit.actor;
71
+ const line =
72
+ `[users-audit] action=${fields.action} actor=${actor}${sess} ` +
73
+ `field=${fields.field} rowsBefore=${fields.rowsBefore} rowsAfter=${fields.rowsAfter} ` +
74
+ `ts=${new Date().toISOString()}\n`;
75
+ try {
76
+ mkdirSync(dirname(audit.logFile), { recursive: true, mode: 0o700 });
77
+ appendFileSync(audit.logFile, line, { mode: 0o600 });
78
+ } catch (err) {
79
+ console.error(
80
+ `[users-audit] op=write-failed logFile=${audit.logFile} error=${err instanceof Error ? err.message : String(err)}`,
81
+ );
82
+ }
83
+ }
84
+
85
+ /** Public wrapper so a direct users.json writer that cannot route through
86
+ * writeAdminEntry (the MCP admin-update-pin path) still emits the one
87
+ * canonical `[users-audit]` line, keeping every write covered. */
88
+ export function logUsersAudit(
89
+ audit: AuditContext,
90
+ fields: { action: string; field: string; rowsBefore: string; rowsAfter: string },
91
+ ): void {
92
+ appendUsersAuditLine(audit, fields);
93
+ }
94
+
30
95
  export interface AdminEntry {
31
96
  userId: string;
32
97
  role: "owner" | "admin";
@@ -39,6 +104,7 @@ export interface AdminWriteInput {
39
104
  usersFile: string; // absolute path to users.json
40
105
  accountDir: string; // absolute path to account dir (containing account.json)
41
106
  caller: string; // identifier for the [admins-write] log line
107
+ audit: AuditContext; // actor/session/logFile for the [users-audit] line
42
108
  }
43
109
 
44
110
  export interface AdminWriteResult {
@@ -93,6 +159,7 @@ export function writeAdminEntry(input: AdminWriteInput): AdminWriteResult {
93
159
  }
94
160
  }
95
161
 
162
+ const rowsBefore = users.map(u => id8(u.userId)).join(",");
96
163
  const existing = users.findIndex(u => u.userId === input.userId);
97
164
  if (existing === -1) {
98
165
  users.push({ userId: input.userId, pin: input.pin });
@@ -103,6 +170,13 @@ export function writeAdminEntry(input: AdminWriteInput): AdminWriteResult {
103
170
 
104
171
  writeFileAtomic(input.usersFile, JSON.stringify(users, null, 2) + "\n", 0o600);
105
172
  result.usersJsonResult = "ok";
173
+ // A new userId is a row add; an existing userId is a pin rotation.
174
+ appendUsersAuditLine(input.audit, {
175
+ action: existing === -1 ? "add" : "set-pin",
176
+ field: existing === -1 ? "row" : "pin",
177
+ rowsBefore,
178
+ rowsAfter: users.map(u => id8(u.userId)).join(","),
179
+ });
106
180
  } catch (err) {
107
181
  result.usersJsonResult = "fail";
108
182
  result.usersError = err instanceof Error ? err.message : String(err);
@@ -139,6 +213,7 @@ export interface AdminRemoveInput {
139
213
  userId: string;
140
214
  accountDir: string;
141
215
  caller: string;
216
+ audit: AuditContext; // actor/session/logFile for the [users-audit] line
142
217
  }
143
218
 
144
219
  export interface AdminRemoveResult {
@@ -163,6 +238,7 @@ export function removeAdminFromAccount(input: AdminRemoveInput): AdminRemoveResu
163
238
  }
164
239
  const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record<string, unknown>;
165
240
  const admins = (config.admins ?? []) as AdminEntry[];
241
+ const rowsBefore = admins.map(a => id8(a.userId)).join(",");
166
242
  const targetIndex = admins.findIndex(a => a.userId === input.userId);
167
243
  if (targetIndex === -1) {
168
244
  result.accountJsonResult = "noop";
@@ -171,6 +247,13 @@ export function removeAdminFromAccount(input: AdminRemoveInput): AdminRemoveResu
171
247
  config.admins = admins;
172
248
  writeFileAtomic(accountJsonPath, JSON.stringify(config, null, 2) + "\n");
173
249
  result.accountJsonResult = "ok";
250
+ // Removal touches account.json admins[] only; the row-set is that store's.
251
+ appendUsersAuditLine(input.audit, {
252
+ action: "remove",
253
+ field: "row",
254
+ rowsBefore,
255
+ rowsAfter: admins.map(a => id8(a.userId)).join(","),
256
+ });
174
257
  }
175
258
  } catch (err) {
176
259
  result.accountJsonResult = "fail";
@@ -202,29 +285,29 @@ export interface InvariantCheckResult {
202
285
  usersWithoutAccount: Array<{ userId: string }>;
203
286
  }
204
287
 
288
+ /** Pure divergence computation — read faults collected, never logged. */
289
+ export interface DivergenceResult {
290
+ divergences: number;
291
+ /** Userids in account.json admins[] that have no row in users.json. */
292
+ accountWithoutUsers: Array<{ userId: string; source: string }>;
293
+ /** Userids in users.json that no account.json admins[] references. */
294
+ usersWithoutAccount: Array<{ userId: string }>;
295
+ /** Read faults encountered during the walk (unreadable users/account file). */
296
+ errors: Array<{ source: string; detail: string }>;
297
+ }
298
+
205
299
  /**
206
300
  * Walk every account.json under accountsDir and compare its admins[] to
207
- * users.json. Emits one log line per divergence and one summary line.
208
- *
209
- * Behaviour: log-only does NOT throw, does NOT refuse boot or install.
210
- * Pre-Task-904 installs that already diverged would otherwise brick on the
211
- * first boot of a Task-904 device. The summary line `divergences=<n>` is
212
- * always emitted (wired-up assertion); per-divergence lines fire only when
213
- * divergences > 0. Operators grep `[install-invariant]` or `[admin-invariant]`
214
- * to detect the regression class without reading every install log.
215
- *
216
- * Promotion to refuse-or-degrade is a future sprint; capture as a TODO once
217
- * the deployed fleet has been audited clean.
301
+ * users.json. PURE: returns the divergence sets and any read faults; does no
302
+ * logging and never throws. Shared by `checkAdminAuthInvariant` (the
303
+ * `[admin-invariant]` boot one-shot) and the `[users-reconcile]` standing tick
304
+ * so the two never disagree on the predicate. An absent users.json or accounts
305
+ * dir is a legitimate first-boot state empty set, not a fault.
218
306
  */
219
- export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCheckResult {
220
- const result: InvariantCheckResult = {
221
- divergences: 0,
222
- accountWithoutUsers: [],
223
- usersWithoutAccount: [],
224
- };
307
+ export function computeAdminStoreDivergence(input: { usersFile: string; accountsDir: string }): DivergenceResult {
308
+ const result: DivergenceResult = { divergences: 0, accountWithoutUsers: [], usersWithoutAccount: [], errors: [] };
225
309
 
226
- // Read users.json userIds (persistent file). Absent file == empty set; do
227
- // not abort — first-boot before set-pin has run is a legitimate state.
310
+ // Read users.json userIds (persistent file). Absent file == empty set.
228
311
  const usersUserIds = new Set<string>();
229
312
  if (existsSync(input.usersFile)) {
230
313
  try {
@@ -236,8 +319,7 @@ export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCh
236
319
  }
237
320
  }
238
321
  } catch (err) {
239
- const msg = err instanceof Error ? err.message : String(err);
240
- console.error(`[${input.tag}] users.json unreadable usersFile=${input.usersFile} error=${msg}`);
322
+ result.errors.push({ source: input.usersFile, detail: err instanceof Error ? err.message : String(err) });
241
323
  }
242
324
  }
243
325
 
@@ -248,9 +330,7 @@ export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCh
248
330
  try {
249
331
  entries = readdirSync(input.accountsDir);
250
332
  } catch (err) {
251
- const msg = err instanceof Error ? err.message : String(err);
252
- console.error(`[${input.tag}] accounts-dir unreadable accountsDir=${input.accountsDir} error=${msg}`);
253
- console.error(`[${input.tag}] check complete divergences=0 (accounts-dir unreadable)`);
333
+ result.errors.push({ source: input.accountsDir, detail: err instanceof Error ? err.message : String(err) });
254
334
  return result;
255
335
  }
256
336
 
@@ -269,8 +349,7 @@ export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCh
269
349
  const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record<string, unknown>;
270
350
  admins = (config.admins ?? []) as AdminEntry[];
271
351
  } catch (err) {
272
- const msg = err instanceof Error ? err.message : String(err);
273
- console.error(`[${input.tag}] account.json unreadable source=${accountJsonPath} error=${msg}`);
352
+ result.errors.push({ source: accountJsonPath, detail: err instanceof Error ? err.message : String(err) });
274
353
  continue;
275
354
  }
276
355
 
@@ -278,10 +357,6 @@ export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCh
278
357
  if (typeof a.userId !== "string") continue;
279
358
  accountUserIds.add(a.userId);
280
359
  if (!usersUserIds.has(a.userId)) {
281
- const userIdShort = a.userId.slice(0, 8);
282
- console.error(
283
- `[${input.tag}] direction=account-without-users userId=${userIdShort} source=${accountJsonPath}`,
284
- );
285
360
  result.accountWithoutUsers.push({ userId: a.userId, source: accountJsonPath });
286
361
  result.divergences += 1;
287
362
  }
@@ -291,15 +366,37 @@ export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCh
291
366
 
292
367
  for (const uid of usersUserIds) {
293
368
  if (!accountUserIds.has(uid)) {
294
- const userIdShort = uid.slice(0, 8);
295
- console.error(
296
- `[${input.tag}] direction=users-without-account userId=${userIdShort} source=${input.usersFile}`,
297
- );
298
369
  result.usersWithoutAccount.push({ userId: uid });
299
370
  result.divergences += 1;
300
371
  }
301
372
  }
302
373
 
303
- console.error(`[${input.tag}] check complete divergences=${result.divergences}`);
304
374
  return result;
305
375
  }
376
+
377
+ /**
378
+ * `[admin-invariant]` / `[install-invariant]` boot one-shot. Wraps the pure
379
+ * `computeAdminStoreDivergence` and emits the documented grep lines:
380
+ * `direction=…`, the per-fault unreadable line, and the always-emitted
381
+ * `check complete divergences=<n>` summary. Log-only — does NOT throw, does NOT
382
+ * refuse boot or install. The recurring standing check is the separate
383
+ * `[users-reconcile]` tick in the admin server boot.
384
+ */
385
+ export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCheckResult {
386
+ const d = computeAdminStoreDivergence({ usersFile: input.usersFile, accountsDir: input.accountsDir });
387
+ for (const e of d.errors) {
388
+ console.error(`[${input.tag}] unreadable source=${e.source} error=${e.detail}`);
389
+ }
390
+ for (const a of d.accountWithoutUsers) {
391
+ console.error(`[${input.tag}] direction=account-without-users userId=${a.userId.slice(0, 8)} source=${a.source}`);
392
+ }
393
+ for (const u of d.usersWithoutAccount) {
394
+ console.error(`[${input.tag}] direction=users-without-account userId=${u.userId.slice(0, 8)} source=${input.usersFile}`);
395
+ }
396
+ console.error(`[${input.tag}] check complete divergences=${d.divergences}`);
397
+ return {
398
+ divergences: d.divergences,
399
+ accountWithoutUsers: d.accountWithoutUsers,
400
+ usersWithoutAccount: d.usersWithoutAccount,
401
+ };
402
+ }
@@ -126,6 +126,10 @@ Tools are available via the `admin` MCP server.
126
126
 
127
127
  **Three-store admin auth invariant.** `admin-add` writes to all three identity stores (`users.json` PIN auth at the persistent `~/{configDir}/users.json` location , `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines plus the `[admins-write]` chokepoint line, and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same `[admin-auth-store]` line. **Single chokepoint:** every `users.json` + `account.json admins[]` mutation routes through `platform/lib/admins-write` (`writeAdminEntry` for admin-add/set-pin, `removeAdminFromAccount` for admin-remove); the static check `grep -rnE 'admins\.push\|config\.admins\s*=' platform/ \| grep -v lib/admins-write` returns 0. Direct `Edit`/`Write` on `account.json` is forbidden by IDENTITY.md doctrine — mutations go through `account-update`, `plugin-toggle-enabled`, or the `admin-*` tools. **Install + boot invariants:** the installer and admin-server boot walk every account.json admins[] vs users.json; `[install-invariant]` / `[admin-invariant] direction=… userId=<id8> source=<file>` fires per divergence with a `check complete divergences=<n>` summary. Log-only — does not block. See `.docs/agents.md` § "Three-store admin auth invariant" for the full contract.
128
128
 
129
+ **Credential audit trail (append-only).** Every write to the credential stores emits one canonical `[users-audit] action=<set-access|clear-access|add|remove|set-pin|migrate> actor=<id8|onboarding|install|boot|unknown> [session=<id8>] field=<accessHash|pin|row> rowsBefore=<id8,…> rowsAfter=<id8,…> ts=<iso>` line to `~/{configDir}/logs/users-audit.log` — a dedicated file, distinct from `server.log`, so a credential mutation is greppable in isolation. The line is emitted at the writer chokepoint (`admin-access-password` for accessHash set/clear/migrate, `admins-write` `writeAdminEntry`/`removeAdminFromAccount` for row/pin/role) so every caller is covered; the one direct users.json writer that does not route through `writeAdminEntry` — `admin-update-pin` — emits the same line via the exported `logUsersAudit`. A write that changes no field (a clear with no hash, a migrate noop, an absent-admin remove) emits nothing. Credential-mutating MCP tools add `session=<id8>` from `SESSION_NODE_ID` so a mutation traces to its agent session even when the transcript is gone.
130
+
131
+ **Standing divergence reconcile.** An out-of-band rewrite has no audited caller, so detection is a standing check, not a write hook. The admin-server boot runs `[users-reconcile] op=ok admins=<n>` on parity / `op=divergence accountOnly=<id8,…> usersOnly=<id8,…>` on mismatch / `op=error detail=…` on a read fault, at boot AND hourly — a dropped admin surfaces within one interval, not at the dropped admin's next failed login. It shares the pure `computeAdminStoreDivergence` predicate with the `[admin-invariant]` one-shot (kept alongside) so the two never disagree. The predicate is admin-set membership between the two stores — disjoint from the account-dir registry sweep, so the two never double-report.
132
+
129
133
  **AdminUser graph stamp + boot self-heal.** The seed cypher in `platform/scripts/seed-neo4j.sh` stamps `:AdminUser.accountId` + `:AdminUser.role='owner'` ON CREATE so the graph-write gate's account-scoped MATCH succeeds on the first user-domain write. Admin-server boot additionally runs `platform/ui/app/lib/adminuser-self-heal.ts` to repair legacy nodes whose `accountId` or `role` is null — emits `[adminuser-self-heal] healed=<N> userId=<8> accountId=<8>` once per boot, idempotent. The `graph-write-gate` reject log gains a `subReason=` field (`no-admin-user-node` / `admin-user-no-accountid` / `no-business-or-personal-profile`) for operator diagnosis. See `.docs/agents.md` § "AdminUser graph stamp contract" for the full contract.
130
134
 
131
135
  `logs-read { type: "agent-stream" }` is the canonical name for the per-conversation tool-use/tool-result archive previously called `system`; both names work and the legacy alias is preserved.
@@ -8,7 +8,7 @@ import { readFile, writeFile } from "node:fs/promises";
8
8
  import { resolve, join } from "node:path";
9
9
  import { execFileSync } from "node:child_process";
10
10
  import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
11
- import { writeAdminEntry, removeAdminFromAccount } from "../../../../lib/admins-write/dist/index.js";
11
+ import { writeAdminEntry, removeAdminFromAccount, logUsersAudit, formatAuditRowIds } from "../../../../lib/admins-write/dist/index.js";
12
12
  import { setAccessPassword as setAccessPasswordHash, clearAccessPassword as clearAccessPasswordHash, accessPasswordCollides } from "../../../../lib/admin-access-password/dist/index.js";
13
13
  import { embed } from "../../../../lib/embed-client/dist/index.js";
14
14
  import { requirePortEnv } from "../../../../lib/require-port-env/dist/index.js";
@@ -209,6 +209,9 @@ const CORE_PLUGINS = ["admin", "memory", "docs", "cloudflare"];
209
209
  // resolve the same file via brand-aware $HOME/<configDir>, so the device-level
210
210
  // admin auth store is single-pathed across both processes.
211
211
  const USERS_FILE = resolve(CONFIG_DIR, "users.json");
212
+ // Append-only credential-store audit log — same path the admin server resolves
213
+ // from app/lib/paths (CONFIG_DIR == MAXY_DIR for both processes).
214
+ const USERS_AUDIT_LOG = resolve(CONFIG_DIR, "logs", "users-audit.log");
212
215
  /** Maximum number of admin users allowed per tier. */
213
216
  const MAX_ADMINS_BY_TIER = {
214
217
  solo: 1,
@@ -917,6 +920,7 @@ eagerTool(server, "admin-add", "Add a new admin user to this account. Creates a
917
920
  usersFile: USERS_FILE,
918
921
  accountDir: getAccountDir(),
919
922
  caller: "admin-add",
923
+ audit: { actor: callerUserId ?? "unknown", session: process.env.SESSION_NODE_ID, logFile: USERS_AUDIT_LOG },
920
924
  });
921
925
  console.error(`[admin-auth-store] action=add userId=${userIdShort} result=${authResult.usersJsonResult} store=users${authResult.usersError ? ` error=${authResult.usersError}` : ""}`);
922
926
  if (authResult.usersJsonResult !== "ok") {
@@ -1092,6 +1096,7 @@ eagerTool(server, "admin-remove", "Remove an admin from this account. Removes th
1092
1096
  userId,
1093
1097
  accountDir: getAccountDir(),
1094
1098
  caller: "admin-remove",
1099
+ audit: { actor: callerUserId ?? "unknown", session: process.env.SESSION_NODE_ID, logFile: USERS_AUDIT_LOG },
1095
1100
  });
1096
1101
  if (removeResult.accountJsonResult === "fail") {
1097
1102
  return { content: [{ type: "text", text: `${TAG} Failed to write account.json: ${removeResult.accountError ?? "unknown error"}` }], isError: true };
@@ -1217,6 +1222,13 @@ eagerTool(server, "admin-update-pin", "Update an existing admin user's PIN. Defa
1217
1222
  }
1218
1223
  console.error(`${TAG} userId=${userIdLabel} result=ok`);
1219
1224
  console.error(`[admin-auth-store] action=update-pin userId=${userIdLabel} result=ok store=users`);
1225
+ // admin-update-pin writes users.json directly (not via writeAdminEntry), so
1226
+ // it emits the [users-audit] line itself to stay covered. Row-set unchanged
1227
+ // (a pin rotation); the audited field is the pin.
1228
+ {
1229
+ const rows = formatAuditRowIds(users.map(u => u.userId));
1230
+ logUsersAudit({ actor: callerUserId ?? "unknown", session: process.env.SESSION_NODE_ID, logFile: USERS_AUDIT_LOG }, { action: "set-pin", field: "pin", rowsBefore: rows, rowsAfter: rows });
1231
+ }
1220
1232
  const self = userId === callerUserId;
1221
1233
  return {
1222
1234
  content: [{ type: "text", text: `PIN updated${self ? "" : ` for userId ${userId}`}. The new PIN takes effect on the next login.` }],
@@ -2111,7 +2123,7 @@ lifelineTool(server, "remote-auth-set-password", "Set an admin's remote access p
2111
2123
  return { content: [{ type: "text", text: `That password is already in use by another admin. Choose a different one.` }], isError: true };
2112
2124
  }
2113
2125
  try {
2114
- await setAccessPasswordHash(targetUserId, password, USERS_FILE);
2126
+ await setAccessPasswordHash(targetUserId, password, USERS_FILE, { actor: process.env.USER_ID ?? "unknown", session: process.env.SESSION_NODE_ID, logFile: USERS_AUDIT_LOG });
2115
2127
  console.error(`[admin] remote-access-password set userId=${targetUserId.slice(0, 8)}`);
2116
2128
  return { content: [{ type: "text", text: `Remote access password set for userId ${targetUserId}.` }] };
2117
2129
  }
@@ -2145,7 +2157,7 @@ lifelineTool(server, "remote-auth-revoke-password", "Revoke an admin's remote ac
2145
2157
  return { content: [{ type: "text", text: "Refusing to revoke the last remaining remote access password — the install would have no remote access. Set another admin's password first." }], isError: true };
2146
2158
  }
2147
2159
  try {
2148
- clearAccessPasswordHash(userId, USERS_FILE);
2160
+ clearAccessPasswordHash(userId, USERS_FILE, { actor: process.env.USER_ID ?? "unknown", session: process.env.SESSION_NODE_ID, logFile: USERS_AUDIT_LOG });
2149
2161
  console.error(`[admin] remote-access-password revoked userId=${userId.slice(0, 8)}`);
2150
2162
  return { content: [{ type: "text", text: `Remote access password revoked for userId ${userId}.` }] };
2151
2163
  }