@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.12

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/src/db.ts CHANGED
@@ -117,12 +117,43 @@ db.exec(`
117
117
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
118
118
  );
119
119
 
120
- CREATE INDEX IF NOT EXISTS idx_agent_telemetry_host_time
121
- ON agent_telemetry(network_id, hostname, ip, created_at);
122
120
  CREATE INDEX IF NOT EXISTS idx_agent_telemetry_alias_time
123
121
  ON agent_telemetry(network_id, alias, created_at);
124
122
  `);
125
123
 
124
+ // Round-2/4 review ② — server-health index split.
125
+ //
126
+ // The /api/server-health/:host query (index.ts) filters
127
+ // WHERE (hostname = ?1 OR ip = ?2) AND created_at >= ?3
128
+ // The old combined index (network_id, hostname, ip, created_at) only
129
+ // covers an equality on hostname AND ip (not OR), so SQLite couldn't
130
+ // use it for the disjunction and fell back to a full scan of
131
+ // agent_telemetry on every server-health page load. At a 30s heartbeat
132
+ // × N agents, the table grows fast and the scan dominates.
133
+ //
134
+ // Split into two narrow indexes so SQLite's OR optimizer can run an
135
+ // index UNION on (hostname-path ∪ ip-path), each path covered by an
136
+ // index that also lets the created_at range be range-scanned.
137
+ //
138
+ // **Honest trade-off** (corrected after reviewer flag): secondary
139
+ // index count goes from 2 (host_time + alias_time) to 3 (hostname_time
140
+ // + ip_time + alias_time). Every agent_telemetry insert now maintains
141
+ // ONE MORE B-tree, so per-insert write-amp is +50%. The trade-off is
142
+ // deliberate: write-amp is bounded by the heartbeat cadence (30s ×
143
+ // N agents) and is small in absolute terms, while the read-path win
144
+ // is going from O(n) full-scan-per-dashboard-poll to O(log n) two-
145
+ // branch index union. For the public-facing hub's expected load
146
+ // shape (many readers, few writers), that's a clear net gain.
147
+ try { db.exec("DROP INDEX IF EXISTS idx_agent_telemetry_host_time"); } catch {}
148
+ try {
149
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_telemetry_hostname_time
150
+ ON agent_telemetry(network_id, hostname, created_at)`);
151
+ } catch {}
152
+ try {
153
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_telemetry_ip_time
154
+ ON agent_telemetry(network_id, ip, created_at)`);
155
+ } catch {}
156
+
126
157
  // inbox: add in_reply_to, requires_response, expires_at, scope, node identity
