@rubytech/create-maxy-code 0.1.353 → 0.1.354
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.
- package/package.json +1 -1
- package/payload/platform/lib/admin-access-password/__tests__/index.test.ts +96 -14
- package/payload/platform/lib/admin-access-password/dist/index.d.ts +20 -4
- package/payload/platform/lib/admin-access-password/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/admin-access-password/dist/index.js +43 -4
- package/payload/platform/lib/admin-access-password/dist/index.js.map +1 -1
- package/payload/platform/lib/admin-access-password/src/index.ts +67 -4
- package/payload/platform/lib/admins-write/__tests__/index.test.ts +182 -0
- package/payload/platform/lib/admins-write/dist/index.d.ts +65 -11
- package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/admins-write/dist/index.js +93 -31
- package/payload/platform/lib/admins-write/dist/index.js.map +1 -1
- package/payload/platform/lib/admins-write/src/index.ts +133 -36
- package/payload/platform/plugins/admin/PLUGIN.md +4 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +15 -3
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +16 -1
- package/payload/platform/plugins/docs/references/troubleshooting.md +15 -0
- package/payload/server/{chunk-QAKVFSEJ.js → chunk-7GNXBVGY.js} +38 -11
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/{AdminLoginScreens-CzdOQKyO.js → AdminLoginScreens-6WgO-Z3m.js} +1 -1
- package/payload/server/public/assets/{AdminShell-CC-CuN6Y.js → AdminShell-GGnPXCOe.js} +1 -1
- package/payload/server/public/assets/{Checkbox-sQBc9xSy.js → Checkbox-D3XFVUMT.js} +1 -1
- package/payload/server/public/assets/{OperatorConversations-DPVKGsRS.css → OperatorConversations-CFWXdnjB.css} +1 -1
- package/payload/server/public/assets/{admin-qW6jM6l0.js → admin-B2yNhVvH.js} +1 -1
- package/payload/server/public/assets/{browser-BsW6KaxZ.js → browser-ByaQ0ItQ.js} +1 -1
- package/payload/server/public/assets/{calendar-BPAiN1cL.js → calendar-BjqCT8cW.js} +1 -1
- package/payload/server/public/assets/chat-kl-Ips-o.js +1 -0
- package/payload/server/public/assets/{data-D_cR_NCZ.js → data-p9p-181v.js} +1 -1
- package/payload/server/public/assets/{graph-CmpAy2WP.js → graph-BuunC8eP.js} +1 -1
- package/payload/server/public/assets/{graph-labels-C0SLq_VP.js → graph-labels-BF3Ak8q0.js} +1 -1
- package/payload/server/public/assets/{operator-CrjtJcEY.js → operator-D7c8mN4J.js} +1 -1
- package/payload/server/public/assets/{page-Cf3fVDSo.js → page-C6uMZdT1.js} +3 -3
- package/payload/server/public/assets/{public-BkNtvSYp.js → public-B9_UhjEa.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +4 -4
- package/payload/server/public/chat.html +5 -5
- package/payload/server/public/data.html +4 -4
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +6 -6
- package/payload/server/public/operator.html +7 -7
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +1010 -966
- package/payload/server/public/assets/chat-DVD9yoVL.js +0 -1
- /package/payload/server/public/assets/{OperatorConversations-CA_2c3sp.js → OperatorConversations-DeZ2wDEK.js} +0 -0
|
@@ -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.
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
220
|
-
const result:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|