@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.41

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 (122) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +654 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message-seam.test.ts +150 -0
  30. package/src/desktop-message.test.ts +368 -0
  31. package/src/ext-token-shared.test.ts +60 -0
  32. package/src/ext-validation-regression.test.ts +131 -0
  33. package/src/external-schedule-edits-http.test.ts +316 -0
  34. package/src/external-schedule-edits.ts +314 -0
  35. package/src/file-download-authz-dev-open.test.ts +135 -0
  36. package/src/file-download-authz.test.ts +403 -0
  37. package/src/file-download-date-bucket.test.ts +436 -0
  38. package/src/file-network-scope-dev-open.test.ts +97 -0
  39. package/src/file-network-scope.test.ts +731 -0
  40. package/src/health-redaction.test.ts +637 -0
  41. package/src/inbox-count-sse.test.ts +310 -0
  42. package/src/inbox-count.ts +29 -0
  43. package/src/index.ts +15 -2187
  44. package/src/lifecycle-guard.ts +50 -0
  45. package/src/list-host-supervisors.test.ts +314 -0
  46. package/src/mcp-write-network-resolution.test.ts +429 -0
  47. package/src/network-name-http.test.ts +71 -0
  48. package/src/network-scope.ts +101 -0
  49. package/src/node-attrs-validate.ts +88 -0
  50. package/src/node-attrs.test.ts +213 -0
  51. package/src/node-id-alias.test.ts +147 -0
  52. package/src/node-lifecycle-controllable.test.ts +64 -0
  53. package/src/node-lifecycle-controllable.ts +51 -0
  54. package/src/observer-avatar-http.test.ts +424 -0
  55. package/src/observer-push.test.ts +252 -0
  56. package/src/package-version-consistency.test.ts +25 -0
  57. package/src/peer-reply-atomic.test.ts +455 -0
  58. package/src/peer-reply-capability.test.ts +70 -0
  59. package/src/probe-validate.test.ts +221 -0
  60. package/src/probe-validate.ts +104 -0
  61. package/src/probe.test.ts +80 -0
  62. package/src/probe.ts +200 -0
  63. package/src/push.ts +163 -7
  64. package/src/redact-tokens.test.ts +65 -0
  65. package/src/redact-tokens.ts +40 -0
  66. package/src/reply-target-derivation.test.ts +96 -0
  67. package/src/require-explicit-test-db.ts +9 -0
  68. package/src/resolve-port.test.ts +57 -0
  69. package/src/resolve-port.ts +44 -0
  70. package/src/response-charset.test.ts +130 -0
  71. package/src/rest-explicit-columns-http.test.ts +212 -0
  72. package/src/rest-identity.test.ts +129 -0
  73. package/src/rest-identity.ts +100 -0
  74. package/src/rest-projections.ts +67 -0
  75. package/src/rest-write-network-resolution.test.ts +133 -0
  76. package/src/runtime-label.test.ts +22 -0
  77. package/src/runtime-label.ts +21 -0
  78. package/src/scheduled-run-terminal.test.ts +268 -0
  79. package/src/scheduled-tasks-http.test.ts +479 -0
  80. package/src/scheduled-tasks.ts +549 -0
  81. package/src/send-reply-agent-warning.test.ts +202 -0
  82. package/src/send-reply-alias-optional.test.ts +131 -0
  83. package/src/send-reply-attachments.test.ts +437 -0
  84. package/src/server.ts +3664 -0
  85. package/src/shared/external-schedule-contract.ts +60 -0
  86. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  87. package/src/shared/probe-host-allowlist.ts +128 -0
  88. package/src/side-thread-command-transport.test.ts +302 -0
  89. package/src/side-thread-command-transport.ts +318 -0
  90. package/src/side-thread-http-integration.test.ts +229 -0
  91. package/src/side-thread-http.ts +504 -0
  92. package/src/side-thread-production.test.ts +72 -0
  93. package/src/side-thread-production.ts +118 -0
  94. package/src/side-thread.test.ts +1067 -0
  95. package/src/side-thread.ts +2495 -0
  96. package/src/skillhub-http.test.ts +88 -0
  97. package/src/start-node.test.ts +109 -0
  98. package/src/stop-delete-node.test.ts +952 -0
  99. package/src/task-auth-origin-http.test.ts +87 -0
  100. package/src/task-auth-origin.test.ts +24 -0
  101. package/src/task-auth-origin.ts +7 -0
  102. package/src/task-consumption.test.ts +471 -0
  103. package/src/task-diagnostic-http.test.ts +145 -0
  104. package/src/task-diagnostic.test.ts +115 -0
  105. package/src/task-diagnostic.ts +118 -0
  106. package/src/task-idempotency-mcp.test.ts +142 -0
  107. package/src/task-idempotency.test.ts +60 -0
  108. package/src/task-idempotency.ts +75 -0
  109. package/src/task-lifecycle-watcher.test.ts +200 -0
  110. package/src/task-lifecycle-watcher.ts +49 -0
  111. package/src/task-network-resolution-http.test.ts +137 -0
  112. package/src/tasks-pagination-http.test.ts +137 -0
  113. package/src/tools.ts +2994 -191
  114. package/src/update-provider.test.ts +234 -0
  115. package/src/upload-early-reject-guard.test.ts +155 -0
  116. package/src/uploads-http.test.ts +92 -14
  117. package/src/uploads.test.ts +66 -0
  118. package/src/uploads.ts +130 -5
  119. package/src/user-inbox-read-http.test.ts +220 -0
  120. package/src/user-inbox-schema.test.ts +53 -0
  121. package/src/vault.test.ts +200 -0
  122. package/src/vault.ts +156 -0