127
158
  for (const col of [
128
159
  { name: "in_reply_to", def: "TEXT" },
@@ -246,6 +277,137 @@ try {
246
277
  if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
247
278
  }
248
279
 
280
+ // RFC-024 (2026-06-28): node config-apply foundation.
281
+ //
282
+ // `nodes` table gains two columns:
283
+ // - `config_revision`: monotonically-incrementing per-node revision
284
+ // that gates write conflicts (dashboard sends base_revision, hub
285
+ // rejects 409 if it doesn't match current). Promoted by hub when
286
+ // the node ACKs `applied` from a successful apply.
287
+ // - `config_snapshot`: masked JSON of the node's current effective
288
+ // config, posted by the node via report_status. Dashboard reads
289
+ // this for the GET snapshot path (never touches per-node files).
290
+ //
291
+ // Both ALTERs wrapped in try/catch for the same reason as
292
+ // must_change_password above (no IF NOT EXISTS on SQLite < 3.35).
293
+ try {
294
+ db.exec(`ALTER TABLE nodes ADD COLUMN config_revision INTEGER DEFAULT 0`);
295
+ } catch (e: any) {
296
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
297
+ }
298
+ try {
299
+ db.exec(`ALTER TABLE nodes ADD COLUMN config_snapshot TEXT`);
300
+ } catch (e: any) {
301
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
302
+ }
303
+
304
+ // RFC-026 §9 / #338 PR2 — daemon self-declare fields promoted from
305
+ // config_snapshot to first-class indexable columns. The hub
306
+ // `list_host_supervisors` MCP tool reads these directly without parsing
307
+ // the snapshot JSON on every list call.
308
+ //
309
+ // Both fields are JSON arrays of strings:
310
+ // runtimes_supported e.g. ["claude-agent-sdk","codex-sdk","grok-build-acp"]
311
+ // allowed_secret_keys e.g. ["ANTHROPIC_API_KEY","OPENAI_API_KEY"]
312
+ //
313
+ // Stored as TEXT (JSON-encoded) — SQLite has no native array type and
314
+ // the wrapper code is small. Both nullable for pre-PR2 daemons; the
315
+ // list endpoint falls back to [] when null.
316
+ try {
317
+ db.exec(`ALTER TABLE nodes ADD COLUMN runtimes_supported TEXT`);
318
+ } catch (e: any) {
319
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
320
+ }
321
+ try {
322
+ db.exec(`ALTER TABLE nodes ADD COLUMN allowed_secret_keys TEXT`);
323
+ } catch (e: any) {
324
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
325
+ }
326
+
327
+ // RFC-027 §2.3 — state machine for stop/delete (D5). 'active' default
328
+ // matches pre-RFC behavior for every existing row. Transitions:
329
+ // active → stopping → stopped → (back to active via restart_node)
330
+ // active → deleting → (row gone)
331
+ // stopped → deleting → (row gone)
332
+ // Used as the inbox-enqueue gate per §2.3 race-free invariant:
333
+ // pushEvent / INSERT INTO inbox must refuse a non-active target so the
334
+ // SIGTERM-in-flight window never gets a new task.
335
+ try {
336
+ db.exec(`ALTER TABLE nodes ADD COLUMN lifecycle_state TEXT DEFAULT 'active'`);
337
+ } catch (e: any) {
338
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
339
+ }
340
+
341
+ // RFC-027 §2 — stop/delete request envelope. Mirrors
342
+ // node_create_requests structure so the daemon-side pull/ack pattern
343
+ // is symmetric with create_node. action='stop'|'delete' picks which
344
+ // branch (delete adds backup_path + revokes ntok on ack).
345
+ db.exec(`
346
+ CREATE TABLE IF NOT EXISTS node_stop_requests (
347
+ request_id TEXT PRIMARY KEY,
348
+ network_id TEXT NOT NULL,
349
+ daemon_node_id TEXT NOT NULL,
350
+ child_node_id TEXT NOT NULL,
351
+ child_alias TEXT NOT NULL,
352
+ action TEXT NOT NULL CHECK (action IN ('stop', 'delete')),
353
+ delete_config INTEGER NOT NULL DEFAULT 1,
354
+ grace_seconds INTEGER NOT NULL DEFAULT 10,
355
+ force INTEGER NOT NULL DEFAULT 0,
356
+ in_flight_at_dispatch INTEGER NOT NULL DEFAULT 0,
357
+ created_by_token TEXT NOT NULL,
358
+ status TEXT NOT NULL DEFAULT 'pending',
359
+ error TEXT,
360
+ backup_path TEXT,
361
+ exit_signal TEXT,
362
+ created_at INTEGER NOT NULL,
363
+ delivered_at INTEGER,
364
+ acked_at INTEGER
365
+ )
366
+ `);
367
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_daemon ON node_stop_requests(daemon_node_id, status)`);
368
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_child ON node_stop_requests(child_node_id)`);
369
+
370
+ // `node_config_updates` — pending + history. One row per dashboard write.
371
+ // At most one row per node may be in a non-terminal state at a time
372
+ // (single-flight enforced at the tool layer; this table just stores).
373
+ //
374
+ // network_id is denormalized from nodes(network_id) at insert time so
375
+ // queries don't need to JOIN through nodes every time + so a node
376
+ // deletion doesn't orphan the history (audit retention).
377
+ db.exec(`
378
+ CREATE TABLE IF NOT EXISTS node_config_updates (
379
+ update_id TEXT PRIMARY KEY,
380
+ node_id TEXT NOT NULL,
381
+ network_id TEXT NOT NULL,
382
+ patch_json TEXT NOT NULL,
383
+ apply_mode TEXT NOT NULL,
384
+ base_revision INTEGER NOT NULL,
385
+ status TEXT NOT NULL,
386
+ error TEXT,
387
+ created_at INTEGER NOT NULL,
388
+ created_by_token TEXT NOT NULL,
389
+ acked_at INTEGER,
390
+ new_revision INTEGER
391
+ );
392
+
393
+ CREATE INDEX IF NOT EXISTS idx_ncu_node_status ON node_config_updates(node_id, status);
394
+ CREATE INDEX IF NOT EXISTS idx_ncu_network ON node_config_updates(network_id);
395
+ `);
396
+
397
+ // F-C (CHANGE_REQ): partial unique index on non-terminal rows. The
398
+ // single-flight check in tools.ts is an app-level check-then-INSERT —
399
+ // safe under single-process Bun, but two hub workers could race and
400
+ // double-insert. This index makes that race impossible at the DB
401
+ // layer regardless of process count. Wrapped in try/catch because
402
+ // SQLite < 3.8.0 doesn't support partial indexes (rare on modern
403
+ // systems; if missing, app-level check is the fallback).
404
+ try {
405
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_ncu_node_inflight ON node_config_updates(node_id) WHERE status IN ('pending', 'restarting')`);
406
+ } catch (e: any) {
407
+ // Older SQLite — fall back to app-level single-flight only.
408
+ console.warn(`[commhub] partial unique index uniq_ncu_node_inflight skipped: ${e?.message || e}`);
409
+ }
410
+
249
411
  // ── V3: networks table ──
250
412
  db.exec(`
251
413
  CREATE TABLE IF NOT EXISTS networks (
@@ -280,6 +442,136 @@ db.exec(`
280
442
  CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
281
443
  `);
282
444
 
445
+ // RFC-026 v4 §4.4.8 C4 — api_tokens metadata so sweeper can revoke
446
+ // orphan child-ntoks without ever touching plaintext tokens. ALTER in
447
+ // try/catch (idempotent for already-migrated DBs).
448
+ // request_id — links a child token to the create_node request that
449
+ // minted it; sweeper joins on this
450
+ // revoked_at — explicit revoke; resolveToken treats non-NULL as
451
+ // invalid even before expiry
452
+ for (const ddl of [
453
+ "ALTER TABLE api_tokens ADD COLUMN request_id TEXT",
454
+ "ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
455
+ "ALTER TABLE api_tokens ADD COLUMN role TEXT",
456
+ ]) {
457
+ try { db.exec(ddl); }
458
+ catch (e: any) {
459
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
460
+ }
461
+ }
462
+ try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
463
+ catch (e: any) { /* index may already exist */ void e; }
464
+
465
+ // RFC-026 v4 §2.5 + §4.4 — node_create_requests. Metadata only;
466
+ // env_blob NEVER lives in this table (F1 mint-stream-evict — secret
467
+ // blob is in-memory Map keyed by request_id, drained on daemon get,
468
+ // TTL 60s GC).
469
+ //
470
+ // env_keys is a JSON array of secret KEY NAMES only (for audit). The
471
+ // actual values flow through pendingEnvBlobs Map and never touch
472
+ // disk.
473
+ db.exec(`
474
+ CREATE TABLE IF NOT EXISTS node_create_requests (
475
+ request_id TEXT PRIMARY KEY,
476
+ daemon_node_id TEXT NOT NULL,
477
+ child_name TEXT NOT NULL,
478
+ network_id TEXT NOT NULL,
479
+ runtime TEXT NOT NULL,
480
+ model TEXT NOT NULL,
481
+ flags_json TEXT NOT NULL,
482
+ env_keys TEXT NOT NULL,
483
+ status TEXT NOT NULL,
484
+ error TEXT,
485
+ child_token_id TEXT,
486
+ created_at INTEGER NOT NULL,
487
+ created_by_token TEXT NOT NULL,
488
+ delivered_at INTEGER,
489
+ acked_at INTEGER,
490
+ child_node_id TEXT
491
+ );
492
+
493
+ CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
494
+ CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
495
+ CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
496
+ `);
497
+ try {
498
+ // Single-flight per (daemon, child_name) — prevents racing dashboard
499
+ // create on same name. Mirrors RFC-024 uniq_ncu_node_inflight.
500
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_ncr_inflight ON node_create_requests(daemon_node_id, child_name) WHERE status IN ('pending', 'delivered')`);
501
+ } catch (e: any) {
502
+ console.warn(`[commhub] partial unique index uniq_ncr_inflight skipped: ${e?.message || e}`);
503
+ }
504
+
505
+ // RFC-028 P1 v4 §2.2 — providers + provider_models + network_secrets +
506
+ // probe_results. All 4 tables idempotent CREATE TABLE IF NOT EXISTS. F2
507
+ // lazy gate: tables created on hub boot regardless; vault master key
508
+ // (ANET_HUB_SECRET_VAULT_KEY env) only required when these tables get
509
+ // their first non-empty row (lazy in vault.ts). Empty tables = hub
510
+ // boots fine without env (critical for existing prod hub升级 不 brick).
511
+ db.exec(`
512
+ CREATE TABLE IF NOT EXISTS providers (
513
+ provider_id TEXT PRIMARY KEY,
514
+ network_id TEXT NOT NULL,
515
+ name TEXT NOT NULL,
516
+ vendor TEXT NOT NULL,
517
+ base_url TEXT NOT NULL,
518
+ secret_key_ref TEXT NOT NULL,
519
+ created_at INTEGER NOT NULL,
520
+ created_by TEXT NOT NULL,
521
+ enabled INTEGER NOT NULL DEFAULT 1,
522
+ UNIQUE(network_id, name)
523
+ );
524
+ CREATE INDEX IF NOT EXISTS idx_providers_network ON providers(network_id);
525
+ `);
526
+ db.exec(`
527
+ CREATE TABLE IF NOT EXISTS provider_models (
528
+ model_id TEXT PRIMARY KEY,
529
+ provider_id TEXT NOT NULL,
530
+ model_name TEXT NOT NULL,
531
+ display_name TEXT,
532
+ context_window INTEGER,
533
+ supports_vision INTEGER NOT NULL DEFAULT 0,
534
+ enabled INTEGER NOT NULL DEFAULT 1,
535
+ created_at INTEGER NOT NULL,
536
+ UNIQUE(provider_id, model_name)
537
+ );
538
+ CREATE INDEX IF NOT EXISTS idx_models_provider ON provider_models(provider_id);
539
+ `);
540
+ // network_secrets — encrypted-at-rest vault. ciphertext + iv + tag are
541
+ // AES-GCM output; plaintext NEVER touches this table. master key from
542
+ // ANET_HUB_SECRET_VAULT_KEY env (lazy gate in vault.ts).
543
+ db.exec(`
544
+ CREATE TABLE IF NOT EXISTS network_secrets (
545
+ network_id TEXT NOT NULL,
546
+ key TEXT NOT NULL,
547
+ ciphertext BLOB NOT NULL,
548
+ iv BLOB NOT NULL,
549
+ tag BLOB NOT NULL,
550
+ created_at INTEGER NOT NULL,
551
+ updated_at INTEGER NOT NULL,
552
+ PRIMARY KEY (network_id, key)
553
+ );
554
+ `);
555
+ // probe_results — connectivity matrix history. v3 R3 lock: error_label
556
+ // is HUB-DERIVED ONLY from (status, raw_status_code) — daemon CANNOT
557
+ // submit arbitrary error string. status enum mirrors ProbeAckPayload.
558
+ db.exec(`
559
+ CREATE TABLE IF NOT EXISTS probe_results (
560
+ probe_id TEXT PRIMARY KEY,
561
+ provider_id TEXT NOT NULL,
562
+ model_name TEXT NOT NULL,
563
+ daemon_node_id TEXT NOT NULL,
564
+ network_id TEXT NOT NULL,
565
+ status TEXT NOT NULL,
566
+ latency_ms INTEGER,
567
+ error_label TEXT,
568
+ probed_at INTEGER NOT NULL,
569
+ probed_by_user TEXT,
570
+ raw_status_code INTEGER
571
+ );
572
+ CREATE INDEX IF NOT EXISTS idx_probe_matrix ON probe_results(network_id, provider_id, model_name, daemon_node_id, probed_at DESC);
573
+ `);
574
+
283
575
  // ── V3: audit_log table ──
284
576
  db.exec(`
285
577
  CREATE TABLE IF NOT EXISTS audit_log (
@@ -707,8 +999,179 @@ export function generateId(prefix: string): string {
707
999
  return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
708
1000
  }
709
1001
 
710
- export function hashPassword(password: string): string {
711
- return new Bun.CryptoHasher("sha256").update(`anet:${password}`).digest("hex");
1002
+ // ──────────────────────────────────────────────────────────────────
1003
+ // Round-6 A1 — Password hashing: salted scrypt with lazy migration
1004
+ //
1005
+ // Format: `scrypt$<N>$<salt-b64>$<hash-b64>`
1006
+ // N = log2(cost). E.g. 14 → 2^14 = 16384 (~50ms on modern hw).
1007
+ // salt-b64 = base64 of 16 random bytes.
1008
+ // hash-b64 = base64 of 64-byte scrypt output.
1009
+ //
1010
+ // Why scrypt: Node built-in crypto.scryptSync — zero new deps.
1011
+ // Memory-hard, ASIC-resistant. Better than bcrypt under modern GPU
1012
+ // attack. argon2id would be marginally better but pulls a native
1013
+ // dep (argon2 npm package) we don't want on the hub.
1014
+ //
1015
+ // Why "$" delimiter: legacy hashes are bare hex (0-9a-f only). Any
1016
+ // "$" in stored hash means new format. Trivial detect with zero
1017
+ // false-positives.
1018
+ //
1019
+ // Lazy migration:
1020
+ // - register/bootstrap/changePassword/resetUserPassword always
1021
+ // write the new format.
1022
+ // - login + changePassword (old-verify) accept BOTH formats.
1023
+ // - on successful login against a legacy hash, the caller rehashes
1024
+ // in place. Zero downtime, no forced password change.
1025
+ //
1026
+ // See feedback_assume_unit_before_threshold.md: explicit-set
1027
+ // crypto parameters; don't rely on library defaults.
1028
+ // ──────────────────────────────────────────────────────────────────
1029
+
1030
+ import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
1031
+
1032
+ // Default cost parameter (N = 2^14 = 16384). Tunable via env for
1033
+ // test suites that don't want to spend ~50ms per scrypt call.
1034
+ // N=10 → 2^10 = 1024 iter, ~5ms (test-only)
1035
+ // N=14 → 2^14 = 16384 iter, ~50ms (production)
1036
+ // N=15 → 2^15 = 32768 iter, ~100ms (if hw improves and ops want headroom)
1037
+ const DEFAULT_SCRYPT_N = 14;
1038
+ const SCRYPT_R = 8;
1039
+ const SCRYPT_P = 1;
1040
+ const SCRYPT_KEYLEN = 64;
1041
+ const SCRYPT_SALT_BYTES = 16;
1042
+ // Bun's scrypt defaults maxmem to ~32 MiB which is too tight for
1043
+ // N ≥ 14 (128 * r * 2^N = ~16 MiB at r=8, N=14; default has no
1044
+ // headroom for the internal buffers). Set explicitly so a higher-N
1045
+ // upgrade later doesn't suddenly throw at runtime.
1046
+ const SCRYPT_MAXMEM = 128 * 1024 * 1024; // 128 MiB
1047
+
1048
+ function getScryptN(): number {
1049
+ const raw = process.env.COMMHUB_SCRYPT_N;
1050
+ if (!raw) return DEFAULT_SCRYPT_N;
1051
+ const n = parseInt(raw, 10);
1052
+ // Bound: 8 (way-too-weak, only for unit tests) — 20 (DoS risk).
1053
+ if (!Number.isFinite(n) || n < 8 || n > 20) return DEFAULT_SCRYPT_N;
1054
+ return n;
1055
+ }
1056
+
1057
+ export function hashPassword(plain: string): string {
1058
+ const N = getScryptN();
1059
+ const salt = randomBytes(SCRYPT_SALT_BYTES);
1060
+ const hash = scryptSync(plain, salt, SCRYPT_KEYLEN, {
1061
+ N: 1 << N,
1062
+ r: SCRYPT_R,
1063
+ p: SCRYPT_P,
1064
+ maxmem: SCRYPT_MAXMEM,
1065
+ });
1066
+ return `scrypt$${N}$${salt.toString("base64")}$${hash.toString("base64")}`;
1067
+ }
1068
+
1069
+ /**
1070
+ * Verify a plaintext password against the stored hash. Accepts BOTH
1071
+ * the new scrypt format and the legacy bare-hex SHA-256 format
1072
+ * (back-compat for pre-A1 deployments).
1073
+ *
1074
+ * Returns:
1075
+ * ok — true iff the password matches.
1076
+ * needsRehash — true iff the stored hash was legacy format AND the
1077
+ * password matched. The caller MUST rehash and write
1078
+ * the new format back to disk so the legacy hash
1079
+ * stops existing. Returned false for already-new
1080
+ * hashes (no rehash needed even on N upgrade —
1081
+ * follow-up if/when we bump default N).
1082
+ */
1083
+ export function verifyPassword(plain: string, stored: string): { ok: boolean; needsRehash: boolean } {
1084
+ // Format detection: new format always contains "$". Legacy bare
1085
+ // sha256 hex never does (0-9a-f only).
1086
+ if (stored.includes("$")) {
1087
+ const parts = stored.split("$");
1088
+ if (parts.length !== 4 || parts[0] !== "scrypt") {
1089
+ // Unknown new-style scheme — be conservative, reject.
1090
+ return { ok: false, needsRehash: false };
1091
+ }
1092
+ const N = parseInt(parts[1], 10);
1093
+ if (!Number.isFinite(N) || N < 8 || N > 20) return { ok: false, needsRehash: false };
1094
+ let saltBuf: Buffer;
1095
+ let expectedBuf: Buffer;
1096
+ try {
1097
+ saltBuf = Buffer.from(parts[2], "base64");
1098
+ expectedBuf = Buffer.from(parts[3], "base64");
1099
+ } catch {
1100
+ return { ok: false, needsRehash: false };
1101
+ }
1102
+ if (expectedBuf.length === 0) return { ok: false, needsRehash: false };
1103
+ let actualBuf: Buffer;
1104
+ try {
1105
+ actualBuf = scryptSync(plain, saltBuf, expectedBuf.length, {
1106
+ N: 1 << N,
1107
+ r: SCRYPT_R,
1108
+ p: SCRYPT_P,
1109
+ maxmem: SCRYPT_MAXMEM,
1110
+ });
1111
+ } catch {
1112
+ return { ok: false, needsRehash: false };
1113
+ }
1114
+ // timingSafeEqual requires equal-length buffers; we sized actual
1115
+ // to expected.length above so this is safe.
1116
+ const ok = timingSafeEqual(actualBuf, expectedBuf);
1117
+ return { ok, needsRehash: false };
1118
+ }
1119
+
1120
+ // Legacy bare-hex SHA-256 format (pre-A1).
1121
+ const legacy = new Bun.CryptoHasher("sha256").update(`anet:${plain}`).digest("hex");
1122
+ // Use timingSafeEqual on byte buffers (hex strings would be
1123
+ // compared via JS === which is variable-time on first-differing
1124
+ // char — the timing leak is tiny for fixed-format hex but pin the
1125
+ // habit). Both are exactly 64 hex chars when sha256 produced.
1126
+ if (stored.length !== legacy.length) {
1127
+ // Length mismatch is itself indistinguishable from a parse miss
1128
+ // — but we still pay the scrypt to keep this branch's timing
1129
+ // peer to the verify branches below.
1130
+ burnEqualTimeScrypt(plain);
1131
+ return { ok: false, needsRehash: false };
1132
+ }
1133
+ let ok = false;
1134
+ try {
1135
+ ok = timingSafeEqual(Buffer.from(stored, "hex"), Buffer.from(legacy, "hex"));
1136
+ } catch {
1137
+ burnEqualTimeScrypt(plain);
1138
+ return { ok: false, needsRehash: false };
1139
+ }
1140
+ // Timing-oracle close (round-6 round-2 — independent reviewer flag):
1141
+ // legacy-OK path callers (auth.ts:login) do `hashPassword(password)`
1142
+ // immediately after to rehash → one scrypt cost on top of
1143
+ // this function's return. ~50ms.
1144
+ // legacy-WRONG path callers reject and return → ZERO additional cost.
1145
+ //
1146
+ // That asymmetry leaks "is this user on the legacy hash" via wall-
1147
+ // clock difference (sub-ms wrong vs ~50ms ok). To close it, burn an
1148
+ // equal-time scrypt on the wrong-password branch too. Now both
1149
+ // legacy paths cost SHA-256 + one scrypt, matching new-format paths
1150
+ // and the missing-user dummy-scrypt path.
1151
+ if (!ok) {
1152
+ burnEqualTimeScrypt(plain);
1153
+ }
1154
+ return { ok, needsRehash: ok };
1155
+ }
1156
+
1157
+ // Round-6 round-2 hardening — throwaway scrypt to equalize timing on
1158
+ // failure paths. Burns the same wall-clock cost as a real
1159
+ // hashPassword() call. Result discarded; salt is a fresh random so
1160
+ // the work is real (not optimised away). Cost ~N=14 → 50ms,
1161
+ // COMMHUB_SCRYPT_N controlled like the real path.
1162
+ function burnEqualTimeScrypt(plain: string): void {
1163
+ try {
1164
+ const N = getScryptN();
1165
+ scryptSync(plain, randomBytes(SCRYPT_SALT_BYTES), SCRYPT_KEYLEN, {
1166
+ N: 1 << N,
1167
+ r: SCRYPT_R,
1168
+ p: SCRYPT_P,
1169
+ maxmem: SCRYPT_MAXMEM,
1170
+ });
1171
+ } catch {
1172
+ // Even on failure to scrypt (shouldn't happen), don't surface —
1173
+ // this is a timing pacer, not a correctness path.
1174
+ }
712
1175
  }
713
1176
 
714
1177
  export function hashToken(token: string): string {
@@ -762,17 +1225,39 @@ export function logAudit(userId: string | null, username: string | null, action:
762
1225
  * (e.g. server-internal bookkeeping that doesn't have a tenant
763
1226
  * context) can omit `callerNetId`, but every MCP tool path MUST
764
1227
  * supply it.
1228
+ *
1229
+ * Returns `{ chained, stoppedReason? }` so the caller can gate its
1230
+ * follow-up SSE push on whether the chain actually wrote (round-2
1231
+ * fix per #275 follow-up, 通信牛 catch): even when the WRITE was
1232
+ * refused, callers were still running `SELECT parent.from_name;
1233
+ * pushEvent(parent.from_name, { parent_task_id }, callerNetId)` —
1234
+ * leaking the foreign parent's task_id into the caller's network
1235
+ * via the SSE payload. Now the caller checks `result.chained` before
1236
+ * pushing the chained_reply event.
765
1237
  */
1238
+ export type ChainReplyResult = {
1239
+ /** True iff this call actually wrote into at least one parent row.
1240
+ * When false, callers MUST NOT push SSE events for parent
1241
+ * listeners — the chain would otherwise leak the foreign parent's
1242
+ * identifiers into the caller's network. */
1243
+ chained: boolean;
1244
+ /** Optional reason the chain stopped short. Today the only enforced
1245
+ * reason is `cross_network`; future reasons (max-depth, parent-
1246
+ * archived, etc.) can land here without breaking callers. */
1247
+ stoppedReason?: "cross_network";
1248
+ };
1249
+
766
1250
  export function chainReplyToParent(
767
1251
  childTaskId: string,
768
1252
  replyText: string,
769
1253
  replyStatus: "replied" | "failed" | "cancelled" = "replied",
770
1254
  maxDepth = 5,
771
1255
  callerNetId?: string | null,
772
- ): void {
1256
+ ): ChainReplyResult {
773
1257
  let currentChildId: string | null = childTaskId;
774
1258
  let currentReply = replyText;
775
1259
  let depth = 0;
1260
+ let chained = false;
776
1261
  // Normalize so undefined ("don't enforce") stays distinct from
777
1262
  // null ("explicit default network"). Caller passes undefined to
778
1263
  // opt out of the cross-tenant check; null to require the parent
@@ -788,12 +1273,12 @@ export function chainReplyToParent(
788
1273
  "SELECT parent_task_id, to_name, from_name, content FROM tasks WHERE task_id = ?1",
789
1274
  currentChildId
790
1275
  );
791
- if (!child?.parent_task_id) return;
1276
+ if (!child?.parent_task_id) return { chained };
792
1277
  const parent: ParentRow | null = db.get<ParentRow>(
793
1278
  "SELECT task_id, from_name, to_name, status, result, network_id, parent_task_id FROM tasks WHERE task_id = ?1",
794
1279
  child.parent_task_id
795
1280
  );
796
- if (!parent) return;
1281
+ if (!parent) return { chained };
797
1282
 
798
1283
  // round5 F2 fix: refuse to write into a parent that lives in a
799
1284
  // different network than the caller. Stops chain-reply being a
@@ -803,7 +1288,7 @@ export function chainReplyToParent(
803
1288
  const parentNet = parent.network_id ?? null;
804
1289
  if (parentNet !== callerNorm) {
805
1290
  console.log(`[commhub] 🚫 chainReplyToParent cross-network blocked: parent=${parent.task_id.slice(0, 8)} parent-net=${parentNet ?? "null"} caller-net=${callerNorm ?? "null"}`);
806
- return;
1291
+ return { chained, stoppedReason: "cross_network" };
807
1292
  }
808
1293
  }
809
1294
 
@@ -844,10 +1329,16 @@ export function chainReplyToParent(
844
1329
  } catch {}
845
1330
  }
846
1331
 
1332
+ // This iteration actually wrote a parent row (and possibly an
1333
+ // inbox notification). Record that so the caller can gate its
1334
+ // SSE push on a real chain.
1335
+ chained = true;
1336
+
847
1337
  // Recurse up the chain.
848
1338
  currentChildId = parent.task_id;
849
1339
  currentReply = newResult;
850
1340
  }
1341
+ return { chained };
851
1342
  }
852
1343
 
853
1344
  export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {