@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/package.json +3 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +65 -5
- package/src/config-apply-sec1.test.ts +723 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/cross-tenant-injection.test.ts +110 -0
- package/src/db.ts +499 -8
- package/src/index.ts +247 -19
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +2285 -64
- package/src/update-provider.test.ts +234 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
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
|
-
|
|
711
|
-
|
|
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
|
-
):
|
|
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) {
|