@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.3

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.
@@ -0,0 +1,354 @@
1
+ // RFC-026 v4 P1 — unit tests for state + sweeper (covers H + J live-
2
+ // test deferment per 通信牛 PR #299 review). These exercise the same
3
+ // pure functions the daemon-facing MCP tools call into; the e2e
4
+ // scenarios H/J would only re-verify these same code paths through
5
+ // multi-daemon or crash-sim plumbing, which is Phase 3 work.
6
+
7
+ import { describe, expect, test, beforeEach, afterAll } from "bun:test";
8
+ import { db } from "./db.js";
9
+ import {
10
+ putPendingEnvBlob,
11
+ takePendingEnvBlob,
12
+ peekPendingEnvBlob,
13
+ runOrphanSweepOnce,
14
+ evictExpired,
15
+ finalizeCreateOnFirstRegister,
16
+ newRequestId,
17
+ stopBackgroundTimersForTest,
18
+ resolveCallerDaemonTokenBound,
19
+ } from "./create-node.js";
20
+ import { hashToken } from "./db.js";
21
+
22
+ // ── helpers ────────────────────────────────────────────────────────
23
+ function freshFixtures() {
24
+ // Wipe RFC-026 state between tests. Other suites use the same DB;
25
+ // we only delete from tables RFC-026 owns.
26
+ db.run("DELETE FROM node_create_requests");
27
+ db.run("DELETE FROM api_tokens WHERE role = 'child' OR role = 'test_child' OR (request_id IS NOT NULL AND request_id LIKE 'cr_test_%')");
28
+ stopBackgroundTimersForTest();
29
+ }
30
+
31
+ function insertRequestRow(
32
+ request_id: string,
33
+ daemon_node_id: string,
34
+ child_name: string,
35
+ status: "pending" | "delivered" | "succeeded" | "failed" | "expired",
36
+ created_at_ms: number,
37
+ child_token_id: string | null = null,
38
+ network_id: string = "test_net",
39
+ ) {
40
+ db.run(
41
+ `INSERT INTO node_create_requests
42
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
43
+ VALUES (?1, ?2, ?3, ?4, 'claude-agent-sdk', 'x', '{}', '[]', ?5, ?6, ?7, 'tok_test')`,
44
+ [request_id, daemon_node_id, child_name, network_id, status, child_token_id, created_at_ms],
45
+ );
46
+ }
47
+
48
+ function insertChildTokenRow(token_id: string, request_id: string) {
49
+ db.run(
50
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id)
51
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'child', ?7)`,
52
+ [token_id, `hash_${token_id}`, "u_test_creator", "test_net", `node:test_child_${token_id}`, "network", request_id],
53
+ );
54
+ }
55
+
56
+ // ── tests ──────────────────────────────────────────────────────────
57
+
58
+ beforeEach(() => freshFixtures());
59
+ afterAll(() => stopBackgroundTimersForTest());
60
+
61
+ describe("§4.4 F1 mint-stream-evict — pendingEnvBlobs Map (covers H C2)", () => {
62
+ test("takePendingEnvBlob with WRONG daemon_node_id returns null (C2 cross-daemon guard)", () => {
63
+ const id = newRequestId();
64
+ putPendingEnvBlob({
65
+ request_id: id,
66
+ daemon_node_id: "node_daemon_A",
67
+ env_blob: { ANTHROPIC_API_KEY: "secret-A" },
68
+ child_token: "ntok_test_child_A",
69
+ child_token_id: "tok_test_child_A",
70
+ });
71
+ // daemonB attempts to take daemonA's request → null
72
+ expect(takePendingEnvBlob(id, "node_daemon_B")).toBeNull();
73
+ // But the entry IS NOT consumed by the wrong-caller attempt — daemonA
74
+ // can still take it. (Otherwise an attacker could DoS legit dispatch
75
+ // by spamming wrong-daemon takes.)
76
+ const blob = takePendingEnvBlob(id, "node_daemon_A");
77
+ expect(blob).not.toBeNull();
78
+ expect(blob!.env_blob).toEqual({ ANTHROPIC_API_KEY: "secret-A" });
79
+ expect(blob!.child_token).toBe("ntok_test_child_A");
80
+ });
81
+
82
+ test("takePendingEnvBlob with RIGHT daemon_node_id evicts immediately (F1 one-shot consume)", () => {
83
+ const id = newRequestId();
84
+ putPendingEnvBlob({
85
+ request_id: id,
86
+ daemon_node_id: "node_daemon_X",
87
+ env_blob: { K1: "v1" },
88
+ child_token: "ntok_x",
89
+ child_token_id: "tok_x",
90
+ });
91
+ expect(peekPendingEnvBlob(id)).not.toBeNull();
92
+ expect(takePendingEnvBlob(id, "node_daemon_X")).not.toBeNull();
93
+ // Second take is null — entry was evicted on first successful take
94
+ expect(takePendingEnvBlob(id, "node_daemon_X")).toBeNull();
95
+ expect(peekPendingEnvBlob(id)).toBeNull();
96
+ });
97
+
98
+ test("takePendingEnvBlob with unknown id returns null", () => {
99
+ expect(takePendingEnvBlob("cr_never_existed", "node_anything")).toBeNull();
100
+ });
101
+
102
+ test("evictExpired drops entries past TTL", () => {
103
+ const id = newRequestId();
104
+ putPendingEnvBlob({
105
+ request_id: id,
106
+ daemon_node_id: "node_z",
107
+ env_blob: {},
108
+ child_token: "ntok_z",
109
+ child_token_id: "tok_z",
110
+ });
111
+ // Fast-forward past 60s TTL
112
+ const past = Date.now() + 60_001;
113
+ expect(evictExpired(past)).toBeGreaterThan(0);
114
+ expect(takePendingEnvBlob(id, "node_z")).toBeNull();
115
+ });
116
+ });
117
+
118
+ describe("§4.4.8 C4 orphan sweeper — runOrphanSweepOnce (covers J F-1 + F-2)", () => {
119
+ test("F-1: status='pending' age > TTL → revoke child-ntok + mark status='failed'", () => {
120
+ const reqId = `cr_test_${Date.now()}_F1`;
121
+ const tokId = `tok_test_${Date.now()}_F1`;
122
+ const longAgo = Date.now() - 90_000; // 90s past — well > 60s TTL
123
+ insertChildTokenRow(tokId, reqId);
124
+ insertRequestRow(reqId, "node_daemon_F1", "child_F1", "pending", longAgo, tokId);
125
+ const result = runOrphanSweepOnce();
126
+ expect(result.swept).toBeGreaterThanOrEqual(1);
127
+ expect(result.revoked).toBeGreaterThanOrEqual(1);
128
+ // Verify api_tokens.revoked_at is now set
129
+ const tokRow = db.get<{ revoked_at: string | null }>(
130
+ "SELECT revoked_at FROM api_tokens WHERE token_id = ?1", tokId,
131
+ );
132
+ expect(tokRow?.revoked_at).not.toBeNull();
133
+ // Verify request status flipped to 'failed'
134
+ const reqRow = db.get<{ status: string; error: string | null }>(
135
+ "SELECT status, error FROM node_create_requests WHERE request_id = ?1", reqId,
136
+ );
137
+ expect(reqRow?.status).toBe("failed");
138
+ expect(reqRow?.error).toContain("sweeper_revoked_before_delivery");
139
+ });
140
+
141
+ test("F-2: status='delivered' age > TTL → revoke child-ntok + mark status='expired'", () => {
142
+ const reqId = `cr_test_${Date.now()}_F2`;
143
+ const tokId = `tok_test_${Date.now()}_F2`;
144
+ const longAgo = Date.now() - 120_000; // 120s past
145
+ insertChildTokenRow(tokId, reqId);
146
+ insertRequestRow(reqId, "node_daemon_F2", "child_F2", "delivered", longAgo, tokId);
147
+ const result = runOrphanSweepOnce();
148
+ expect(result.swept).toBeGreaterThanOrEqual(1);
149
+ expect(result.revoked).toBeGreaterThanOrEqual(1);
150
+ const tokRow = db.get<{ revoked_at: string | null }>(
151
+ "SELECT revoked_at FROM api_tokens WHERE token_id = ?1", tokId,
152
+ );
153
+ expect(tokRow?.revoked_at).not.toBeNull();
154
+ const reqRow = db.get<{ status: string; error: string | null }>(
155
+ "SELECT status, error FROM node_create_requests WHERE request_id = ?1", reqId,
156
+ );
157
+ expect(reqRow?.status).toBe("expired");
158
+ expect(reqRow?.error).toContain("sweeper_revoked_after_delivery_no_ack");
159
+ });
160
+
161
+ test("does NOT sweep recent pending/delivered rows (within TTL)", () => {
162
+ const reqId = `cr_test_${Date.now()}_fresh`;
163
+ const tokId = `tok_test_${Date.now()}_fresh`;
164
+ insertChildTokenRow(tokId, reqId);
165
+ // created 5s ago — well within 60s TTL
166
+ insertRequestRow(reqId, "node_daemon_fresh", "child_fresh", "pending", Date.now() - 5_000, tokId);
167
+ const result = runOrphanSweepOnce();
168
+ expect(result.swept).toBe(0);
169
+ expect(result.revoked).toBe(0);
170
+ const tokRow = db.get<{ revoked_at: string | null }>(
171
+ "SELECT revoked_at FROM api_tokens WHERE token_id = ?1", tokId,
172
+ );
173
+ expect(tokRow?.revoked_at).toBeNull();
174
+ const reqRow = db.get<{ status: string }>(
175
+ "SELECT status FROM node_create_requests WHERE request_id = ?1", reqId,
176
+ );
177
+ expect(reqRow?.status).toBe("pending");
178
+ });
179
+
180
+ test("does NOT sweep terminal rows (succeeded/failed/expired)", () => {
181
+ const reqId = `cr_test_${Date.now()}_terminal`;
182
+ const tokId = `tok_test_${Date.now()}_terminal`;
183
+ insertChildTokenRow(tokId, reqId);
184
+ insertRequestRow(reqId, "node_daemon_t", "child_t", "succeeded", Date.now() - 120_000, tokId);
185
+ const result = runOrphanSweepOnce();
186
+ expect(result.swept).toBe(0);
187
+ expect(result.revoked).toBe(0);
188
+ const tokRow = db.get<{ revoked_at: string | null }>(
189
+ "SELECT revoked_at FROM api_tokens WHERE token_id = ?1", tokId,
190
+ );
191
+ expect(tokRow?.revoked_at).toBeNull();
192
+ });
193
+
194
+ test("sweeper is idempotent — second run on same stale row is a no-op", () => {
195
+ const reqId = `cr_test_${Date.now()}_idem`;
196
+ const tokId = `tok_test_${Date.now()}_idem`;
197
+ insertChildTokenRow(tokId, reqId);
198
+ insertRequestRow(reqId, "node_daemon_i", "child_i", "pending", Date.now() - 120_000, tokId);
199
+ const r1 = runOrphanSweepOnce();
200
+ expect(r1.swept).toBe(1);
201
+ const r2 = runOrphanSweepOnce();
202
+ expect(r2.swept).toBe(0); // already terminal after first sweep
203
+ });
204
+ });
205
+
206
+ describe("§4.1.4 C2 token-bound daemon resolution regression (PR #299 BLOCKER #1)", () => {
207
+ // Calls the REAL resolveCallerDaemonTokenBound helper exported from
208
+ // create-node.ts — same code path the daemon-facing MCP tools take
209
+ // (per 通信龙 PR #299 nit 1, no inline-mirror SQL: anti-pattern that
210
+ // would let helper-drift slip past). Setup: two daemons same alias
211
+ // in different networks; two ntoks bound to each; confirm each
212
+ // ntok's resolution returns ITS OWN node row (not the other).
213
+ test("two daemons same alias different networks: helper resolves token to correct node", () => {
214
+ db.run("DELETE FROM nodes WHERE alias = 'daemon-shared'");
215
+ db.run("DELETE FROM api_tokens WHERE token_id IN ('tok_test_A', 'tok_test_B')");
216
+ // daemonA in netA
217
+ db.run(
218
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id, updated_at)
219
+ VALUES ('node_test_daemonA', 'daemon-shared', 'daemon-shared', 'claude-agent-sdk', 'm', 'net_test_A', datetime('now'))`,
220
+ );
221
+ // daemonB in netB — SAME alias
222
+ db.run(
223
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id, updated_at)
224
+ VALUES ('node_test_daemonB', 'daemon-shared', 'daemon-shared', 'claude-agent-sdk', 'm', 'net_test_B', datetime('now'))`,
225
+ );
226
+ // ntok rows bound to each daemon (name='node:daemon-shared'; network_id distinguishes)
227
+ db.run(
228
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope)
229
+ VALUES ('tok_test_A', ?1, 'u_test_creator', 'net_test_A', 'node:daemon-shared', 'network')`,
230
+ [hashToken("ntok_test_A")],
231
+ );
232
+ db.run(
233
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope)
234
+ VALUES ('tok_test_B', ?1, 'u_test_creator', 'net_test_B', 'node:daemon-shared', 'network')`,
235
+ [hashToken("ntok_test_B")],
236
+ );
237
+
238
+ // ntok A → must resolve to node_test_daemonA (NOT daemonB)
239
+ const resA = resolveCallerDaemonTokenBound({
240
+ callerTokenIsNetwork: true,
241
+ callerTokenId: "tok_test_A",
242
+ enforceNetworkId: "net_test_A",
243
+ });
244
+ expect(resA.ok).toBe(true);
245
+ if (resA.ok) {
246
+ expect(resA.daemonNodeId).toBe("node_test_daemonA");
247
+ expect(resA.networkId).toBe("net_test_A");
248
+ }
249
+
250
+ // ntok B → must resolve to node_test_daemonB
251
+ const resB = resolveCallerDaemonTokenBound({
252
+ callerTokenIsNetwork: true,
253
+ callerTokenId: "tok_test_B",
254
+ enforceNetworkId: "net_test_B",
255
+ });
256
+ expect(resB.ok).toBe(true);
257
+ if (resB.ok) expect(resB.daemonNodeId).toBe("node_test_daemonB");
258
+
259
+ // Pre-blocker buggy form (alias only) would be ambiguous —
260
+ // confirm there really ARE 2 rows so the SQL was un-scoped before.
261
+ const all = db.all<{ node_id: string }>(
262
+ `SELECT node_id FROM nodes WHERE alias = ?1`, "daemon-shared",
263
+ );
264
+ expect(all.length).toBe(2);
265
+
266
+ // Mismatched scope: tok_test_A presented with enforceNetworkId=B
267
+ // (compromised caller trying to pivot to wrong net) → reject.
268
+ const resCross = resolveCallerDaemonTokenBound({
269
+ callerTokenIsNetwork: true,
270
+ callerTokenId: "tok_test_A",
271
+ enforceNetworkId: "net_test_B",
272
+ });
273
+ expect(resCross.ok).toBe(false);
274
+
275
+ // utok (callerTokenIsNetwork=false) → reject as not-a-daemon
276
+ const resUtok = resolveCallerDaemonTokenBound({
277
+ callerTokenIsNetwork: false,
278
+ callerTokenId: "tok_test_A",
279
+ enforceNetworkId: "net_test_A",
280
+ });
281
+ expect(resUtok.ok).toBe(false);
282
+
283
+ // Revoked token → reject
284
+ db.run("UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = 'tok_test_A'");
285
+ const resRevoked = resolveCallerDaemonTokenBound({
286
+ callerTokenIsNetwork: true,
287
+ callerTokenId: "tok_test_A",
288
+ enforceNetworkId: "net_test_A",
289
+ });
290
+ expect(resRevoked.ok).toBe(false);
291
+
292
+ db.run("DELETE FROM nodes WHERE node_id IN ('node_test_daemonA', 'node_test_daemonB')");
293
+ db.run("DELETE FROM api_tokens WHERE token_id IN ('tok_test_A', 'tok_test_B')");
294
+ });
295
+
296
+ test("daemon B cannot take/ack daemon A's request even with same alias (Map-level guard)", () => {
297
+ const id = newRequestId();
298
+ putPendingEnvBlob({
299
+ request_id: id,
300
+ daemon_node_id: "node_legit_daemonA",
301
+ env_blob: { ANTHROPIC_API_KEY: "s" },
302
+ child_token: "ntok_a",
303
+ child_token_id: "tok_a",
304
+ });
305
+ // daemonB-token resolves to node_id = node_attacker (different node_id)
306
+ expect(takePendingEnvBlob(id, "node_attacker")).toBeNull();
307
+ // Original entry still there for daemonA
308
+ expect(takePendingEnvBlob(id, "node_legit_daemonA")).not.toBeNull();
309
+ });
310
+ });
311
+
312
+ describe("finalizeCreateOnFirstRegister — content-match on child register", () => {
313
+ test("matching child_name + network_id flips status='succeeded' + stamps child_node_id", () => {
314
+ const reqId = `cr_test_${Date.now()}_finalize`;
315
+ insertRequestRow(reqId, "node_daemon_F", "child_finalize_test", "delivered", Date.now() - 1_000, null, "test_net_finalize");
316
+ const r = finalizeCreateOnFirstRegister({
317
+ node_id: "node_child_real_F",
318
+ alias: "child_finalize_test",
319
+ network_id: "test_net_finalize",
320
+ });
321
+ expect(r.finalized).toBe(1);
322
+ const row = db.get<{ status: string; child_node_id: string | null }>(
323
+ "SELECT status, child_node_id FROM node_create_requests WHERE request_id = ?1", reqId,
324
+ );
325
+ expect(row?.status).toBe("succeeded");
326
+ expect(row?.child_node_id).toBe("node_child_real_F");
327
+ });
328
+
329
+ test("non-matching alias is a no-op", () => {
330
+ const reqId = `cr_test_${Date.now()}_nomatch`;
331
+ insertRequestRow(reqId, "node_daemon_nm", "expected_alias", "pending", Date.now(), null, "test_net_nm");
332
+ const r = finalizeCreateOnFirstRegister({
333
+ node_id: "node_intruder",
334
+ alias: "different_alias",
335
+ network_id: "test_net_nm",
336
+ });
337
+ expect(r.finalized).toBe(0);
338
+ const row = db.get<{ status: string }>(
339
+ "SELECT status FROM node_create_requests WHERE request_id = ?1", reqId,
340
+ );
341
+ expect(row?.status).toBe("pending");
342
+ });
343
+
344
+ test("cross-network: same child_name in different network is a no-op (network_id mismatch)", () => {
345
+ const reqId = `cr_test_${Date.now()}_crossnet`;
346
+ insertRequestRow(reqId, "node_daemon_xn", "shared_name", "delivered", Date.now(), null, "test_net_A");
347
+ const r = finalizeCreateOnFirstRegister({
348
+ node_id: "node_evil",
349
+ alias: "shared_name",
350
+ network_id: "test_net_B", // different network
351
+ });
352
+ expect(r.finalized).toBe(0);
353
+ });
354
+ });
@@ -0,0 +1,299 @@
1
+ // RFC-026 v4 hub-side runtime — pendingEnvBlobs Map (F1 mint-stream-
2
+ // evict) + sweeper (C4 orphan ntok revoke) + content-match finalize
3
+ // helper (called from report_status when a child first registers).
4
+ //
5
+ // The actual MCP tools (create_node / get_create_request /
6
+ // ack_create_request) are registered in tools.ts and call into this
7
+ // module. Keeping the heavy state + sweeper here makes the tool layer
8
+ // thin + this module independently testable.
9
+
10
+ import { db, uuidv4 } from "./db.js";
11
+
12
+ // ── §4.4 F1 — pendingEnvBlobs Map ─────────────────────────────────
13
+ // In-memory ONLY. Never touches disk / never enters the SQL store.
14
+ // Daemon `get_create_request(request_id)` consumes + evicts. Untaken
15
+ // blobs are GC'd at TTL expiry.
16
+
17
+ export interface PendingEnvBlob {
18
+ request_id: string;
19
+ daemon_node_id: string; // hub uses this for C2 cross-daemon check
20
+ env_blob: Record<string, string>; // KEY → secret value (plaintext, never logged)
21
+ child_token: string; // freshly minted ntok_ for the child
22
+ child_token_id: string; // tokens.token_id for sweeper revoke
23
+ expires_at: number; // ms epoch
24
+ }
25
+
26
+ const TTL_MS = 60_000; // 60s per RFC-026 v4 §4.4
27
+ const SWEEPER_INTERVAL_MS = 30_000; // 30s per RFC-026 v4 §4.4.8
28
+ const REAPER_TTL_MS = 60_000; // 60s timeout for pending/delivered rows
29
+
30
+ const pendingEnvBlobs = new Map<string, PendingEnvBlob>();
31
+ let gcTimer: ReturnType<typeof setInterval> | null = null;
32
+ let sweeperTimer: ReturnType<typeof setInterval> | null = null;
33
+
34
+ export function putPendingEnvBlob(b: Omit<PendingEnvBlob, "expires_at">): void {
35
+ pendingEnvBlobs.set(b.request_id, { ...b, expires_at: Date.now() + TTL_MS });
36
+ }
37
+
38
+ /** Get the pending env_blob for daemon. Returns null if not found OR
39
+ * if caller's daemon_node_id doesn't match (C2 cross-daemon guard).
40
+ * Atomically deletes the entry from the Map (one-shot consume). */
41
+ export function takePendingEnvBlob(
42
+ request_id: string,
43
+ callerDaemonNodeId: string,
44
+ ): PendingEnvBlob | null {
45
+ const b = pendingEnvBlobs.get(request_id);
46
+ if (!b) return null;
47
+ if (b.daemon_node_id !== callerDaemonNodeId) return null;
48
+ if (Date.now() > b.expires_at) {
49
+ pendingEnvBlobs.delete(request_id);
50
+ return null;
51
+ }
52
+ pendingEnvBlobs.delete(request_id); // §4.4 evict immediately
53
+ return b;
54
+ }
55
+
56
+ /** Test/diagnostic helper — does NOT consume. */
57
+ export function peekPendingEnvBlob(request_id: string): PendingEnvBlob | null {
58
+ return pendingEnvBlobs.get(request_id) || null;
59
+ }
60
+
61
+ export function evictExpired(now = Date.now()): number {
62
+ let evicted = 0;
63
+ for (const [k, v] of pendingEnvBlobs) {
64
+ if (now > v.expires_at) {
65
+ pendingEnvBlobs.delete(k);
66
+ evicted++;
67
+ }
68
+ }
69
+ return evicted;
70
+ }
71
+
72
+ export function startPendingEnvGcTimer(): void {
73
+ if (gcTimer) return;
74
+ gcTimer = setInterval(() => {
75
+ const n = evictExpired();
76
+ if (n > 0) console.log(`[create-node] GC: evicted ${n} expired env_blob(s)`);
77
+ }, 5_000);
78
+ (gcTimer as any).unref?.();
79
+ }
80
+
81
+ // ── §4.4.8 C4 — orphan ntok revoke sweeper ────────────────────────
82
+ // Scans `node_create_requests` for non-terminal rows older than
83
+ // REAPER_TTL_MS. Atomically marks request as failed/expired AND
84
+ // revokes the matching api_tokens.row (via request_id link) so the
85
+ // orphan child-ntok cannot be used by anyone who scraped it.
86
+
87
+ export function runOrphanSweepOnce(now = Date.now()): { swept: number; revoked: number } {
88
+ const cutoffAge = REAPER_TTL_MS;
89
+ const cutoffMs = now - cutoffAge;
90
+ // Two cohorts (per RFC §4.4.8 case table):
91
+ // F-1 status='pending' age > TTL → hub crashed before daemon get
92
+ // F-2 status='delivered' age > TTL → daemon got blob but never ack'd
93
+ const stale = db.all<{ request_id: string; status: string; child_token_id: string | null }>(
94
+ `SELECT request_id, status, child_token_id
95
+ FROM node_create_requests
96
+ WHERE status IN ('pending', 'delivered')
97
+ AND created_at < ?1`,
98
+ cutoffMs,
99
+ );
100
+ if (stale.length === 0) return { swept: 0, revoked: 0 };
101
+
102
+ let swept = 0; let revoked = 0;
103
+ // SQLite Bun driver doesn't expose BEGIN/COMMIT cleanly across the
104
+ // wrapper here; fold per-row into a transaction.
105
+ try {
106
+ db.exec("BEGIN");
107
+ for (const row of stale) {
108
+ // Revoke the child-ntok first (sweeper is server-side ground
109
+ // truth per §4.4.8). If child_token_id null somehow, skip
110
+ // revoke (request never made it that far) but still mark
111
+ // failed.
112
+ if (row.child_token_id) {
113
+ // db.run returns the SQLite adapter's RunResult (changes counter
114
+ // if driver-supported). Only count a revoke when the UPDATE
115
+ // actually touched a row — under the bun:sqlite adapter the
116
+ // changes prop is reliable; on adapters returning undefined we
117
+ // fall back to a post-UPDATE SELECT to confirm revoked_at IS
118
+ // NOT NULL (DB is ground truth). Avoids over-counting when
119
+ // child_token_id is stale or already revoked. (通信龙 nit 2)
120
+ const r = db.run(
121
+ `UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`,
122
+ [row.child_token_id],
123
+ );
124
+ const changes = (r as any)?.changes;
125
+ if (typeof changes === "number") {
126
+ if (changes > 0) revoked++;
127
+ } else {
128
+ // Adapter didn't report; verify via SELECT
129
+ const after = db.get<{ revoked_at: string | null }>(
130
+ `SELECT revoked_at FROM api_tokens WHERE token_id = ?1`, row.child_token_id,
131
+ );
132
+ if (after?.revoked_at) revoked++;
133
+ }
134
+ }
135
+ const newStatus = row.status === "pending" ? "failed" : "expired";
136
+ const err = row.status === "pending" ? "sweeper_revoked_before_delivery" : "sweeper_revoked_after_delivery_no_ack";
137
+ db.run(
138
+ `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
139
+ [newStatus, err, now, row.request_id],
140
+ );
141
+ // Also drop any lingering env_blob (defense; should already be
142
+ // gone for delivered, but pending could leak if GC hasn't run).
143
+ pendingEnvBlobs.delete(row.request_id);
144
+ swept++;
145
+ }
146
+ db.exec("COMMIT");
147
+ } catch (e: any) {
148
+ try { db.exec("ROLLBACK"); } catch { /* swallow */ }
149
+ console.warn(`[create-node] sweeper transaction failed: ${e?.message || e}`);
150
+ return { swept: 0, revoked: 0 };
151
+ }
152
+ if (swept > 0) {
153
+ console.log(`[commhub] ✓ create-node sweeper: swept ${swept} stale request(s), revoked ${revoked} child-ntok(s)`);
154
+ auditCreateNode({
155
+ action: "create_node_sweeper_revoked",
156
+ detail: { swept, revoked, sweeper_run_at_ms: now },
157
+ });
158
+ }
159
+ return { swept, revoked };
160
+ }
161
+
162
+ export function startSweeperTimer(): void {
163
+ if (sweeperTimer) return;
164
+ // Boot-time sweep: catch any rows left over from a prior process
165
+ // crash (case F-1: hub crashed after mint, before daemon get).
166
+ try { runOrphanSweepOnce(); } catch (e: any) {
167
+ console.warn(`[create-node] boot sweeper failed: ${e?.message || e}`);
168
+ }
169
+ sweeperTimer = setInterval(() => {
170
+ try { runOrphanSweepOnce(); }
171
+ catch (e: any) { console.warn(`[create-node] periodic sweeper failed: ${e?.message || e}`); }
172
+ }, SWEEPER_INTERVAL_MS);
173
+ (sweeperTimer as any).unref?.();
174
+ }
175
+
176
+ export function stopBackgroundTimersForTest(): void {
177
+ if (gcTimer) { clearInterval(gcTimer); gcTimer = null; }
178
+ if (sweeperTimer) { clearInterval(sweeperTimer); sweeperTimer = null; }
179
+ pendingEnvBlobs.clear();
180
+ }
181
+
182
+ // ── content-match finalize on child first register ────────────────
183
+ // Called from tools.ts report_status handler when a node registers (or
184
+ // re-registers). If there's a pending node_create_requests row whose
185
+ // `child_name` matches the incoming alias AND the daemon_node_id is
186
+ // in caller's network, mark it succeeded and stamp the real
187
+ // child_node_id. Mirrors RFC-024 finalizePendingMatchingUpdates.
188
+
189
+ export function finalizeCreateOnFirstRegister(
190
+ incoming: { node_id: string; alias: string; network_id: string | null },
191
+ ): { finalized: number; matchedIds: string[] } {
192
+ if (!incoming.node_id || !incoming.alias) return { finalized: 0, matchedIds: [] };
193
+ const net = incoming.network_id ?? "default";
194
+ const pending = db.all<{ request_id: string; daemon_node_id: string }>(
195
+ `SELECT request_id, daemon_node_id FROM node_create_requests
196
+ WHERE child_name = ?1 AND network_id = ?2 AND status IN ('delivered', 'pending')
197
+ ORDER BY created_at ASC`,
198
+ incoming.alias, net,
199
+ );
200
+ if (pending.length === 0) return { finalized: 0, matchedIds: [] };
201
+
202
+ const matchedIds: string[] = [];
203
+ for (const row of pending) {
204
+ const r = db.run(
205
+ `UPDATE node_create_requests
206
+ SET status = 'succeeded', child_node_id = ?1, acked_at = ?2
207
+ WHERE request_id = ?3 AND status IN ('delivered', 'pending')`,
208
+ [incoming.node_id, Date.now(), row.request_id],
209
+ );
210
+ matchedIds.push(row.request_id);
211
+ console.log(`[commhub] ✓ create-node finalize: request=${row.request_id} child=${incoming.alias} (${incoming.node_id}) daemon=${row.daemon_node_id}`);
212
+ auditCreateNode({
213
+ action: "create_node_succeeded",
214
+ network_id: net,
215
+ target_id: row.request_id,
216
+ detail: { child_node_id: incoming.node_id, child_alias: incoming.alias, daemon_node_id: row.daemon_node_id },
217
+ });
218
+ }
219
+ return { finalized: matchedIds.length, matchedIds };
220
+ }
221
+
222
+ export function newRequestId(): string {
223
+ return `cr_${uuidv4()}`;
224
+ }
225
+
226
+ // §4.1.4 C2 — resolve the caller daemon's node row by **token-bound
227
+ // identity** (PR #299 BLOCKER #1). Module-level so unit tests can
228
+ // import + call directly (per 通信龙 nit 1 — don't inline-mirror SQL
229
+ // in tests; that's exactly the helper-drift anti-pattern the tools.ts
230
+ // comment warns against).
231
+ export type DaemonResolveResult =
232
+ | { ok: true; daemonNodeId: string; daemonAlias: string; networkId: string }
233
+ | { ok: false; error: string };
234
+
235
+ export function resolveCallerDaemonTokenBound(opts: {
236
+ callerTokenIsNetwork: boolean;
237
+ callerTokenId: string | null | undefined;
238
+ enforceNetworkId: string | null | undefined;
239
+ }): DaemonResolveResult {
240
+ if (!opts.callerTokenIsNetwork || !opts.callerTokenId) {
241
+ return { ok: false, error: "caller_not_a_daemon" };
242
+ }
243
+ if (!opts.enforceNetworkId) {
244
+ return { ok: false, error: "caller_not_a_daemon" };
245
+ }
246
+ const tokRow = db.get<{ name: string; network_id: string | null }>(
247
+ `SELECT name, network_id FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
248
+ opts.callerTokenId,
249
+ );
250
+ if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
251
+ return { ok: false, error: "caller_not_a_daemon" };
252
+ }
253
+ if (tokRow.network_id !== opts.enforceNetworkId) {
254
+ return { ok: false, error: "caller_not_a_daemon" };
255
+ }
256
+ const tokenAlias = tokRow.name.slice(5);
257
+ const nodeRow = db.get<{ node_id: string; alias: string; network_id: string }>(
258
+ `SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2 LIMIT 1`,
259
+ tokenAlias, tokRow.network_id,
260
+ );
261
+ if (!nodeRow) {
262
+ return { ok: false, error: "caller_not_a_daemon" };
263
+ }
264
+ return {
265
+ ok: true,
266
+ daemonNodeId: nodeRow.node_id,
267
+ daemonAlias: nodeRow.alias,
268
+ networkId: nodeRow.network_id,
269
+ };
270
+ }
271
+
272
+ // RFC-026 §4.5 — append a row to audit_log for every create_node
273
+ // lifecycle event. Best-effort: never throw out (calling tool should
274
+ // continue even if audit insert fails for any reason).
275
+ export function auditCreateNode(input: {
276
+ action: "create_node_dispatched" | "create_node_rejected" | "create_node_succeeded" | "create_node_sweeper_revoked";
277
+ user_id?: string | null;
278
+ username?: string | null;
279
+ network_id?: string | null;
280
+ target_id?: string | null; // request_id
281
+ detail: Record<string, unknown>;
282
+ }): void {
283
+ try {
284
+ db.run(
285
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
286
+ VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
287
+ [
288
+ input.user_id || null,
289
+ input.username || null,
290
+ input.action,
291
+ input.target_id || null,
292
+ JSON.stringify(input.detail),
293
+ input.network_id || null,
294
+ ],
295
+ );
296
+ } catch (e: any) {
297
+ console.warn(`[commhub] audit_log insert (create_node) failed: ${e?.message || e}`);
298
+ }
299
+ }