@@ -0,0 +1,267 @@
1
+ // #380 fix — REST /api/host-supervisors utok→default-network fallback.
2
+ //
3
+ // Before this patch the endpoint hard-4xxed when the client omitted the
4
+ // `network_id` query param. The dashboard's create-node wizard was
5
+ // reported as "hub 400" against a prod hub that had zero daemons AND was
6
+ // being polled without the query — the frontend read that as "no
7
+ // available servers" and blocked the wizard.
8
+ //
9
+ // Post-fix contract:
10
+ // - ntok caller → uses bound network (existing)
11
+ // - utok caller + `?network_id=...` verified → uses that (existing)
12
+ // - utok caller + NO query + 1 accessible net → fallback to that net (200)
13
+ // - utok caller + NO query + 2+ accessible net → 400, error=network_id_required_multi
14
+ // - utok caller + NO query + 0 accessible net → 400, error=missing_network_id
15
+ //
16
+ // The multi-network case is the authz boundary — we refuse to guess
17
+ // which network to list from (per 通信龙 spec "别 fallback 到错 network").
18
+ //
19
+ // Test pattern mirrors uploads-http.test.ts: bind the real Bun.serve
20
+ // server on an ephemeral port with a temp DB, hit it with fetch().
21
+
22
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
23
+ import "./require-explicit-test-db.js";
24
+ import { register, login, createNetwork } from "./auth.js";
25
+ import { db } from "./db.js";
26
+
27
+ // #438 corrective: private bootServer({ port: 0 }) instance (see
28
+ // uploads-http.test.ts for rationale).
29
+ let BASE = "";
30
+ let server: any = null;
31
+
32
+ // Three users:
33
+ // soloUser — single accessible network (fallback should work)
34
+ // multiUser — two accessible networks (fallback should refuse)
35
+ // orphanUser — zero networks (fallback has nothing to derive)
36
+ let soloToken = "", multiToken = "", orphanToken = "", adminToken = "";
37
+ let soloNetworkId = "";
38
+ let multiNetworkA = "", multiNetworkB = "";
39
+ let soloDaemonAlias = "";
40
+
41
+ beforeAll(async () => {
42
+ process.env.HOST = "127.0.0.1";
43
+
44
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
45
+ const password = "BootstrapPw123Aa!";
46
+
47
+ // Pre-register a first user so subsequent test users are NOT auto-admin.
48
+ // resolveRestNetworkScope's admin branch bypasses fallback (admin can
49
+ // legitimately query any network), which would make solo's no-query
50
+ // case a false-negative for our fix contract.
51
+ const seed = register(`seed_admin_${suffix}`, password, undefined, "seed");
52
+ if (!seed.ok || !seed.token) throw new Error("seed admin failed: " + JSON.stringify(seed));
53
+ // "First registered user is auto-admin" only holds when this suite has
54
+ // the DB to itself. In an aggregate run another suite registers first,
55
+ // the seed lands as a plain user, and paths 7/8 silently test the
56
+ // wrong role. Promote explicitly — resolveToken reads users.role at
57
+ // request time, so promotion after token issuance is effective.
58
+ db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`seed_admin_${suffix}`]);
59
+ // Save the admin token so path 7 can hit /api/host-supervisors as an
60
+ // admin directly (通信龙 review point 1: verify admin+no-query goes 400
61
+ // explicitly, not only implicitly via seed_admin's shadow presence).
62
+ adminToken = seed.token;
63
+
64
+ // Solo user — register() auto-creates a default network; nothing to add.
65
+ const solo = register(`solo_${suffix}`, password, undefined, "seed");
66
+ if (!solo.ok || !solo.token) throw new Error("solo register failed: " + JSON.stringify(solo));
67
+ soloToken = solo.token;
68
+ soloNetworkId = solo.network_id ?? "";
69
+ if (!soloNetworkId) throw new Error("solo default network missing");
70
+
71
+ // Multi user — register + create a second owned network.
72
+ const multi = register(`multi_${suffix}`, password, undefined, "seed");
73
+ if (!multi.ok || !multi.token) throw new Error("multi register failed: " + JSON.stringify(multi));
74
+ multiToken = multi.token;
75
+ multiNetworkA = multi.network_id ?? "";
76
+ if (!multiNetworkA) throw new Error("multi default network missing");
77
+ const authCtx = login(`multi_${suffix}`, password);
78
+ const secondNet: any = createNetwork(authCtx.user!.user_id, `multi_second_${suffix}`);
79
+ if (!secondNet.ok || !secondNet.network_id) throw new Error("multi second-net create failed: " + JSON.stringify(secondNet));
80
+ multiNetworkB = secondNet.network_id;
81
+
82
+ // Orphan user — register then delete membership in the default net so
83
+ // the user has zero networks. Direct DB manipulation is fine here;
84
+ // we're modeling an edge case the production API would never let a
85
+ // normal user hit (they'd always keep at least their default), but
86
+ // the endpoint contract still needs to be locked.
87
+ const orphan = register(`orphan_${suffix}`, password, undefined, "seed");
88
+ if (!orphan.ok || !orphan.token) throw new Error("orphan register failed: " + JSON.stringify(orphan));
89
+ orphanToken = orphan.token;
90
+ const orphanUserId = login(`orphan_${suffix}`, password).user!.user_id;
91
+ db.run("DELETE FROM network_members WHERE user_id = ?1", [orphanUserId]);
92
+
93
+ // 通信龙 review point 2: path-2's fallback response must land the
94
+ // daemon list *from soloNetworkId specifically*, not from the whole
95
+ // sessions/nodes table. The original test-2 asserted daemons was an
96
+ // array but a clean fixture (0 daemons in the DB) would trivially
97
+ // satisfy that. Insert:
98
+ // (a) a host_supervisor daemon in soloNetworkId — should show up in
99
+ // fallback response
100
+ // (b) a host_supervisor daemon in multiNetworkA — should NOT show
101
+ // up in solo's fallback (cross-tenant isolation lock)
102
+ soloDaemonAlias = `solo_daemon_${suffix}`;
103
+ seedHostSupervisorDaemon({
104
+ alias: soloDaemonAlias,
105
+ networkId: soloNetworkId,
106
+ userIdForToken: login(`solo_${suffix}`, password).user!.user_id,
107
+ });
108
+ seedHostSupervisorDaemon({
109
+ alias: `other_daemon_${suffix}`,
110
+ networkId: multiNetworkA,
111
+ userIdForToken: login(`multi_${suffix}`, password).user!.user_id,
112
+ });
113
+
114
+ // server.ts is side-effect-free to import; boot a private instance.
115
+ const { bootServer } = await import("./server.js");
116
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
117
+ BASE = `http://127.0.0.1:${server.port}`;
118
+ await new Promise((r) => setTimeout(r, 100));
119
+ });
120
+
121
+ // Insert a minimal `nodes` row + `api_tokens` row so the daemon shows up
122
+ // in /api/host-supervisors' JOIN-then-role-filter query (index.ts
123
+ // GET /api/host-supervisors handler, at the SELECT ... FROM nodes n ...
124
+ // EXISTS (SELECT 1 FROM api_tokens ...) branch). The api_tokens row
125
+ // with name='node:<alias>' + revoked_at IS NULL is the "still active"
126
+ // witness — without it EXISTS drops the row.
127
+ function seedHostSupervisorDaemon(opts: {
128
+ alias: string;
129
+ networkId: string;
130
+ userIdForToken: string;
131
+ }) {
132
+ const nodeId = `n_test_${opts.alias}`;
133
+ db.run(
134
+ `INSERT OR REPLACE INTO nodes (
135
+ node_id, node_name, alias, runtime, model, config_path,
136
+ channels, server, hostname, network_id,
137
+ config_revision, config_snapshot
138
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
139
+ [nodeId, opts.alias, opts.alias, "claude-agent-sdk", "claude-sonnet-4-5",
140
+ "/tmp/cfg.json", "[]", "test-host", "test-host", opts.networkId,
141
+ 0, JSON.stringify({ role: "host_supervisor", daemon_capabilities: {} })],
142
+ );
143
+ db.run(
144
+ `INSERT OR REPLACE INTO api_tokens (
145
+ token_id, token_hash, user_id, network_id, name, scope
146
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
147
+ [`tok_test_${opts.alias}`, `hash_test_${opts.alias}`,
148
+ opts.userIdForToken, opts.networkId, `node:${opts.alias}`, "network"],
149
+ );
150
+ }
151
+
152
+ afterAll(() => {
153
+ try { server?.stop?.(true); } catch {}
154
+ });
155
+
156
+ async function get(path: string, token: string): Promise<{ status: number; body: any }> {
157
+ const res = await fetch(`${BASE}${path}`, {
158
+ headers: { "Authorization": `Bearer ${token}` },
159
+ });
160
+ const body = await res.json().catch(() => null);
161
+ return { status: res.status, body };
162
+ }
163
+
164
+ describe("#380 — /api/host-supervisors utok→default-network fallback", () => {
165
+ test("path 1 — utok with 1 network, WITH ?network_id → 200 (existing behavior)", async () => {
166
+ const r = await get(`/api/host-supervisors?network_id=${soloNetworkId}`, soloToken);
167
+ expect(r.status).toBe(200);
168
+ expect(r.body?.ok).toBe(true);
169
+ expect(Array.isArray(r.body?.daemons)).toBe(true);
170
+ expect(typeof r.body?.count).toBe("number");
171
+ });
172
+
173
+ test("path 2 — utok with 1 network, NO ?network_id → 200 (fallback lands AND scopes correctly)", async () => {
174
+ // This is the core #380 fix: dashboard forgot the query but the user
175
+ // only has one network anyway, so we can safely fall back to it.
176
+ //
177
+ // 通信龙 review point 2: harden the assertion beyond "daemons is an
178
+ // array". We seeded one daemon in soloNetworkId and one in
179
+ // multiNetworkA (multi user); the fallback response MUST include the
180
+ // solo one and NOT the multi one. Otherwise a future regression that
181
+ // wired the fallback to "all networks the user knows about" would
182
+ // slip through — cross-tenant leak.
183
+ const r = await get(`/api/host-supervisors`, soloToken);
184
+ expect(r.status).toBe(200);
185
+ expect(r.body?.ok).toBe(true);
186
+ expect(Array.isArray(r.body?.daemons)).toBe(true);
187
+
188
+ const aliases: string[] = r.body.daemons.map((d: any) => d.alias);
189
+ expect(aliases).toContain(soloDaemonAlias);
190
+ // Belt: nothing from the multi user's network snuck through.
191
+ expect(aliases.every((a: string) => !a.startsWith("other_daemon_"))).toBe(true);
192
+ // Every returned daemon's daemon_node_id should be one this user can
193
+ // legitimately see — locking the shape end-to-end.
194
+ for (const d of r.body.daemons) {
195
+ expect(typeof d.daemon_node_id).toBe("string");
196
+ expect(typeof d.alias).toBe("string");
197
+ }
198
+ });
199
+
200
+ test("path 3 — utok with 2 networks, NO ?network_id → 400 network_id_required_multi", async () => {
201
+ // Authz boundary: refuse to guess which network to list. Distinguishing
202
+ // error field lets the client tell the user "pick a network" vs
203
+ // "you're not in any networks".
204
+ const r = await get(`/api/host-supervisors`, multiToken);
205
+ expect(r.status).toBe(400);
206
+ expect(r.body?.ok).toBe(false);
207
+ expect(r.body?.error).toBe("network_id_required_multi");
208
+ expect(r.body?.memberships).toBe(2);
209
+ });
210
+
211
+ test("path 4 — utok with 2 networks, WITH ?network_id → 200 (explicit choice honored)", async () => {
212
+ const r = await get(`/api/host-supervisors?network_id=${multiNetworkA}`, multiToken);
213
+ expect(r.status).toBe(200);
214
+ expect(r.body?.ok).toBe(true);
215
+ const r2 = await get(`/api/host-supervisors?network_id=${multiNetworkB}`, multiToken);
216
+ expect(r2.status).toBe(200);
217
+ expect(r2.body?.ok).toBe(true);
218
+ });
219
+
220
+ test("path 5 — utok with 2 networks, WITH ?network_id (not a member) → denied by scope layer", async () => {
221
+ // Requesting a network the user isn't in must never leak — this is
222
+ // enforced upstream at resolveRestNetworkScope, but re-lock here.
223
+ const foreignNet = "net_absolutely_not_a_member";
224
+ const r = await get(`/api/host-supervisors?network_id=${foreignNet}`, multiToken);
225
+ expect(r.status).toBe(403);
226
+ });
227
+
228
+ test("path 7 — admin utok, NO ?network_id → 400 (admin can span every network; no cross-network guessing)", async () => {
229
+ // 通信龙 review point 1: the previous suite only asserted admin
230
+ // indirectly (seed_admin was created first so other test users
231
+ // weren't auto-admin). This test hits /api/host-supervisors with
232
+ // seed_admin's own utok — resolveRestNetworkScope's isAdmin branch
233
+ // returns networkIds:null, singleNetworkId returns null, endpoint
234
+ // 400s. The response distinguishes it from the multi-network case
235
+ // via `memberships` being absent-or-0 (admin doesn't populate
236
+ // scope.networkIds), so the client sees an explicit "you're admin,
237
+ // pass network_id" affordance.
238
+ const r = await get(`/api/host-supervisors`, adminToken);
239
+ expect(r.status).toBe(400);
240
+ expect(r.body?.ok).toBe(false);
241
+ // Not the multi-network error — admin path goes through the "no
242
+ // scope.networkIds" branch → memberships defaults to 0 (the ??
243
+ // coalesce in the handler). This asymmetry lets the client
244
+ // distinguish "you own too many" from "you can see all of them".
245
+ expect(r.body?.error).toBe("missing_network_id");
246
+ });
247
+
248
+ test("path 8 — admin utok, WITH explicit ?network_id → 200 (admin can inspect any network)", async () => {
249
+ // Complement to path 7: even without membership in a target network,
250
+ // admin can query it. Locks resolveRestNetworkScope's isAdmin =>
251
+ // {networkId: requested, networkIds: null} branch.
252
+ const r = await get(`/api/host-supervisors?network_id=${multiNetworkA}`, adminToken);
253
+ expect(r.status).toBe(200);
254
+ expect(r.body?.ok).toBe(true);
255
+ // The daemon we seeded in multiNetworkA must be reachable.
256
+ const aliases: string[] = r.body.daemons.map((d: any) => d.alias);
257
+ expect(aliases.some(a => a.startsWith("other_daemon_"))).toBe(true);
258
+ });
259
+
260
+ test("path 6 — utok with 0 networks, NO ?network_id → 400 missing_network_id (memberships=0)", async () => {
261
+ const r = await get(`/api/host-supervisors`, orphanToken);
262
+ expect(r.status).toBe(400);
263
+ expect(r.body?.ok).toBe(false);
264
+ expect(r.body?.error).toBe("missing_network_id");
265
+ expect(r.body?.memberships).toBe(0);
266
+ });
267
+ });
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { db } from "./db.js";
3
+ import { parseStoredTags } from "./node-attrs-validate.js";
3
4
 
4
5
  // Regression test for SELECT * fragility on GET /api/nodes.
5
6
  //
@@ -29,8 +30,28 @@ afterAll(() => {
29
30
  try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
30
31
  });
31
32
 
33
+ // Helper: mirror handler's role-extraction pass to mimic the production
34
+ // response shape exactly. Keeps the test the source of truth for the
35
+ // contract; if the handler changes, the assertions still lock what
36
+ // the dashboard / external clients see.
37
+ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
38
+ let role: string | null = null;
39
+ const snap = r.config_snapshot;
40
+ if (snap) {
41
+ try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
42
+ catch { /* malformed */ }
43
+ }
44
+ const { config_snapshot, ...rest } = r;
45
+ return {
46
+ ...rest,
47
+ tags: parseStoredTags(r.tags),
48
+ attrs_revision: Number(r.attrs_revision ?? 0),
49
+ role,
50
+ };
51
+ }
52
+
32
53
  describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
