@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,188 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { parseStoredTags } from "./node-attrs-validate.js";
|
|
4
|
+
|
|
5
|
+
// Regression test for SELECT * fragility on GET /api/nodes.
|
|
6
|
+
//
|
|
7
|
+
// Prior behavior: `SELECT * FROM nodes` returned whatever columns the
|
|
8
|
+
// table happened to have. When RFC-024 added config_revision +
|
|
9
|
+
// config_snapshot columns (read by the per-node GET /api/nodes/:id/config
|
|
10
|
+
// endpoint, NOT meant for the list endpoint), they silently leaked into
|
|
11
|
+
// the list response and broke V3 Networks tests that asserted on the
|
|
12
|
+
// nodes-list row shape.
|
|
13
|
+
//
|
|
14
|
+
// Current behavior: explicit column list — the response contract is:
|
|
15
|
+
// node_id, node_name, alias, runtime, model, config_path, channels,
|
|
16
|
+
// server, hostname, network_id, created_at, updated_at
|
|
17
|
+
// This test runs the same SQL the handler uses, on a row that DOES have
|
|
18
|
+
// config_revision + config_snapshot populated, and asserts those fields
|
|
19
|
+
// do NOT appear in the returned row. If anyone reverts the handler to
|
|
20
|
+
// SELECT *, or adds an internal column to the SELECT, this test breaks.
|
|
21
|
+
|
|
22
|
+
const TEST_NETWORK = "net_test_shape";
|
|
23
|
+
const TEST_NODE = "node_test_shape_abc";
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
// Best-effort cleanup (test runs in shared test DB; rows are isolated by id)
|
|
27
|
+
try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
|
|
28
|
+
});
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Helper: mirror handler's role-extraction pass to mimic the production
|
|
34
|
+
// response shape exactly. Keeps the test the source of truth for the
|
|
35
|
+
// contract; if the handler changes, the assertions still lock what
|
|
36
|
+
// the dashboard / external clients see.
|
|
37
|
+
function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
38
|
+
let role: string | null = null;
|
|
39
|
+
const snap = r.config_snapshot;
|
|
40
|
+
if (snap) {
|
|
41
|
+
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
42
|
+
catch { /* malformed */ }
|
|
43
|
+
}
|
|
44
|
+
const { config_snapshot, ...rest } = r;
|
|
45
|
+
return {
|
|
46
|
+
...rest,
|
|
47
|
+
tags: parseStoredTags(r.tags),
|
|
48
|
+
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
49
|
+
role,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
|
|
54
|
+
test("explicit columns + role extracted: config_snapshot NOT in response, role IS", () => {
|
|
55
|
+
// Insert a row with the leak-prone columns populated. Use INSERT OR
|
|
56
|
+
// REPLACE so concurrent test runs don't trip on PRIMARY KEY.
|
|
57
|
+
db.run(
|
|
58
|
+
`INSERT OR REPLACE INTO nodes (
|
|
59
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
60
|
+
channels, server, hostname, network_id,
|
|
61
|
+
config_revision, config_snapshot
|
|
62
|
+
) VALUES (
|
|
63
|
+
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12
|
|
64
|
+
)`,
|
|
65
|
+
[TEST_NODE, "test-shape", "test-shape", "claude-agent-sdk", "claude-opus",
|
|
66
|
+
"/tmp/cfg.json", "[]", "localhost", "test-host", TEST_NETWORK,
|
|
67
|
+
7, JSON.stringify({ model: "claude-opus", flags: { x: true }, role: "host_supervisor" })],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Mirror handler SQL + mapping
|
|
71
|
+
const raw = db.all<Record<string, unknown>>(
|
|
72
|
+
`SELECT node_id, node_name, alias, runtime, model,
|
|
73
|
+
config_path, channels, server, hostname,
|
|
74
|
+
network_id, created_at, updated_at,
|
|
75
|
+
config_snapshot, display_name, team, tags, attrs_revision
|
|
76
|
+
FROM nodes WHERE node_id = ?1`,
|
|
77
|
+
TEST_NODE,
|
|
78
|
+
);
|
|
79
|
+
const rows = raw.map(mapRow);
|
|
80
|
+
|
|
81
|
+
expect(rows).toHaveLength(1);
|
|
82
|
+
const row = rows[0]!;
|
|
83
|
+
// Expected fields present (13 = 12 contract + new role)
|
|
84
|
+
for (const k of ["node_id", "node_name", "alias", "runtime", "model",
|
|
85
|
+
"config_path", "channels", "server", "hostname",
|
|
86
|
+
"network_id", "created_at", "updated_at", "role"]) {
|
|
87
|
+
expect(row).toHaveProperty(k);
|
|
88
|
+
}
|
|
89
|
+
// Internal fields NOT present
|
|
90
|
+
expect(row).not.toHaveProperty("config_revision");
|
|
91
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
92
|
+
// role is EXTRACTED from config_snapshot JSON, not the raw blob
|
|
93
|
+
expect(row.role).toBe("host_supervisor");
|
|
94
|
+
// Belt: the key set is exactly the dashboard-facing contract.
|
|
95
|
+
// NOTE: this file MIRRORS the handler's SQL rather than calling it, so
|
|
96
|
+
// it can drift — it already lagged behind `lifecycle_state` /
|
|
97
|
+
// `avatar_url`, which the handler selects but this mirror does not.
|
|
98
|
+
// Treat a diff here as "re-read the handler", not as proof of the wire
|
|
99
|
+
// shape; node-attrs.test.ts asserts the real HTTP response.
|
|
100
|
+
expect(Object.keys(row).sort()).toEqual([
|
|
101
|
+
"alias", "attrs_revision", "channels", "config_path", "created_at",
|
|
102
|
+
"display_name", "hostname", "model", "network_id", "node_id",
|
|
103
|
+
"node_name", "role", "runtime", "server", "tags", "team",
|
|
104
|
+
"updated_at",
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("role is null when config_snapshot is empty / missing / malformed", () => {
|
|
109
|
+
// Empty snapshot → null
|
|
110
|
+
db.run(
|
|
111
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
112
|
+
VALUES (?1, ?2, ?3, ?4, NULL)`,
|
|
113
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK],
|
|
114
|
+
);
|
|
115
|
+
let row = mapRow(db.get<Record<string, unknown>>(
|
|
116
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
117
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
118
|
+
display_name, team, tags, attrs_revision
|
|
119
|
+
FROM nodes WHERE node_id = ?1`,
|
|
120
|
+
TEST_NODE,
|
|
121
|
+
)!);
|
|
122
|
+
expect(row.role).toBeNull();
|
|
123
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
124
|
+
|
|
125
|
+
// Malformed JSON → null (handler doesn't throw, just leaves role null)
|
|
126
|
+
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
|
|
127
|
+
row = mapRow(db.get<Record<string, unknown>>(
|
|
128
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
129
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
130
|
+
display_name, team, tags, attrs_revision
|
|
131
|
+
FROM nodes WHERE node_id = ?1`,
|
|
132
|
+
TEST_NODE,
|
|
133
|
+
)!);
|
|
134
|
+
expect(row.role).toBeNull();
|
|
135
|
+
|
|
136
|
+
// Snapshot without role key → null
|
|
137
|
+
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
|
|
138
|
+
[JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
|
|
139
|
+
row = mapRow(db.get<Record<string, unknown>>(
|
|
140
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
141
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
142
|
+
display_name, team, tags, attrs_revision
|
|
143
|
+
FROM nodes WHERE node_id = ?1`,
|
|
144
|
+
TEST_NODE,
|
|
145
|
+
)!);
|
|
146
|
+
expect(row.role).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("daemon discovery use-case: dashboard finds role=host_supervisor without prefix heuristic", () => {
|
|
150
|
+
// Daemon registered with arbitrary node_id (NO `node_daemon_` prefix)
|
|
151
|
+
db.run(
|
|
152
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
153
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
154
|
+
[TEST_NODE, "my-daemon", "my-daemon", TEST_NETWORK,
|
|
155
|
+
JSON.stringify({ role: "host_supervisor", runtime: "claude-agent-sdk" })],
|
|
156
|
+
);
|
|
157
|
+
const rows = db.all<Record<string, unknown>>(
|
|
158
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
159
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
160
|
+
display_name, team, tags, attrs_revision
|
|
161
|
+
FROM nodes WHERE network_id = ?1`,
|
|
162
|
+
TEST_NETWORK,
|
|
163
|
+
).map(mapRow);
|
|
164
|
+
// Dashboard's lookup: find first node with role==='host_supervisor'
|
|
165
|
+
const daemon = rows.find(r => r.role === "host_supervisor");
|
|
166
|
+
expect(daemon).toBeTruthy();
|
|
167
|
+
expect(daemon!.node_id).toBe(TEST_NODE);
|
|
168
|
+
// Even though node_id does NOT start with `node_daemon_` (the prior heuristic)
|
|
169
|
+
expect(String(daemon!.node_id).startsWith("node_daemon_")).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("internal columns are still readable via dedicated query (config snapshot endpoint path)", () => {
|
|
173
|
+
db.run(
|
|
174
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id,
|
|
175
|
+
config_revision, config_snapshot)
|
|
176
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
177
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK,
|
|
178
|
+
42, JSON.stringify({ model: "claude-opus" })],
|
|
179
|
+
);
|
|
180
|
+
// Mirrors the SELECT in GET /api/nodes/:id/config (server/src/index.ts):
|
|
181
|
+
const cfgRow = db.get<{ config_revision: number; config_snapshot: string }>(
|
|
182
|
+
"SELECT config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
183
|
+
TEST_NODE,
|
|
184
|
+
);
|
|
185
|
+
expect(cfgRow?.config_revision).toBe(42);
|
|
186
|
+
expect(cfgRow?.config_snapshot).toContain("claude-opus");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// 登录会话:闲置过期 + 「登录设备」列表 + 退出其他设备。
|
|
2
|
+
//
|
|
3
|
+
// 生产上 user-login 令牌一次登录一条、从不过期也不回收,泄漏任何一条就等于那个账号(多为管理员)。
|
|
4
|
+
// 这里的每一条在 main 上都是红的:main 没有闲置过期(旧令牌永远有效)、登录响应不带 token_id、
|
|
5
|
+
// /api/auth/sessions* 端点不存在。正控:在用的会话、显式 API 令牌、节点令牌照常可用。
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { register, createNetworkTokenForNode, createToken } from "./auth.js";
|
|
12
|
+
import { db, hashToken } from "./db.js";
|
|
13
|
+
|
|
14
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-sessions-"));
|
|
15
|
+
let BASE = "";
|
|
16
|
+
let hub: any = null;
|
|
17
|
+
const PW = "SessionsPassw0rd!";
|
|
18
|
+
let USER = "", userId = "", NET = "", ntok = "", apiToken = "";
|
|
19
|
+
|
|
20
|
+
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
|
21
|
+
async function call(token: string, method: string, path: string, body?: unknown, extra: Record<string, string> = {}) {
|
|
22
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
23
|
+
method,
|
|
24
|
+
headers: { ...(token ? auth(token) : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }), ...extra },
|
|
25
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
28
|
+
}
|
|
29
|
+
async function loginAs(label?: string, ua = "anet-test-agent/1.0") {
|
|
30
|
+
const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW, ...(label ? { client_label: label } : {}) }, { "User-Agent": ua });
|
|
31
|
+
expect(r.status).toBe(200);
|
|
32
|
+
return r.body as { token: string; token_id: string };
|
|
33
|
+
}
|
|
34
|
+
// 把某条令牌的「最近使用」拨回 days 天前(created_at 一起拨,模拟一条很早签发、之后没再用过的令牌)。
|
|
35
|
+
function ageToken(token: string, days: number) {
|
|
36
|
+
db.run(
|
|
37
|
+
"UPDATE api_tokens SET last_used_at = datetime('now', ?1), created_at = datetime('now', ?1) WHERE token_hash = ?2",
|
|
38
|
+
[`-${days} days`, hashToken(token)],
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
function neverUsed(token: string, days: number) {
|
|
42
|
+
db.run("UPDATE api_tokens SET last_used_at = NULL, created_at = datetime('now', ?1) WHERE token_hash = ?2", [`-${days} days`, hashToken(token)]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
47
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
48
|
+
process.env.HOST = "127.0.0.1";
|
|
49
|
+
// 这里每个用例都真走 POST /api/auth/login 签发会话;默认的每 IP 登录限流会在第 ~20 次开始 429。
|
|
50
|
+
process.env.COMMHUB_LOGIN_IP_MAX = "1000";
|
|
51
|
+
delete process.env.COMMHUB_SESSION_IDLE_DAYS;
|
|
52
|
+
USER = `sess_user_${Date.now()}`;
|
|
53
|
+
const reg = register(USER, PW);
|
|
54
|
+
userId = reg.user!.user_id; NET = reg.network_id!;
|
|
55
|
+
const minted = createNetworkTokenForNode(userId, NET, "sess-node", "node_sess_1");
|
|
56
|
+
expect(minted.ok).toBe(true);
|
|
57
|
+
ntok = minted.token!;
|
|
58
|
+
const api = createToken(userId, "ci-script");
|
|
59
|
+
expect(api.ok).toBe(true);
|
|
60
|
+
apiToken = api.token!;
|
|
61
|
+
const mod: any = await import("./server.js");
|
|
62
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
63
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
64
|
+
}, 30_000);
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
try { hub?.stop?.(true); } catch {}
|
|
68
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("login / register identify the issued token", () => {
|
|
72
|
+
test("login returns token_id of the token it returned (and stays backward-compatible)", async () => {
|
|
73
|
+
const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW });
|
|
74
|
+
expect(r.status).toBe(200);
|
|
75
|
+
expect(r.body.ok).toBe(true);
|
|
76
|
+
expect(typeof r.body.token).toBe("string");
|
|
77
|
+
expect(r.body.token_id).toMatch(/^tok_/);
|
|
78
|
+
const row = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
|
|
79
|
+
expect(row.token_id).toBe(r.body.token_id);
|
|
80
|
+
});
|
|
81
|
+
test("register returns token_id", async () => {
|
|
82
|
+
const r = await call("", "POST", "/api/auth/register", { username: `sess_reg_${Date.now()}`, password: PW });
|
|
83
|
+
expect(r.status).toBe(200);
|
|
84
|
+
expect(r.body.token_id).toMatch(/^tok_/);
|
|
85
|
+
const row = db.get<any>("SELECT token_id, name FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
|
|
86
|
+
expect(row.token_id).toBe(r.body.token_id);
|
|
87
|
+
expect(row.name).toBe("user-login");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("sliding idle expiry (default 30 days)", () => {
|
|
92
|
+
test("a login token idle past the window is rejected with token_expired", async () => {
|
|
93
|
+
const s = await loginAs();
|
|
94
|
+
ageToken(s.token, 31);
|
|
95
|
+
const r = await call(s.token, "GET", "/api/auth/me");
|
|
96
|
+
expect(r.status).toBe(401);
|
|
97
|
+
expect(r.body.error).toBe("token_expired");
|
|
98
|
+
// 同一条 401 改写也覆盖走统一 requireAuth 的 REST 端点
|
|
99
|
+
const r2 = await call(s.token, "GET", "/api/sessions");
|
|
100
|
+
expect(r2.status).toBe(401);
|
|
101
|
+
expect(r2.body.error).toBe("token_expired");
|
|
102
|
+
});
|
|
103
|
+
test("a login token that was never used counts idle time from created_at", async () => {
|
|
104
|
+
const s = await loginAs();
|
|
105
|
+
neverUsed(s.token, 45);
|
|
106
|
+
const r = await call(s.token, "GET", "/api/auth/me");
|
|
107
|
+
expect(r.status).toBe(401);
|
|
108
|
+
expect(r.body.error).toBe("token_expired");
|
|
109
|
+
});
|
|
110
|
+
test("an old but recently used login token stays valid (the window slides)", async () => {
|
|
111
|
+
const s = await loginAs();
|
|
112
|
+
// 90 天前签发,2 天前还在用
|
|
113
|
+
db.run("UPDATE api_tokens SET created_at = datetime('now', '-90 days'), last_used_at = datetime('now', '-2 days') WHERE token_hash = ?1", [hashToken(s.token)]);
|
|
114
|
+
const r = await call(s.token, "GET", "/api/auth/me");
|
|
115
|
+
expect(r.status).toBe(200);
|
|
116
|
+
// 使用把 last_used_at 推到现在(超过节流间隔,必须写)
|
|
117
|
+
const row = db.get<any>("SELECT last_used_at >= datetime('now', '-60 seconds') AS fresh FROM api_tokens WHERE token_hash = ?1", hashToken(s.token));
|
|
118
|
+
expect(row.fresh).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
test("last_used_at is written at most once per hour per token", async () => {
|
|
121
|
+
const s = await loginAs();
|
|
122
|
+
db.run("UPDATE api_tokens SET last_used_at = datetime('now', '-10 minutes') WHERE token_hash = ?1", [hashToken(s.token)]);
|
|
123
|
+
const before = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
|
|
124
|
+
expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
125
|
+
const after = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
|
|
126
|
+
expect(after).toBe(before);
|
|
127
|
+
});
|
|
128
|
+
test("a truly invalid token still says invalid, not expired", async () => {
|
|
129
|
+
const r = await call("utok_00000000000000000000000000000000", "GET", "/api/auth/me");
|
|
130
|
+
expect(r.status).toBe(401);
|
|
131
|
+
expect(r.body.error).not.toBe("token_expired");
|
|
132
|
+
});
|
|
133
|
+
test("node tokens and explicit API tokens never idle-expire", async () => {
|
|
134
|
+
neverUsed(ntok, 400);
|
|
135
|
+
neverUsed(apiToken, 400);
|
|
136
|
+
const n = await call(ntok, "GET", "/api/auth/me");
|
|
137
|
+
expect(n.status).toBe(200);
|
|
138
|
+
expect(n.body.credential.kind).toBe("node");
|
|
139
|
+
expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
|
|
140
|
+
});
|
|
141
|
+
test("COMMHUB_SESSION_IDLE_DAYS configures the window; 0 disables it", async () => {
|
|
142
|
+
const s = await loginAs();
|
|
143
|
+
ageToken(s.token, 10);
|
|
144
|
+
process.env.COMMHUB_SESSION_IDLE_DAYS = "7";
|
|
145
|
+
try {
|
|
146
|
+
expect((await call(s.token, "GET", "/api/auth/me")).body.error).toBe("token_expired");
|
|
147
|
+
process.env.COMMHUB_SESSION_IDLE_DAYS = "0";
|
|
148
|
+
expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
149
|
+
} finally {
|
|
150
|
+
delete process.env.COMMHUB_SESSION_IDLE_DAYS;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("GET /api/auth/sessions", () => {
|
|
156
|
+
test("lists my live login sessions with label, user agent and is_current", async () => {
|
|
157
|
+
const a = await loginAs("Android · 0.2.150", "okhttp/4");
|
|
158
|
+
const b = await loginAs("macOS · 0.2.150", "Mozilla/5.0 (Macintosh)");
|
|
159
|
+
const dead = await loginAs("stale");
|
|
160
|
+
ageToken(dead.token, 40);
|
|
161
|
+
const r = await call(b.token, "GET", "/api/auth/sessions");
|
|
162
|
+
expect(r.status).toBe(200);
|
|
163
|
+
expect(r.body.idle_timeout_days).toBe(30);
|
|
164
|
+
expect(r.body.current_token_id).toBe(b.token_id);
|
|
165
|
+
const ids = r.body.sessions.map((x: any) => x.token_id);
|
|
166
|
+
expect(ids).toContain(a.token_id);
|
|
167
|
+
expect(ids).toContain(b.token_id);
|
|
168
|
+
expect(ids).not.toContain(dead.token_id);
|
|
169
|
+
const me = r.body.sessions.find((x: any) => x.token_id === b.token_id);
|
|
170
|
+
expect(me.is_current).toBe(true);
|
|
171
|
+
expect(me.client_label).toBe("macOS · 0.2.150");
|
|
172
|
+
expect(me.user_agent).toBe("Mozilla/5.0 (Macintosh)");
|
|
173
|
+
expect(typeof me.created_at).toBe("string");
|
|
174
|
+
const other = r.body.sessions.find((x: any) => x.token_id === a.token_id);
|
|
175
|
+
expect(other.is_current).toBe(false);
|
|
176
|
+
expect(other.client_label).toBe("Android · 0.2.150");
|
|
177
|
+
// 不泄漏令牌本身或哈希;API 令牌和节点令牌不在列表里
|
|
178
|
+
expect(JSON.stringify(r.body)).not.toContain("token_hash");
|
|
179
|
+
expect(JSON.stringify(r.body)).not.toContain(b.token);
|
|
180
|
+
expect(r.body.sessions.every((x: any) => x.name !== "ci-script" && !String(x.name).startsWith("node:"))).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
test("node tokens get 403 user_token_required", async () => {
|
|
183
|
+
const r = await call(ntok, "GET", "/api/auth/sessions");
|
|
184
|
+
expect(r.status).toBe(403);
|
|
185
|
+
expect(r.body.error).toBe("user_token_required");
|
|
186
|
+
});
|
|
187
|
+
test("no token → 401", async () => {
|
|
188
|
+
expect((await call("", "GET", "/api/auth/sessions")).status).toBe(401);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("revoking sessions", () => {
|
|
193
|
+
test("revoke-others keeps the current token and leaves API and node tokens alone", async () => {
|
|
194
|
+
const keep = await loginAs("keep");
|
|
195
|
+
const other1 = await loginAs("other1");
|
|
196
|
+
const other2 = await loginAs("other2");
|
|
197
|
+
const r = await call(keep.token, "POST", "/api/auth/sessions/revoke-others");
|
|
198
|
+
expect(r.status).toBe(200);
|
|
199
|
+
expect(r.body.kept_token_id).toBe(keep.token_id);
|
|
200
|
+
expect(r.body.revoked).toBeGreaterThanOrEqual(2);
|
|
201
|
+
expect((await call(keep.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
202
|
+
expect((await call(other1.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
203
|
+
expect((await call(other2.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
204
|
+
expect((await call(ntok, "GET", "/api/auth/me")).status).toBe(200);
|
|
205
|
+
expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
|
|
206
|
+
const list = await call(keep.token, "GET", "/api/auth/sessions");
|
|
207
|
+
expect(list.body.sessions.map((x: any) => x.token_id)).toEqual([keep.token_id]);
|
|
208
|
+
const audit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'sessions_revoked_others' AND target_id = ?1", keep.token_id);
|
|
209
|
+
expect(audit?.detail).toMatch(/^revoked=\d+$/);
|
|
210
|
+
});
|
|
211
|
+
test("revoke-others refuses node tokens and non-session tokens", async () => {
|
|
212
|
+
const r = await call(ntok, "POST", "/api/auth/sessions/revoke-others");
|
|
213
|
+
expect(r.status).toBe(403);
|
|
214
|
+
expect(r.body.error).toBe("user_token_required");
|
|
215
|
+
const s = await loginAs("survivor");
|
|
216
|
+
const viaApi = await call(apiToken, "POST", "/api/auth/sessions/revoke-others");
|
|
217
|
+
expect(viaApi.status).toBe(403);
|
|
218
|
+
expect(viaApi.body.error).toBe("session_token_required");
|
|
219
|
+
expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
220
|
+
});
|
|
221
|
+
test("DELETE /api/auth/sessions/:id revokes one other session", async () => {
|
|
222
|
+
const me = await loginAs("me");
|
|
223
|
+
const victim = await loginAs("victim");
|
|
224
|
+
const r = await call(me.token, "DELETE", `/api/auth/sessions/${victim.token_id}`);
|
|
225
|
+
expect(r.status).toBe(200);
|
|
226
|
+
expect(r.body.was_current).toBe(false);
|
|
227
|
+
expect((await call(victim.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
228
|
+
expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
229
|
+
expect(db.get<any>("SELECT 1 AS x FROM audit_log WHERE action = 'session_revoked' AND target_id = ?1", victim.token_id)?.x).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
test("DELETE of my own session is a logout", async () => {
|
|
232
|
+
const me = await loginAs("logout");
|
|
233
|
+
const r = await call(me.token, "DELETE", `/api/auth/sessions/${me.token_id}`);
|
|
234
|
+
expect(r.status).toBe(200);
|
|
235
|
+
expect(r.body.was_current).toBe(true);
|
|
236
|
+
expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
237
|
+
});
|
|
238
|
+
test("DELETE cannot reach API tokens, node tokens, or another user's session", async () => {
|
|
239
|
+
const me = await loginAs("guard");
|
|
240
|
+
const apiRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(apiToken));
|
|
241
|
+
const ntokRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(ntok));
|
|
242
|
+
expect((await call(me.token, "DELETE", `/api/auth/sessions/${apiRow.token_id}`)).status).toBe(404);
|
|
243
|
+
expect((await call(me.token, "DELETE", `/api/auth/sessions/${ntokRow.token_id}`)).status).toBe(404);
|
|
244
|
+
const stranger = register(`sess_stranger_${Date.now()}`, PW);
|
|
245
|
+
expect((await call(me.token, "DELETE", `/api/auth/sessions/${stranger.token_id}`)).status).toBe(404);
|
|
246
|
+
expect((await call(stranger.token!, "GET", "/api/auth/me")).status).toBe(200);
|
|
247
|
+
expect((await call(ntok, "DELETE", `/api/auth/sessions/${me.token_id}`)).status).toBe(403);
|
|
248
|
+
expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -10,13 +10,17 @@ import { register } from "./auth.js";
|
|
|
10
10
|
|
|
11
11
|
// First user gets RELAXED rules (admin bootstrap allows 4-char "anethub" etc.)
|
|
12
12
|
// To test full-strength rules, seed a dummy admin first.
|
|
13
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
14
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
15
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
16
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
13
17
|
beforeAll(() => {
|
|
14
18
|
// Idempotent: if DB file already has users (from a previous run on the
|
|
15
19
|
// same temp path), this errors with "username already taken" → that's
|
|
16
20
|
// fine, the goal is just "ensure at least one user exists so subsequent
|
|
17
21
|
// registers hit the strict path".
|
|
18
22
|
register("_seed_admin", "BootstrapPw1", undefined, "seed");
|
|
19
|
-
});
|
|
23
|
+
}, 30_000);
|
|
20
24
|
|
|
21
25
|
describe("register — username rules", () => {
|
|
22
26
|
it("rejects empty username", () => {
|