@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.101
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/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +493 -35
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +270 -1
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +984 -34
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +72 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -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/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +518 -0
- package/src/requirement-due-reminders.ts +369 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +344 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +650 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -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/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +4955 -300
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,349 @@
|
|
|
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
|
+
/** RFC-027 §4.5 D8 — tx-aware variant. Identical SQL to auditCreateNode
|
|
276
|
+
* but RE-THROWS on failure so a BEGIN..COMMIT around lifecycle UPDATE +
|
|
277
|
+
* audit INSERT can ROLLBACK if the audit row would have been lost.
|
|
278
|
+
* Required for the "no audit-but state changed" window that §4.5 / D8
|
|
279
|
+
* explicitly closes (PR1 SF-2 review catch — auditCreateNode's
|
|
280
|
+
* swallow-and-warn defeats atomicity).
|
|
281
|
+
*
|
|
282
|
+
* Callers in a non-transaction context should keep using auditCreateNode
|
|
283
|
+
* (best-effort, never throws). */
|
|
284
|
+
export function auditCreateNodeStrict(input: {
|
|
285
|
+
action: Parameters<typeof auditCreateNode>[0]["action"];
|
|
286
|
+
user_id?: string | null;
|
|
287
|
+
username?: string | null;
|
|
288
|
+
network_id?: string | null;
|
|
289
|
+
target_id?: string | null;
|
|
290
|
+
detail: Record<string, unknown>;
|
|
291
|
+
}): void {
|
|
292
|
+
db.run(
|
|
293
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
294
|
+
VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
|
|
295
|
+
[
|
|
296
|
+
input.user_id || null,
|
|
297
|
+
input.username || null,
|
|
298
|
+
input.action,
|
|
299
|
+
input.target_id || null,
|
|
300
|
+
JSON.stringify(input.detail),
|
|
301
|
+
input.network_id || null,
|
|
302
|
+
],
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function auditCreateNode(input: {
|
|
307
|
+
action:
|
|
308
|
+
| "create_node_dispatched"
|
|
309
|
+
| "create_node_dispatch_attempted"
|
|
310
|
+
| "create_node_rejected"
|
|
311
|
+
| "create_node_succeeded"
|
|
312
|
+
| "create_node_sweeper_revoked"
|
|
313
|
+
| "daemon_capability_lied" // RFC-026 §9.3 D2 — daemon declared runtime support, child died before serving
|
|
314
|
+
// RFC-027 §4.5 — stop/delete lifecycle audit action enum extension.
|
|
315
|
+
// Re-using auditCreateNode is fine: the action column carries the
|
|
316
|
+
// discriminator; target_type stays 'node_create_request' which is a
|
|
317
|
+
// misnomer for stop/delete rows but updating the schema would force a
|
|
318
|
+
// wider migration (see P1.1 issue).
|
|
319
|
+
| "stop_node_dispatched"
|
|
320
|
+
| "stop_node_completed"
|
|
321
|
+
| "start_node_dispatched"
|
|
322
|
+
| "start_node_completed"
|
|
323
|
+
| "delete_node_dispatched"
|
|
324
|
+
| "delete_node_completed"
|
|
325
|
+
| "forced_stop_with_in_flight"
|
|
326
|
+
| "backup_purged";
|
|
327
|
+
user_id?: string | null;
|
|
328
|
+
username?: string | null;
|
|
329
|
+
network_id?: string | null;
|
|
330
|
+
target_id?: string | null; // request_id
|
|
331
|
+
detail: Record<string, unknown>;
|
|
332
|
+
}): void {
|
|
333
|
+
try {
|
|
334
|
+
db.run(
|
|
335
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
336
|
+
VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
|
|
337
|
+
[
|
|
338
|
+
input.user_id || null,
|
|
339
|
+
input.username || null,
|
|
340
|
+
input.action,
|
|
341
|
+
input.target_id || null,
|
|
342
|
+
JSON.stringify(input.detail),
|
|
343
|
+
input.network_id || null,
|
|
344
|
+
],
|
|
345
|
+
);
|
|
346
|
+
} catch (e: any) {
|
|
347
|
+
console.warn(`[commhub] audit_log insert (create_node) failed: ${e?.message || e}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { isInteractiveDashboardTask } from "../../agent-node/src/inbox-dispatch";
|
|
6
|
+
import { shouldCreateScheduledGoal } from "../../agent-node/src/goals/routing";
|
|
7
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
8
|
+
import { db } from "./db.js";
|
|
9
|
+
|
|
10
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-dashboard-slash-http-"));
|
|
11
|
+
const TARGET = `dashboard-slash-target-${process.pid}`;
|
|
12
|
+
const TARGET_NODE_ID = `node_dashboard_slash_target_${process.pid}`;
|
|
13
|
+
const TARGET_RESUME_ID = `resume_dashboard_slash_target_${process.pid}`;
|
|
14
|
+
let server: any;
|
|
15
|
+
let base = "";
|
|
16
|
+
let userToken = "";
|
|
17
|
+
let nodeToken = "";
|
|
18
|
+
let networkId = "";
|
|
19
|
+
let requestSequence = 0;
|
|
20
|
+
|
|
21
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
22
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
23
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
24
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
27
|
+
const username = `dashboard_slash_${Date.now()}`;
|
|
28
|
+
const registered = register(username, "DashboardSlash123!", undefined, "seed");
|
|
29
|
+
expect(registered.ok).toBe(true);
|
|
30
|
+
userToken = registered.token!;
|
|
31
|
+
networkId = registered.network_id!;
|
|
32
|
+
const userId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [username])!.user_id;
|
|
33
|
+
const minted = createNetworkTokenForNode(userId, networkId, "dashboard-slash-node");
|
|
34
|
+
expect(minted.ok).toBe(true);
|
|
35
|
+
nodeToken = minted.token!;
|
|
36
|
+
db.run(
|
|
37
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
38
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
39
|
+
[TARGET_RESUME_ID, TARGET, TARGET_NODE_ID, networkId],
|
|
40
|
+
);
|
|
41
|
+
db.run(
|
|
42
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
|
|
43
|
+
VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
|
|
44
|
+
[TARGET_NODE_ID, TARGET, networkId],
|
|
45
|
+
);
|
|
46
|
+
const mod: any = await import("./server.js");
|
|
47
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
48
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
49
|
+
}, 30_000);
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
try { server?.stop?.(true); } catch {}
|
|
53
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
async function postAndRead(token: string, from: string, task: string) {
|
|
57
|
+
requestSequence += 1;
|
|
58
|
+
const clientRequestId = `dreq_${requestSequence.toString(16).padStart(32, "0")}`;
|
|
59
|
+
const response = await fetch(`${base}/api/task`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
alias: TARGET,
|
|
64
|
+
from,
|
|
65
|
+
network_id: networkId,
|
|
66
|
+
task,
|
|
67
|
+
meta: { source: "dashboard-chat", client_request_id: clientRequestId, auth_origin: "user" },
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
const body = await response.json() as any;
|
|
71
|
+
expect([200, 202]).toContain(response.status);
|
|
72
|
+
const taskId = body.task_id ?? body.message_id;
|
|
73
|
+
const row = db.get<{ content: string; meta_json: string }>(
|
|
74
|
+
"SELECT content, meta_json FROM inbox WHERE id = ?1",
|
|
75
|
+
[taskId],
|
|
76
|
+
);
|
|
77
|
+
expect(row).toBeTruthy();
|
|
78
|
+
return { type: "task", content: row!.content, meta_json: row!.meta_json };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("real Hub → authenticated Dashboard slash routing", () => {
|
|
82
|
+
test("Dashboard /goal and /loop remain exact native payloads for every runtime", async () => {
|
|
83
|
+
for (const command of ["/goal 更新一下文档", "/loop 5m native loop"] as const) {
|
|
84
|
+
const row = await postAndRead(userToken, "dashboard-user", command);
|
|
85
|
+
expect(row.content).toBe(command);
|
|
86
|
+
expect(isInteractiveDashboardTask(row)).toBe(true);
|
|
87
|
+
for (const runtime of ["codex-app-server", "codex", "claude", "grok", "opencode"] as const) {
|
|
88
|
+
expect(shouldCreateScheduledGoal(row.content, runtime, true)).toBe(false);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("Dashboard /agoal and /aloop use the Agent Network scheduler", async () => {
|
|
94
|
+
for (const command of ["/agoal 5m update docs", "/aloop 5m update docs"] as const) {
|
|
95
|
+
const row = await postAndRead(userToken, "dashboard-user", command);
|
|
96
|
+
expect(isInteractiveDashboardTask(row)).toBe(true);
|
|
97
|
+
expect(shouldCreateScheduledGoal(row.content, "codex-app-server", true)).toBe(true);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("node token cannot forge Dashboard pass-through and keeps legacy compatibility", async () => {
|
|
102
|
+
const row = await postAndRead(nodeToken, "dashboard-slash-node", "/loop 5m legacy automation");
|
|
103
|
+
expect(JSON.parse(row.meta_json).auth_origin).toBe("node");
|
|
104
|
+
expect(isInteractiveDashboardTask(row)).toBe(false);
|
|
105
|
+
expect(shouldCreateScheduledGoal(row.content, "codex-app-server", false)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
assertSafeTestDatabaseEnv,
|
|
4
|
+
redactPgUrl,
|
|
5
|
+
resolveDatabaseTarget,
|
|
6
|
+
} from "./db-adapter";
|
|
7
|
+
|
|
8
|
+
const BASE_ENV: NodeJS.ProcessEnv = { HOME: "/nonexistent" };
|
|
9
|
+
|
|
10
|
+
describe("#435 inherited DATABASE_URL guard", () => {
|
|
11
|
+
for (const databaseUrl of [
|
|
12
|
+
"postgres://user:pw@prod.example:5432/commhub",
|
|
13
|
+
"postgresql://user:pw@prod.example:5432/commhub",
|
|
14
|
+
"sqlite:///tmp/not-an-escape",
|
|
15
|
+
]) {
|
|
16
|
+
test(`NODE_ENV=test rejects ${databaseUrl.split(":", 1)[0]} DATABASE_URL`, () => {
|
|
17
|
+
expect(() => assertSafeTestDatabaseEnv({
|
|
18
|
+
...BASE_ENV,
|
|
19
|
+
NODE_ENV: "test",
|
|
20
|
+
DATABASE_URL: databaseUrl,
|
|
21
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("test without DATABASE_URL leaves the existing SQLite guard in charge", () => {
|
|
26
|
+
expect(() => assertSafeTestDatabaseEnv({ ...BASE_ENV, NODE_ENV: "test" })).not.toThrow();
|
|
27
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "test" }))
|
|
28
|
+
.toThrow(/REFUSING to open the default SQLite database/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("DATABASE_URL refusal wins even when COMMHUB_DB is also set", () => {
|
|
32
|
+
expect(() => resolveDatabaseTarget({
|
|
33
|
+
...BASE_ENV,
|
|
34
|
+
NODE_ENV: "test",
|
|
35
|
+
DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
|
|
36
|
+
COMMHUB_DB: "/tmp/isolated.db",
|
|
37
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("production postgres branch remains unchanged without constructing it", () => {
|
|
41
|
+
const url = "postgres://user:pw@prod.example:5432/commhub";
|
|
42
|
+
expect(resolveDatabaseTarget({
|
|
43
|
+
...BASE_ENV,
|
|
44
|
+
NODE_ENV: "production",
|
|
45
|
+
DATABASE_URL: url,
|
|
46
|
+
})).toEqual({ kind: "postgres", url });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("unset NODE_ENV still selects postgres", () => {
|
|
50
|
+
const url = "postgresql://user:pw@prod.example:5432/commhub";
|
|
51
|
+
expect(resolveDatabaseTarget({ ...BASE_ENV, DATABASE_URL: url }))
|
|
52
|
+
.toEqual({ kind: "postgres", url });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("explicit SQLite targets remain unchanged in test and production", () => {
|
|
56
|
+
for (const NODE_ENV of ["test", "production"]) {
|
|
57
|
+
expect(resolveDatabaseTarget({
|
|
58
|
+
...BASE_ENV,
|
|
59
|
+
NODE_ENV,
|
|
60
|
+
COMMHUB_DB: "/tmp/isolated.db",
|
|
61
|
+
})).toEqual({ kind: "sqlite", path: "/tmp/isolated.db" });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("non-server callers cannot silently fall through to the production default SQLite path", () => {
|
|
66
|
+
for (const NODE_ENV of [undefined, "development", "production"]) {
|
|
67
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV }))
|
|
68
|
+
.toThrow(/explicit COMMHUB_DB|COMMHUB_SERVER=1/);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the explicit server boot capability permits the canonical default SQLite path", () => {
|
|
73
|
+
expect(resolveDatabaseTarget({
|
|
74
|
+
...BASE_ENV,
|
|
75
|
+
NODE_ENV: "production",
|
|
76
|
+
COMMHUB_SERVER: "1",
|
|
77
|
+
})).toEqual({ kind: "sqlite", path: "/nonexistent/.commhub/commhub.db" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("lookalike server capability values fail closed", () => {
|
|
81
|
+
for (const COMMHUB_SERVER of ["", "true", "yes", "01", " 1"] ) {
|
|
82
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, COMMHUB_SERVER }))
|
|
83
|
+
.toThrow(/COMMHUB_SERVER=1/);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("RFC-039 §9 COMMHUB_TEST_PG_URL (test-only PostgreSQL)", () => {
|
|
89
|
+
const TEST = { ...BASE_ENV, NODE_ENV: "test" };
|
|
90
|
+
const ok = (url: string) => expect(resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url })).toEqual({ kind: "postgres", url });
|
|
91
|
+
const refused = (url: string, why: RegExp) => {
|
|
92
|
+
let message = "";
|
|
93
|
+
try { resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url }); } catch (e: any) { message = String(e?.message ?? e); }
|
|
94
|
+
expect(message).toMatch(/REFUSING COMMHUB_TEST_PG_URL/);
|
|
95
|
+
expect(message).toMatch(why);
|
|
96
|
+
// Credentials never reach an error message.
|
|
97
|
+
expect(message).not.toContain("s3cret");
|
|
98
|
+
expect(message).not.toContain("tester");
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
test("loopback host + anet_*_test database is accepted under NODE_ENV=test", () => {
|
|
102
|
+
ok("postgres://tester:s3cret@127.0.0.1:5432/anet_sched_test");
|
|
103
|
+
ok("postgresql://tester:s3cret@localhost/anet_side_thread_test");
|
|
104
|
+
ok("postgres://tester:s3cret@[::1]:6543/anet_x_test?connect_timeout=2&sslmode=disable");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("ignored outside NODE_ENV=test", () => {
|
|
108
|
+
expect(resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "production", COMMHUB_DB: "/tmp/i.db", COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test" }))
|
|
109
|
+
.toEqual({ kind: "sqlite", path: "/tmp/i.db" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("inherited DATABASE_URL still refuses even when COMMHUB_TEST_PG_URL is set", () => {
|
|
113
|
+
expect(() => resolveDatabaseTarget({
|
|
114
|
+
...TEST,
|
|
115
|
+
DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
|
|
116
|
+
COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test",
|
|
117
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("refuses a value that is not a URL or not postgres", () => {
|
|
121
|
+
refused("not a url", /not a URL/);
|
|
122
|
+
refused("mysql://tester:s3cret@127.0.0.1/anet_x_test", /scheme/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("refuses every host that is not literally loopback", () => {
|
|
126
|
+
for (const host of ["10.0.0.5", "db.internal", "localhost.evil.example", "127.0.0.2", "127.0.0.1,evil.example", "LOCALHOST", "%2Ftmp"]) {
|
|
127
|
+
refused(`postgres://tester:s3cret@${host}/anet_x_test`, /host must be/);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("refuses database names outside /^anet_[a-z0-9_]*_test$/", () => {
|
|
132
|
+
for (const name of ["commhub", "anet_x", "anet_x_test2", "ANET_X_TEST", "anet-x_test", "anet_x_test/extra", ""]) {
|
|
133
|
+
refused(`postgres://tester:s3cret@127.0.0.1/${name}`, /database name/);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("refuses connection-steering query parameters", () => {
|
|
138
|
+
refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?host=db.internal", /"host" is not allowed/);
|
|
139
|
+
refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?options=-csearch_path%3Dprod", /"options" is not allowed/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("redactPgUrl drops user and password", () => {
|
|
143
|
+
const out = redactPgUrl("postgres://tester:s3cret@127.0.0.1:5432/anet_x_test");
|
|
144
|
+
expect(out).not.toContain("tester");
|
|
145
|
+
expect(out).not.toContain("s3cret");
|
|
146
|
+
expect(out).toContain("127.0.0.1:5432/anet_x_test");
|
|
147
|
+
});
|
|
148
|
+
});
|