33
- test("explicit columns: internal config_revision/config_snapshot NOT in response", () => {
54
+ test("explicit columns + role extracted: config_snapshot NOT in response, role IS", () => {
34
55
  // Insert a row with the leak-prone columns populated. Use INSERT OR
35
56
  // REPLACE so concurrent test runs don't trip on PRIMARY KEY.
36
57
  db.run(
@@ -43,38 +64,111 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
43
64
  )`,
44
65
  [TEST_NODE, "test-shape", "test-shape", "claude-agent-sdk", "claude-opus",
45
66
  "/tmp/cfg.json", "[]", "localhost", "test-host", TEST_NETWORK,
46
- 7, JSON.stringify({ model: "claude-opus", flags: { x: true } })],
67
+ 7, JSON.stringify({ model: "claude-opus", flags: { x: true }, role: "host_supervisor" })],
47
68
  );
48
69
 
49
- // Run the same SQL the handler runs (mirror; if handler changes, this
50
- // assertion still locks the shape).
51
- const rows = db.all<Record<string, unknown>>(
70
+ // Mirror handler SQL + mapping
71
+ const raw = db.all<Record<string, unknown>>(
52
72
  `SELECT node_id, node_name, alias, runtime, model,
53
73
  config_path, channels, server, hostname,
54
- network_id, created_at, updated_at
74
+ network_id, created_at, updated_at,
75
+ config_snapshot, display_name, team, tags, attrs_revision
55
76
  FROM nodes WHERE node_id = ?1`,
56
77
  TEST_NODE,
57
78
  );
79
+ const rows = raw.map(mapRow);
58
80
 
59
81
  expect(rows).toHaveLength(1);
60
82
  const row = rows[0]!;
61
- // Expected fields present
83
+ // Expected fields present (13 = 12 contract + new role)
62
84
  for (const k of ["node_id", "node_name", "alias", "runtime", "model",
63
85
  "config_path", "channels", "server", "hostname",
64
- "network_id", "created_at", "updated_at"]) {
86
+ "network_id", "created_at", "updated_at", "role"]) {
65
87
  expect(row).toHaveProperty(k);
66
88
  }
67
- // Internal fields NOT present (these are the leak the test guards against)
89
+ // Internal fields NOT present
68
90
  expect(row).not.toHaveProperty("config_revision");
69
91
  expect(row).not.toHaveProperty("config_snapshot");
70
- // Belt: full key set is exactly the 12 contract fields
92
+ // role is EXTRACTED from config_snapshot JSON, not the raw blob
93
+ expect(row.role).toBe("host_supervisor");
94
+ // Belt: the key set is exactly the dashboard-facing contract.
95
+ // NOTE: this file MIRRORS the handler's SQL rather than calling it, so
96
+ // it can drift — it already lagged behind `lifecycle_state` /
97
+ // `avatar_url`, which the handler selects but this mirror does not.
98
+ // Treat a diff here as "re-read the handler", not as proof of the wire
99
+ // shape; node-attrs.test.ts asserts the real HTTP response.
71
100
  expect(Object.keys(row).sort()).toEqual([
72
- "alias", "channels", "config_path", "created_at", "hostname",
73
- "model", "network_id", "node_id", "node_name", "runtime",
74
- "server", "updated_at",
101
+ "alias", "attrs_revision", "channels", "config_path", "created_at",
102
+ "display_name", "hostname", "model", "network_id", "node_id",
103
+ "node_name", "role", "runtime", "server", "tags", "team",
104
+ "updated_at",
75
105
  ]);
76
106
  });
77
107
 
108
+ test("role is null when config_snapshot is empty / missing / malformed", () => {
109
+ // Empty snapshot → null
110
+ db.run(
111
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
112
+ VALUES (?1, ?2, ?3, ?4, NULL)`,
113
+ [TEST_NODE, "test-shape", "test-shape", TEST_NETWORK],
114
+ );
115
+ let row = mapRow(db.get<Record<string, unknown>>(
116
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
117
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
118
+ display_name, team, tags, attrs_revision
119
+ FROM nodes WHERE node_id = ?1`,
120
+ TEST_NODE,
121
+ )!);
122
+ expect(row.role).toBeNull();
123
+ expect(row).not.toHaveProperty("config_snapshot");
124
+
125
+ // Malformed JSON → null (handler doesn't throw, just leaves role null)
126
+ db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
127
+ row = mapRow(db.get<Record<string, unknown>>(
128
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
129
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
130
+ display_name, team, tags, attrs_revision
131
+ FROM nodes WHERE node_id = ?1`,
132
+ TEST_NODE,
133
+ )!);
134
+ expect(row.role).toBeNull();
135
+
136
+ // Snapshot without role key → null
137
+ db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
138
+ [JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
139
+ row = mapRow(db.get<Record<string, unknown>>(
140
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
141
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
142
+ display_name, team, tags, attrs_revision
143
+ FROM nodes WHERE node_id = ?1`,
144
+ TEST_NODE,
145
+ )!);
146
+ expect(row.role).toBeNull();
147
+ });
148
+
149
+ test("daemon discovery use-case: dashboard finds role=host_supervisor without prefix heuristic", () => {
150
+ // Daemon registered with arbitrary node_id (NO `node_daemon_` prefix)
151
+ db.run(
152
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
153
+ VALUES (?1, ?2, ?3, ?4, ?5)`,
154
+ [TEST_NODE, "my-daemon", "my-daemon", TEST_NETWORK,
155
+ JSON.stringify({ role: "host_supervisor", runtime: "claude-agent-sdk" })],
156
+ );
157
+ const rows = db.all<Record<string, unknown>>(
158
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
159
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
160
+ display_name, team, tags, attrs_revision
161
+ FROM nodes WHERE network_id = ?1`,
162
+ TEST_NETWORK,
163
+ ).map(mapRow);
164
+ // Dashboard's lookup: find first node with role==='host_supervisor'
165
+ const daemon = rows.find(r => r.role === "host_supervisor");
166
+ expect(daemon).toBeTruthy();
167
+ expect(daemon!.node_id).toBe(TEST_NODE);
168
+ // Even though node_id does NOT start with `node_daemon_` (the prior heuristic)
169
+ expect(String(daemon!.node_id).startsWith("node_daemon_")).toBe(false);
170
+ });
171
+
78
172
  test("internal columns are still readable via dedicated query (config snapshot endpoint path)", () => {
79
173
  db.run(
80
174
  `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id,
package/src/auth.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
+ import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
6
7
 
7
8
  // Round-6 A1 hardening — dummy hash for username-enumeration timing
8
9
  // close. We compute ONE scrypt hash of a throwaway password and reuse
@@ -87,11 +88,32 @@ export function register(username: string, password: string, email?: string, dis
87
88
  [userId, username, pwHash, email || null, displayName || username, isFirstUser ? "admin" : "user"]
88
89
  );
89
90
 
90
- // Auto-create default network + add as owner member
91
+ // Auto-create the user's own network + add as owner member.
92
+ //
93
+ // Name it after the user rather than the literal "default". Every
94
+ // registration used to mint a network called "default", so a hub with N
95
+ // users showed N identical "default" rows in the Dashboard sidebar with
96
+ // no way to tell them apart (23 of them on the live hub, 22 empty).
97
+ // Three CLI call sites (bin/cli.ts debate / demo-social / pr-review) resolve
98
+ // a network as:
99
+ // me.user.default_network_id
100
+ // || networks.find(n => n.network_name === "default")
101
+ // || networks[0]
102
+ // Checked against the live hub: GET /api/auth/me returns
103
+ // {user_id, username, display_name, email, role} — there is no
104
+ // default_network_id field, and nothing in server/ ever writes one. So the
105
+ // first term is always undefined and the name lookup is the operative path,
106
+ // not a fallback.
107
+ //
108
+ // After this change that lookup misses for new users and they land on
109
+ // networks[0]. That still resolves to this network because listNetworks()
110
+ // is `ORDER BY created_at` ascending (auth.ts:296) and this row is the
111
+ // user's oldest. Reordering that query would silently point those three
112
+ // commands at the wrong network.
91
113
  const networkId = generateId("net");
92
114
  db.run(
93
115
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
94
- [networkId, "default", userId, "Auto-created default network"]
116
+ [networkId, username, userId, `Auto-created network for ${username}`]
95
117
  );
96
118
  db.run(
97
119
  "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
@@ -191,18 +213,67 @@ export function login(username: string, password: string): AuthResult {
191
213
  };
192
214
  }
193
215
 
194
- /** Create a network-scoped token (ntok_) for a specific node */
195
- export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string): { ok: boolean; token?: string; error?: string } {
216
+ /**
217
+ * Create a network-scoped token (ntok_) for a specific node.
218
+ *
219
+ * RFC-036: callers that provide nodeId atomically establish the immutable
220
+ * node-owner binding before the plaintext token is returned. The 3-argument
221
+ * legacy form remains available for old clients, but its token has no bound
222
+ * node id and therefore cannot consume external-schedule edit intents.
223
+ */
224
+ export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string, nodeId?: string): { ok: boolean; token?: string; token_id?: string; node_id?: string; error?: string } {
196
225
  // Verify user is a member of this network with write access
197
226
  const role = getUserNetworkRole(userId, networkId);
198
227
  if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
228
+ if (!nodeName || nodeName.length > 200) return { ok: false, error: "invalid_node_name" };
229
+ if (nodeId !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(nodeId))) {
230
+ return { ok: false, error: "invalid_node_id" };
231
+ }
199
232
  const token = generateNetworkToken();
200
233
  const tokenId = generateId("tok");
201
- db.run(
202
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
203
- [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, "network"]
204
- );
205
- return { ok: true, token };
234
+ try {
235
+ db.transaction(() => {
236
+ if (nodeId) {
237
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
238
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
239
+ nodeId,
240
+ );
241
+ if (existing?.network_id && existing.network_id !== networkId) throw new Error("cross_network_node");
242
+ // Existing pre-RFC-036 rows have no trustworthy owner anchor. A token
243
+ // refresh must not turn knowledge of a legacy node_id into ownership.
244
+ // They remain read-only until a separately authorized migration.
245
+ if (existing && !existing.owner_user_id) throw new Error("node_owner_unclaimed");
246
+ if (existing?.owner_user_id && existing.owner_user_id !== userId) throw new Error("node_owner_mismatch");
247
+ db.run(
248
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
249
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))
250
+ ON CONFLICT(node_id) DO UPDATE SET
251
+ node_name = COALESCE(nodes.node_name, ?2),
252
+ alias = COALESCE(nodes.alias, ?2),
253
+ network_id = COALESCE(nodes.network_id, ?3),
254
+ owner_user_id = COALESCE(nodes.owner_user_id, ?4),
255
+ updated_at = datetime('now')`,
256
+ [nodeId, nodeName, networkId, userId],
257
+ );
258
+ if (!existing) {
259
+ db.run(
260
+ `INSERT INTO audit_log (user_id, action, target_type, target_id, detail, network_id)
261
+ VALUES (?1, 'external_schedule.owner_claimed', 'node', ?2, ?3, ?4)`,
262
+ [userId, nodeId, JSON.stringify({ node_id: nodeId, network_id: networkId }), networkId],
263
+ );
264
+ }
265
+ }
266
+ db.run(
267
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
268
+ [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, nodeId ?? null],
269
+ );
270
+ });
271
+ } catch (error: any) {
272
+ const code = String(error?.message || "node_token_create_failed");
273
+ if (code === "cross_network_node" || code === "node_owner_unclaimed" || code === "node_owner_mismatch") return { ok: false, error: code };
274
+ throw error;
275
+ }
276
+ return { ok: true, token, token_id: tokenId, ...(nodeId ? { node_id: nodeId } : {}) };
206
277
  }
207
278
 
208
279
  export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
@@ -235,7 +306,7 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
235
306
 
236
307
  export function getUserNetworks(userId: string) {
237
308
  return db.all<any>(
238
- "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at",
309
+ `SELECT ${NETWORK_REST_SELECT} FROM networks WHERE owner_id = ?1 ORDER BY created_at`,
239
310
  userId);
240
311
  }
241
312
 
@@ -280,7 +351,7 @@ export function listTokens(userId: string) {
280
351
  }
281
352
 
282
353
  export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
283
- const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
354
+ const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
284
355
  if (!net) return { ok: false, error: "network not found" };
285
356
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
286
357
  const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
@@ -290,13 +361,18 @@ export function renameNetwork(userId: string, networkId: string, newName: string
290
361
  }
291
362
 
292
363
  export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
293
- const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
364
+ const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
294
365
  if (!net) return { ok: false, error: "network not found" };
295
366
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
296
367
  // Check if any sessions/tasks still reference this network
297
368
  const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
298
369
  if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
299
370
  db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
371
+ // PR #519 codex catch: leaving membership rows behind made deleted
372
+ // networks count toward getUserNetworkIds — a stale row could flip a
373
+ // single-network user to "ambiguous" or auto-resolve writes INTO the
374
+ // deleted network. Remove memberships with the network.
375
+ db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
300
376
  return { ok: true };
301
377
  }
302
378
 
@@ -444,7 +520,10 @@ export function createInvite(networkId: string, createdBy: string, role: string
444
520
  }
445
521
 
446
522
  export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
447
- const invite = db.get<any>("SELECT * FROM network_invites WHERE invite_code = ?1", inviteCode);
523
+ const invite = db.get<any>(
524
+ "SELECT invite_code, network_id, role, created_by, max_uses, used_count, expires_at, created_at FROM network_invites WHERE invite_code = ?1",
525
+ inviteCode,
526
+ );
448
527
  if (!invite) return { ok: false, error: "invalid invite code" };
449
528
  if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
450
529
  if (invite.expires_at) {
@@ -469,7 +548,7 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
469
548
  /** Get all networks a user is a member of (replaces owner-only query) */
470
549
  export function getUserAllNetworks(userId: string) {
471
550
  return db.all<any>(
472
- `SELECT n.*, nm.role as member_role
551
+ `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role
473
552
  FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
474
553
  WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
475
554
  userId);