@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.100
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 +965 -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 +390 -0
- package/src/requirement-due-reminders.ts +310 -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-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 +641 -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,223 @@
|
|
|
1
|
+
// #1459 ① P3 —— per-user 收件回读 + ack + 未读数 + redact-at-read。
|
|
2
|
+
//
|
|
3
|
+
// 承重的是**隔离**:A 只能读到 A 的。所以下面每条隔离断言都额外钉住探针身份 ——
|
|
4
|
+
// 首个注册用户会自动成为全局 admin,而 admin 在别处走的是不过滤的分支;
|
|
5
|
+
// 若探针恰好是 admin,隔离断言会"通过但什么都没证明"(#500/#506 同款)。
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-user-inbox-read-"));
|
|
12
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
13
|
+
|
|
14
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
15
|
+
let base = "";
|
|
16
|
+
let adminToken = "", aToken = "", bToken = "";
|
|
17
|
+
const MASTER_TOKEN = "legacy-master-token-for-test";
|
|
18
|
+
let aUserId = "", bUserId = "", aNet = "", bNet = "";
|
|
19
|
+
let adminUserId = "", adminNet = "";
|
|
20
|
+
|
|
21
|
+
const get = async (path: string, token: string) => {
|
|
22
|
+
const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
23
|
+
return { status: r.status, body: await r.json() as any };
|
|
24
|
+
};
|
|
25
|
+
const post = async (path: string, token: string, body: unknown) => {
|
|
26
|
+
const r = await fetch(`${base}${path}`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
return { status: r.status, body: await r.json() as any };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function seedMessage(mid: string, userId: string, netId: string, content: string, meta?: string, acked = 0) {
|
|
35
|
+
const { db } = require("./db.js");
|
|
36
|
+
db.run(
|
|
37
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked, acked_at)
|
|
38
|
+
VALUES (?1, ?2, ?3, 'node-x', 'agent_message', NULL, ?4, 'info', ?5, ?6, ?7)`,
|
|
39
|
+
[mid, netId, userId, content, meta ?? null, acked, acked ? "2026-01-01 00:00:00" : null],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
// 传统全局 master token:requireAuth 对 GET /api/* 放行,但 resolveRequestAuth
|
|
45
|
+
// 返回 null(没有 user)。这条路径是分支内 `if (!callerUserId)` 唯一能被触发的
|
|
46
|
+
// 场景 —— 必须在 import server.js 之前设,AUTH_TOKEN 是模块加载期读的常量。
|
|
47
|
+
process.env.COMMHUB_AUTH_TOKEN ||= MASTER_TOKEN;
|
|
48
|
+
|
|
49
|
+
const { db } = await import("./db.js");
|
|
50
|
+
const { register, addNetworkMember } = await import("./auth.js");
|
|
51
|
+
const stamp = Date.now();
|
|
52
|
+
|
|
53
|
+
// 首个注册用户 = 全局 admin。**故意先注册一个用不到的 admin**,这样下面
|
|
54
|
+
// 两个探针 A/B 都不是 admin —— 隔离断言才有分辨力。
|
|
55
|
+
const admin = register(`ui_admin_${stamp}`, "UiAdmin-Strong-1!");
|
|
56
|
+
expect(admin.ok).toBe(true);
|
|
57
|
+
adminToken = admin.token!;
|
|
58
|
+
adminUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_admin_${stamp}`)!.user_id;
|
|
59
|
+
adminNet = admin.network_id!;
|
|
60
|
+
|
|
61
|
+
const a = register(`ui_a_${stamp}`, "UiA-Strong-1!");
|
|
62
|
+
const b = register(`ui_b_${stamp}`, "UiB-Strong-1!");
|
|
63
|
+
expect(a.ok && b.ok).toBe(true);
|
|
64
|
+
aToken = a.token!; bToken = b.token!;
|
|
65
|
+
aNet = a.network_id!; bNet = b.network_id!;
|
|
66
|
+
aUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_a_${stamp}`)!.user_id;
|
|
67
|
+
bUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_b_${stamp}`)!.user_id;
|
|
68
|
+
|
|
69
|
+
// 前提断言:探针不是 admin。靠"注册顺序"是隐式前提,会随夹具变化悄悄失效,
|
|
70
|
+
// 所以显式断言。
|
|
71
|
+
// 承重的是 role ≠ "admin";这里钉死具体值(auth.ts:88 非首用户写 "user"),
|
|
72
|
+
// 因为 `not.toBe("admin")` 在 role 为 undefined(查不到行)时也会通过 —— 松判据
|
|
73
|
+
// 会朝"没问题"那边失败。
|
|
74
|
+
for (const [name, id] of [["A", aUserId], ["B", bUserId]] as const) {
|
|
75
|
+
const role = db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role;
|
|
76
|
+
expect([name, role]).toEqual([name, "user"]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 🔴 B 必须在**同一个 network** 里也有一条私信。
|
|
80
|
+
// 第一版夹具让 A/B 各处一网,于是 addNetworkScope 顺手挡住了 B 的行 ——
|
|
81
|
+
// 变异掉 `WHERE user_id = ?1` 后隔离断言照样通过(M1/M2/M4 全部存活)。
|
|
82
|
+
// 同网同事之间,user_id 过滤是**唯一**的隔离手段,夹具必须落在那一格。
|
|
83
|
+
expect(addNetworkMember(aNet, bUserId, "member").ok).toBe(true);
|
|
84
|
+
|
|
85
|
+
seedMessage("dm_a1", aUserId, aNet, "A 的第一条");
|
|
86
|
+
seedMessage("dm_a2", aUserId, aNet, "A 的第二条");
|
|
87
|
+
seedMessage("dm_b_same_net", bUserId, aNet, "B 在 A 同网里的私信");
|
|
88
|
+
seedMessage("dm_b1", bUserId, bNet, "B 的私信");
|
|
89
|
+
// A 自己的、但落在 A 不是成员的 network 上的行(例如 A 被移出该网后的遗留)。
|
|
90
|
+
// 它是 addNetworkScope 这次调用的唯一 witnessed-red 对象:user_id 过滤对它无效。
|
|
91
|
+
seedMessage("dm_a_foreign_net", aUserId, bNet, "A 在外网的遗留私信");
|
|
92
|
+
// #1493 —— network_id 升到 schema 级 NOT NULL 后,「network_id 为 NULL 的行」已
|
|
93
|
+
// **schema 级不可能**(写路径原本三闸挡、现在 DB 直接拒)。原先手播的 dm_a_nonet /
|
|
94
|
+
// dm_admin_nonet 两条 NULL 夹具随之删除:
|
|
95
|
+
// · dm_a_nonet(证"受网络限制成员读不到 NULL 行")—— 网络 scope 隔离已由下面
|
|
96
|
+
// dm_a_foreign_net(A 自己的行、落在 A 非成员的网)覆盖,不丢覆盖。
|
|
97
|
+
// · dm_admin_nonet 重锚到 admin **自己网络**的非 NULL 行 dm_admin_own,保住
|
|
98
|
+
// "admin(scope 助手 no-op)仍只读自己的、不绕过 user_id 过滤"这条覆盖。
|
|
99
|
+
seedMessage("dm_admin_own", adminUserId, adminNet, "admin 自己网络里的私信");
|
|
100
|
+
// #1493 SDK马 建议:用**非 NULL** 行补回"admin(scope 助手 no-op)能读到别网的自己的行"
|
|
101
|
+
// 这维覆盖(原先靠 NULL 行,现 NULL 不可能)。对照 dm_a_foreign_net(受限成员读不到),
|
|
102
|
+
// 两边都不依赖 NULL。
|
|
103
|
+
seedMessage("dm_admin_foreign_net", adminUserId, bNet, "admin 在别网的自己的私信");
|
|
104
|
+
seedMessage("dm_a_read", aUserId, aNet, "A 已读的一条", undefined, 1);
|
|
105
|
+
seedMessage("dm_a_secret", aUserId, aNet,
|
|
106
|
+
"凭据在正文里 ntok_abcdef123456", JSON.stringify({ k: "ghp_" + "A".repeat(24) }));
|
|
107
|
+
|
|
108
|
+
const mod = await import("./server.js");
|
|
109
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
110
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(() => {
|
|
114
|
+
try { server?.stop(true); } catch {}
|
|
115
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("#1459 ① P3 GET /api/messages?scope=user", () => {
|
|
119
|
+
test("🔴 只返回调用者自己的消息 —— A 读不到 B 的", async () => {
|
|
120
|
+
const { status, body } = await get("/api/messages?scope=user", aToken);
|
|
121
|
+
expect(status).toBe(200);
|
|
122
|
+
expect(body.ok).toBe(true);
|
|
123
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
124
|
+
expect(ids).toContain("dm_a1");
|
|
125
|
+
expect(ids).toContain("dm_a_read"); // 不加 unacked 时已读也要回来
|
|
126
|
+
expect(ids).not.toContain("dm_b_same_net"); // 承重:同网,只有 user_id 过滤挡得住
|
|
127
|
+
expect(ids).not.toContain("dm_b1"); // 跨网,scope 助手挡
|
|
128
|
+
expect(ids).not.toContain("dm_a_foreign_net"); // 承重:A 自己的行,只有 scope 助手挡得住
|
|
129
|
+
expect(body.messages.every((m: any) => m.user_id === aUserId)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("🔴 收件人取自 auth ctx —— query 里指定别人的 user_id 无效", async () => {
|
|
133
|
+
const { body } = await get(`/api/messages?scope=user&user_id=${bUserId}`, aToken);
|
|
134
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
135
|
+
expect(ids).not.toContain("dm_b_same_net");
|
|
136
|
+
expect(ids).not.toContain("dm_b1");
|
|
137
|
+
expect(body.messages.every((m: any) => m.user_id === aUserId)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("🔴 redact-at-read:正文与 meta 里的凭据形状被遮住", async () => {
|
|
141
|
+
const { body } = await get("/api/messages?scope=user", aToken);
|
|
142
|
+
const row = body.messages.find((m: any) => m.message_id === "dm_a_secret");
|
|
143
|
+
expect(row).toBeTruthy();
|
|
144
|
+
expect(row.content).toContain("ntok_***redacted***");
|
|
145
|
+
expect(row.content).not.toContain("ntok_abcdef123456");
|
|
146
|
+
expect(row.meta_json).toContain("ghp_***redacted***");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// #1493 后 NULL 网络行不可能,原"受网络限制成员读不到 NULL 行"一测删除——
|
|
150
|
+
// 网络 scope 隔离已由上面 dm_a_foreign_net(承重断言,test 112 line 121)覆盖。
|
|
151
|
+
// admin 一测重锚到非 NULL:验 admin(scope 助手 no-op)**仍只读自己的、不绕过 user_id 过滤**。
|
|
152
|
+
test("admin(scope 助手 no-op)仍受 user_id 限制:只读自己的、不读别人的", async () => {
|
|
153
|
+
const { body } = await get("/api/messages?scope=user", adminToken);
|
|
154
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
155
|
+
expect(ids).toContain("dm_admin_own"); // 读得到自己的
|
|
156
|
+
expect(ids).toContain("dm_admin_foreign_net"); // scope 助手 no-op → 跨网也读得到自己的
|
|
157
|
+
expect(ids).not.toContain("dm_a1"); // 承重:admin 不绕过 user_id 过滤读别人的
|
|
158
|
+
expect(body.messages.every((m: any) => m.user_id === adminUserId)).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("unread 是未读数而非总数(A:4 条里 3 条未读)", async () => {
|
|
162
|
+
const { body } = await get("/api/messages?scope=user", aToken);
|
|
163
|
+
expect(body.messages.length).toBe(4);
|
|
164
|
+
expect(body.unread).toBe(3);
|
|
165
|
+
expect(body.pending_count).toBe(body.unread);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("unacked=1 排掉已读那条", async () => {
|
|
169
|
+
const { body } = await get("/api/messages?scope=user&unacked=1", aToken);
|
|
170
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
171
|
+
expect(ids).not.toContain("dm_a_read"); // 承重:集合里确实有已读行才有分辨力
|
|
172
|
+
expect(ids.sort()).toEqual(["dm_a1", "dm_a2", "dm_a_secret"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("未鉴权 ⇒ 401(/api 全局闸)", async () => {
|
|
176
|
+
const r = await fetch(`${base}/api/messages?scope=user`);
|
|
177
|
+
expect(r.status).toBe(401);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("🔴 legacy master token 过得了全局闸但没有 user ⇒ 401,不能回一个空列表", async () => {
|
|
181
|
+
// 这条才是分支内 `if (!callerUserId)` 的 witnessed-red:把它换成
|
|
182
|
+
// `?? ""` 后本条从 401 变成 200 + 空列表 —— "看起来正常"的错误答案。
|
|
183
|
+
const { status, body } = await get("/api/messages?scope=user", MASTER_TOKEN);
|
|
184
|
+
expect([status, body.error]).toEqual([401, "auth_required"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("alias 分支未受影响(dashboard 依赖它)", async () => {
|
|
188
|
+
const { status, body } = await get("/api/messages", adminToken);
|
|
189
|
+
expect(status).toBe(200);
|
|
190
|
+
expect(body.ok).toBe(true);
|
|
191
|
+
expect(Array.isArray(body.messages)).toBe(true);
|
|
192
|
+
expect(body.unread).toBeUndefined(); // 老分支不该多出新字段
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("#1459 ① P3 POST /api/messages/ack", () => {
|
|
197
|
+
test("ack 自己的消息 ⇒ acked 置位、未读数下降", async () => {
|
|
198
|
+
const before = (await get("/api/messages?scope=user", aToken)).body.unread;
|
|
199
|
+
const { status, body } = await post("/api/messages/ack", aToken, { message_id: "dm_a1" });
|
|
200
|
+
expect(status).toBe(200);
|
|
201
|
+
expect(body.acked).toBe(1);
|
|
202
|
+
const after = (await get("/api/messages?scope=user", aToken)).body.unread;
|
|
203
|
+
expect(after).toBe(before - 1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("🔴 ack 不了同网同事的消息 —— 改动行数为 0,且对方仍是未读", async () => {
|
|
207
|
+
const { body } = await post("/api/messages/ack", aToken, { message_id: "dm_b_same_net" });
|
|
208
|
+
expect(body.acked).toBe(0);
|
|
209
|
+
const bView = (await get("/api/messages?scope=user", bToken)).body;
|
|
210
|
+
expect(bView.messages.find((m: any) => m.message_id === "dm_b_same_net").acked).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("ack 跨网的消息同样为 0", async () => {
|
|
214
|
+
const { body } = await post("/api/messages/ack", aToken, { message_id: "dm_b1" });
|
|
215
|
+
expect(body.acked).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("缺参 ⇒ 400;未鉴权 ⇒ 401", async () => {
|
|
219
|
+
expect((await post("/api/messages/ack", aToken, {})).status).toBe(400);
|
|
220
|
+
const r = await fetch(`${base}/api/messages/ack`, { method: "POST", body: "{}" });
|
|
221
|
+
expect(r.status).toBe(401);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// #1459 ① — user_inbox 建表 + 幂等打底(P1)。零行为变化,只落 schema。
|
|
2
|
+
//
|
|
3
|
+
// 承重属性(SDK马 review 要点):message_id 即主键 → send_desktop_message 因 HTTP
|
|
4
|
+
// 响应丢失被重试时,同一 dm_<uuid> 用 INSERT OR IGNORE 不会插出重复行(前端免去重)。
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { db } from "./db.js";
|
|
8
|
+
|
|
9
|
+
describe("#1459 user_inbox schema (P1)", () => {
|
|
10
|
+
test("table exists with the expected columns", () => {
|
|
11
|
+
const cols = db.all<{ name: string }>("PRAGMA table_info(user_inbox)").map(c => c.name);
|
|
12
|
+
for (const c of ["message_id", "network_id", "user_id", "from_session", "kind", "title", "content", "severity", "meta_json", "acked", "created_at", "acked_at"]) {
|
|
13
|
+
expect(cols).toContain(c);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// 注:这条钉的是「主键在我们以为的那一列」(SQLite 行为),**不是**「写入路径幂等」——
|
|
18
|
+
// 后者要 P2 驱动真实 send_desktop_message 两次来证(SDK马 review)。这里只保证主键选对。
|
|
19
|
+
test("message_id is PRIMARY KEY → INSERT OR IGNORE dedups on the same id", () => {
|
|
20
|
+
const mid = `dm_test_${Date.now()}`;
|
|
21
|
+
try {
|
|
22
|
+
const ins = () => db.run(
|
|
23
|
+
`INSERT OR IGNORE INTO user_inbox (message_id, network_id, user_id, from_session, content)
|
|
24
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
25
|
+
[mid, "net_test_1459", "u_test_1459", "sender-a", "hello"],
|
|
26
|
+
);
|
|
27
|
+
ins();
|
|
28
|
+
ins(); // 同 message_id 第二次插入应被 IGNORE
|
|
29
|
+
const n = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n ?? 0;
|
|
30
|
+
expect(n).toBe(1);
|
|
31
|
+
} finally {
|
|
32
|
+
db.run("DELETE FROM user_inbox WHERE message_id = ?1", mid);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("defaults: acked=0, kind/severity='info'", () => {
|
|
37
|
+
const mid = `dm_def_${Date.now()}`;
|
|
38
|
+
try {
|
|
39
|
+
db.run(
|
|
40
|
+
// #1493 后 network_id 是 NOT NULL,补上(本测试只验其它列的默认值)
|
|
41
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, content) VALUES (?1, ?2, ?3, ?4)`,
|
|
42
|
+
[mid, "net_def_1459", "u_def_1459", "body"],
|
|
43
|
+
);
|
|
44
|
+
const row = db.get<{ acked: number; kind: string; severity: string }>(
|
|
45
|
+
"SELECT acked, kind, severity FROM user_inbox WHERE message_id = ?1", mid,
|
|
46
|
+
);
|
|
47
|
+
expect(row?.acked).toBe(0);
|
|
48
|
+
expect(row?.kind).toBe("info");
|
|
49
|
+
expect(row?.severity).toBe("info");
|
|
50
|
+
} finally {
|
|
51
|
+
db.run("DELETE FROM user_inbox WHERE message_id = ?1", mid);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// RFC-028 P1 §4.1 vault layer tests. F2 lazy gate is the critical
|
|
2
|
+
// invariant — existing prod hub升级 without ANET_HUB_SECRET_VAULT_KEY
|
|
3
|
+
// must NOT boot-fail. Every test runs with deterministic key isolation.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test, beforeEach, afterAll } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
encryptSecret, decryptSecret,
|
|
8
|
+
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
9
|
+
vaultStatusForBoot, vaultMasterKeyFingerprint,
|
|
10
|
+
_resetVaultKeyForTest, VaultError,
|
|
11
|
+
} from "./vault.js";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
|
|
14
|
+
const TEST_KEY_HEX = "0123456789abcdef".repeat(4); // 32 bytes hex = 64 chars
|
|
15
|
+
|
|
16
|
+
function setKey(hex: string | undefined): void {
|
|
17
|
+
if (hex === undefined) delete process.env.ANET_HUB_SECRET_VAULT_KEY;
|
|
18
|
+
else process.env.ANET_HUB_SECRET_VAULT_KEY = hex;
|
|
19
|
+
_resetVaultKeyForTest();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
db.run("DELETE FROM network_secrets");
|
|
24
|
+
db.run("DELETE FROM provider_models");
|
|
25
|
+
db.run("DELETE FROM providers");
|
|
26
|
+
});
|
|
27
|
+
afterAll(() => { _resetVaultKeyForTest(); delete process.env.ANET_HUB_SECRET_VAULT_KEY; });
|
|
28
|
+
|
|
29
|
+
describe("RFC-028 P1 §4.1 F2 — LAZY GATE (critical: don't brick prod hub)", () => {
|
|
30
|
+
test("hub boot WITHOUT env + WITHOUT vault data → vaultStatusForBoot says configured=false, needsKeyToOp=false (boots OK)", () => {
|
|
31
|
+
setKey(undefined);
|
|
32
|
+
const s = vaultStatusForBoot();
|
|
33
|
+
expect(s.configured).toBe(false);
|
|
34
|
+
expect(s.tablesHaveData).toBe(false);
|
|
35
|
+
expect(s.needsKeyToOp).toBe(false);
|
|
36
|
+
// Crucially: no throw, no crash, hub keeps running.
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("hub boot WITH env + WITHOUT vault data → configured=true, tablesEmpty (no fingerprint needed)", () => {
|
|
40
|
+
setKey(TEST_KEY_HEX);
|
|
41
|
+
const s = vaultStatusForBoot();
|
|
42
|
+
expect(s.configured).toBe(true);
|
|
43
|
+
expect(s.tablesHaveData).toBe(false);
|
|
44
|
+
expect(s.needsKeyToOp).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("hub WITHOUT env + WITH vault data → needsKeyToOp=true (banner warning, NO throw)", () => {
|
|
48
|
+
setKey(TEST_KEY_HEX);
|
|
49
|
+
vaultUpsert("net_test", "TEST_KEY", "plaintext-value");
|
|
50
|
+
setKey(undefined);
|
|
51
|
+
const s = vaultStatusForBoot();
|
|
52
|
+
expect(s.configured).toBe(false);
|
|
53
|
+
expect(s.tablesHaveData).toBe(true);
|
|
54
|
+
expect(s.needsKeyToOp).toBe(true);
|
|
55
|
+
// Still no throw — banner is informational; throw only happens on
|
|
56
|
+
// actual vault op (test below).
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("vault op WITHOUT env throws vault_master_key_missing (lazy throw, not boot throw)", () => {
|
|
60
|
+
setKey(undefined);
|
|
61
|
+
expect(() => vaultUpsert("net_test", "K", "v")).toThrow(VaultError);
|
|
62
|
+
try { vaultUpsert("net_test", "K", "v"); }
|
|
63
|
+
catch (e: any) { expect(e.code).toBe("vault_master_key_missing"); }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("vault op WITH invalid env (not 32 bytes hex) throws vault_master_key_invalid", () => {
|
|
67
|
+
setKey("not-hex");
|
|
68
|
+
expect(() => vaultUpsert("net_test", "K", "v")).toThrow(/vault_master_key_invalid/);
|
|
69
|
+
setKey("aabbcc"); // too short
|
|
70
|
+
expect(() => vaultUpsert("net_test", "K", "v")).toThrow(/vault_master_key_invalid/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("RFC-028 P1 §4.1 — AES-GCM encrypt/decrypt round-trip", () => {
|
|
75
|
+
test("encrypt+decrypt roundtrip preserves plaintext", () => {
|
|
76
|
+
setKey(TEST_KEY_HEX);
|
|
77
|
+
const pt = "sk-ant-api03-abc123xyz";
|
|
78
|
+
const enc = encryptSecret(pt);
|
|
79
|
+
expect(enc.ciphertext.length).toBeGreaterThan(0);
|
|
80
|
+
expect(enc.iv.length).toBe(12);
|
|
81
|
+
expect(enc.tag.length).toBe(16);
|
|
82
|
+
expect(decryptSecret(enc)).toBe(pt);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("ciphertext is unique per call (random IV)", () => {
|
|
86
|
+
setKey(TEST_KEY_HEX);
|
|
87
|
+
const a = encryptSecret("same-secret");
|
|
88
|
+
const b = encryptSecret("same-secret");
|
|
89
|
+
expect(a.iv.equals(b.iv)).toBe(false);
|
|
90
|
+
expect(a.ciphertext.equals(b.ciphertext)).toBe(false);
|
|
91
|
+
// Both decrypt back to same plaintext
|
|
92
|
+
expect(decryptSecret(a)).toBe("same-secret");
|
|
93
|
+
expect(decryptSecret(b)).toBe("same-secret");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("tamper detection — flipping ciphertext bit throws vault_decrypt_failed", () => {
|
|
97
|
+
setKey(TEST_KEY_HEX);
|
|
98
|
+
const enc = encryptSecret("important");
|
|
99
|
+
enc.ciphertext[0] ^= 0x01;
|
|
100
|
+
expect(() => decryptSecret(enc)).toThrow(/vault_decrypt_failed/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("tamper detection — flipping tag bit throws vault_decrypt_failed", () => {
|
|
104
|
+
setKey(TEST_KEY_HEX);
|
|
105
|
+
const enc = encryptSecret("important");
|
|
106
|
+
enc.tag[0] ^= 0x01;
|
|
107
|
+
expect(() => decryptSecret(enc)).toThrow(/vault_decrypt_failed/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("wrong key cannot decrypt", () => {
|
|
111
|
+
setKey(TEST_KEY_HEX);
|
|
112
|
+
const enc = encryptSecret("important");
|
|
113
|
+
setKey("ff".repeat(32)); // different key
|
|
114
|
+
expect(() => decryptSecret(enc)).toThrow(/vault_decrypt_failed/);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("RFC-028 P1 §4.1 — DB ops + key listing (永不返 value)", () => {
|
|
119
|
+
test("vaultUpsert + vaultGet roundtrip via DB", () => {
|
|
120
|
+
setKey(TEST_KEY_HEX);
|
|
121
|
+
vaultUpsert("net_A", "ANTHROPIC_API_KEY", "sk-ant-prod-1");
|
|
122
|
+
expect(vaultGet("net_A", "ANTHROPIC_API_KEY")).toBe("sk-ant-prod-1");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("vaultUpsert replaces existing key (same network_id + key)", () => {
|
|
126
|
+
setKey(TEST_KEY_HEX);
|
|
127
|
+
vaultUpsert("net_B", "K1", "v1");
|
|
128
|
+
vaultUpsert("net_B", "K1", "v2");
|
|
129
|
+
expect(vaultGet("net_B", "K1")).toBe("v2");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("vaultGet returns null for missing key (does NOT throw)", () => {
|
|
133
|
+
setKey(TEST_KEY_HEX);
|
|
134
|
+
expect(vaultGet("net_X", "MISSING")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("vaultListKeys returns key NAMES only (no values, ever)", () => {
|
|
138
|
+
setKey(TEST_KEY_HEX);
|
|
139
|
+
vaultUpsert("net_L", "KEY_A", "value-a");
|
|
140
|
+
vaultUpsert("net_L", "KEY_B", "value-b");
|
|
141
|
+
const keys = vaultListKeys("net_L");
|
|
142
|
+
expect(keys).toEqual(["KEY_A", "KEY_B"]); // sorted; no values
|
|
143
|
+
expect(JSON.stringify(keys)).not.toContain("value-a");
|
|
144
|
+
expect(JSON.stringify(keys)).not.toContain("value-b");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("network isolation — netA's secret不 visible to netB", () => {
|
|
148
|
+
setKey(TEST_KEY_HEX);
|
|
149
|
+
vaultUpsert("net_A", "SHARED_NAME", "value-from-A");
|
|
150
|
+
vaultUpsert("net_B", "SHARED_NAME", "value-from-B");
|
|
151
|
+
expect(vaultGet("net_A", "SHARED_NAME")).toBe("value-from-A");
|
|
152
|
+
expect(vaultGet("net_B", "SHARED_NAME")).toBe("value-from-B");
|
|
153
|
+
expect(vaultListKeys("net_A")).toEqual(["SHARED_NAME"]);
|
|
154
|
+
expect(vaultListKeys("net_B")).toEqual(["SHARED_NAME"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("vaultDelete removes key + returns true; second delete returns false", () => {
|
|
158
|
+
setKey(TEST_KEY_HEX);
|
|
159
|
+
vaultUpsert("net_D", "K", "v");
|
|
160
|
+
expect(vaultDelete("net_D", "K")).toBe(true);
|
|
161
|
+
expect(vaultGet("net_D", "K")).toBeNull();
|
|
162
|
+
expect(vaultDelete("net_D", "K")).toBe(false); // no-op
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("RFC-028 P1 §4.1 — master key fingerprint", () => {
|
|
167
|
+
test("fingerprint same for same key, different for different key", () => {
|
|
168
|
+
setKey(TEST_KEY_HEX);
|
|
169
|
+
const fp1 = vaultMasterKeyFingerprint();
|
|
170
|
+
expect(fp1.length).toBe(16);
|
|
171
|
+
setKey(TEST_KEY_HEX); // re-set same key
|
|
172
|
+
expect(vaultMasterKeyFingerprint()).toBe(fp1);
|
|
173
|
+
setKey("ff".repeat(32));
|
|
174
|
+
expect(vaultMasterKeyFingerprint()).not.toBe(fp1);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("RFC-028 P1 §4.1 — DB ciphertext storage (PRAGMA-style: NO plaintext in DB)", () => {
|
|
179
|
+
test("BLOB stored is ciphertext, not plaintext (raw SELECT verifies)", () => {
|
|
180
|
+
setKey(TEST_KEY_HEX);
|
|
181
|
+
const plaintext = "sk-very-secret-key-do-not-leak";
|
|
182
|
+
vaultUpsert("net_P", "ANTHROPIC_API_KEY", plaintext);
|
|
183
|
+
const raw = db.get<{ ciphertext: Buffer | Uint8Array; iv: Buffer | Uint8Array; tag: Buffer | Uint8Array }>(
|
|
184
|
+
"SELECT ciphertext, iv, tag FROM network_secrets WHERE network_id = ?1 AND key = ?2",
|
|
185
|
+
"net_P", "ANTHROPIC_API_KEY",
|
|
186
|
+
);
|
|
187
|
+
expect(raw).not.toBeNull();
|
|
188
|
+
const ctBuf = Buffer.isBuffer(raw!.ciphertext) ? raw!.ciphertext : Buffer.from(raw!.ciphertext as any);
|
|
189
|
+
// Plaintext substring MUST NOT appear in ciphertext bytes
|
|
190
|
+
expect(ctBuf.toString("binary")).not.toContain(plaintext);
|
|
191
|
+
expect(ctBuf.toString("utf8")).not.toContain(plaintext);
|
|
192
|
+
// Even as hex / base64 / partial — bytes must not match plaintext bytes
|
|
193
|
+
const plainBytes = Buffer.from(plaintext, "utf8");
|
|
194
|
+
let found = false;
|
|
195
|
+
for (let i = 0; i <= ctBuf.length - plainBytes.length; i++) {
|
|
196
|
+
if (ctBuf.subarray(i, i + plainBytes.length).equals(plainBytes)) { found = true; break; }
|
|
197
|
+
}
|
|
198
|
+
expect(found).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// RFC-028 P1 §4.1 — vault layer (AES-GCM at-rest + lazy master key gate).
|
|
2
|
+
//
|
|
3
|
+
// F2 CRITICAL: master key from ANET_HUB_SECRET_VAULT_KEY env is
|
|
4
|
+
// resolved LAZILY — hub boot does NOT require it. Existing prod hubs
|
|
5
|
+
// without the env should boot + run normally. The env is only required
|
|
6
|
+
// when:
|
|
7
|
+
// (a) vault write hits (upsert_network_secret)
|
|
8
|
+
// (b) vault read hits (probe / create-node env_refs)
|
|
9
|
+
// (c) network_secrets / providers tables are non-empty at boot
|
|
10
|
+
// (banner status only — doesn't throw)
|
|
11
|
+
//
|
|
12
|
+
// If the env is missing AND a vault op runs → throw `vault_master_key_missing`
|
|
13
|
+
// with explicit migration message. Existing hub stays up.
|
|
14
|
+
|
|
15
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
|
16
|
+
import { db } from "./db.js";
|
|
17
|
+
|
|
18
|
+
let _masterKey: Buffer | null = null;
|
|
19
|
+
let _bootBannerShown = false;
|
|
20
|
+
|
|
21
|
+
/** Lazy getter — throws on first vault op if env missing. */
|
|
22
|
+
export function getVaultMasterKey(): Buffer {
|
|
23
|
+
if (_masterKey) return _masterKey;
|
|
24
|
+
const hex = process.env.ANET_HUB_SECRET_VAULT_KEY;
|
|
25
|
+
if (!hex) {
|
|
26
|
+
throw new VaultError("vault_master_key_missing",
|
|
27
|
+
"ANET_HUB_SECRET_VAULT_KEY env var not set. Generate one: openssl rand -hex 32. " +
|
|
28
|
+
"Required only when using vault/provider features; this op tried to access vault."
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
|
|
32
|
+
throw new VaultError("vault_master_key_invalid",
|
|
33
|
+
"ANET_HUB_SECRET_VAULT_KEY must be 32 bytes hex (64 chars). Generate: openssl rand -hex 32"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
_masterKey = Buffer.from(hex, "hex");
|
|
37
|
+
return _masterKey;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Banner-only check (does not throw). Call on boot for operator visibility. */
|
|
41
|
+
export function vaultStatusForBoot(): { configured: boolean; tablesHaveData: boolean; needsKeyToOp: boolean } {
|
|
42
|
+
const configured = !!process.env.ANET_HUB_SECRET_VAULT_KEY
|
|
43
|
+
&& /^[0-9a-fA-F]{64}$/.test(process.env.ANET_HUB_SECRET_VAULT_KEY);
|
|
44
|
+
let tablesHaveData = false;
|
|
45
|
+
try {
|
|
46
|
+
const r1 = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_secrets");
|
|
47
|
+
const r2 = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM providers");
|
|
48
|
+
tablesHaveData = (r1?.n || 0) > 0 || (r2?.n || 0) > 0;
|
|
49
|
+
} catch { /* tables may not exist yet */ }
|
|
50
|
+
return {
|
|
51
|
+
configured,
|
|
52
|
+
tablesHaveData,
|
|
53
|
+
needsKeyToOp: tablesHaveData && !configured,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reset for unit tests (must be combined with delete process.env.ANET_HUB_SECRET_VAULT_KEY). */
|
|
58
|
+
export function _resetVaultKeyForTest(): void { _masterKey = null; _bootBannerShown = false; }
|
|
59
|
+
|
|
60
|
+
export class VaultError extends Error {
|
|
61
|
+
constructor(public code: string, msg: string) {
|
|
62
|
+
super(`${code}: ${msg}`);
|
|
63
|
+
this.name = "VaultError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── AES-GCM encrypt/decrypt ─────────────────────────────────────────
|
|
68
|
+
// AES-256-GCM. 12-byte IV per RFC 5116. Auth tag is 16 bytes. We
|
|
69
|
+
// store iv + ciphertext + tag separately (DB schema mirrors NIST SP
|
|
70
|
+
// 800-38D structure for forward-compatibility).
|
|
71
|
+
|
|
72
|
+
const ALGO = "aes-256-gcm";
|
|
73
|
+
const IV_BYTES = 12;
|
|
74
|
+
const TAG_BYTES = 16;
|
|
75
|
+
|
|
76
|
+
export interface EncryptedSecret {
|
|
77
|
+
ciphertext: Buffer;
|
|
78
|
+
iv: Buffer;
|
|
79
|
+
tag: Buffer;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function encryptSecret(plaintext: string): EncryptedSecret {
|
|
83
|
+
const key = getVaultMasterKey();
|
|
84
|
+
const iv = randomBytes(IV_BYTES);
|
|
85
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
86
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
87
|
+
const tag = cipher.getAuthTag();
|
|
88
|
+
return { ciphertext: ct, iv, tag };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function decryptSecret(enc: EncryptedSecret): string {
|
|
92
|
+
const key = getVaultMasterKey();
|
|
93
|
+
if (enc.tag.length !== TAG_BYTES) {
|
|
94
|
+
throw new VaultError("vault_tag_invalid", `auth tag must be ${TAG_BYTES} bytes, got ${enc.tag.length}`);
|
|
95
|
+
}
|
|
96
|
+
const decipher = createDecipheriv(ALGO, key, enc.iv);
|
|
97
|
+
decipher.setAuthTag(enc.tag);
|
|
98
|
+
try {
|
|
99
|
+
const pt = Buffer.concat([decipher.update(enc.ciphertext), decipher.final()]);
|
|
100
|
+
return pt.toString("utf8");
|
|
101
|
+
} catch (e: any) {
|
|
102
|
+
// GCM auth fail = either wrong key, tampered ciphertext, or
|
|
103
|
+
// tampered iv/tag. All collapse to one error.
|
|
104
|
+
throw new VaultError("vault_decrypt_failed", `AES-GCM auth fail (key/iv/tag/ciphertext mismatch): ${e?.message || e}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── DB ops ───────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export function vaultUpsert(networkId: string, key: string, plaintext: string): void {
|
|
111
|
+
const enc = encryptSecret(plaintext);
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
db.run(
|
|
114
|
+
`INSERT INTO network_secrets (network_id, key, ciphertext, iv, tag, created_at, updated_at)
|
|
115
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
|
|
116
|
+
ON CONFLICT(network_id, key) DO UPDATE SET
|
|
117
|
+
ciphertext = ?3, iv = ?4, tag = ?5, updated_at = ?6`,
|
|
118
|
+
[networkId, key, enc.ciphertext, enc.iv, enc.tag, now],
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Returns the decrypted plaintext, or null if no row.
|
|
123
|
+
* Throws VaultError on missing key / decrypt failure. */
|
|
124
|
+
export function vaultGet(networkId: string, key: string): string | null {
|
|
125
|
+
const row = db.get<{ ciphertext: Buffer; iv: Buffer; tag: Buffer }>(
|
|
126
|
+
"SELECT ciphertext, iv, tag FROM network_secrets WHERE network_id = ?1 AND key = ?2",
|
|
127
|
+
networkId, key,
|
|
128
|
+
);
|
|
129
|
+
if (!row) return null;
|
|
130
|
+
return decryptSecret({
|
|
131
|
+
ciphertext: Buffer.isBuffer(row.ciphertext) ? row.ciphertext : Buffer.from(row.ciphertext as any),
|
|
132
|
+
iv: Buffer.isBuffer(row.iv) ? row.iv : Buffer.from(row.iv as any),
|
|
133
|
+
tag: Buffer.isBuffer(row.tag) ? row.tag : Buffer.from(row.tag as any),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** List vault key names for a network (NEVER returns values). */
|
|
138
|
+
export function vaultListKeys(networkId: string): string[] {
|
|
139
|
+
const rows = db.all<{ key: string }>(
|
|
140
|
+
"SELECT key FROM network_secrets WHERE network_id = ?1 ORDER BY key",
|
|
141
|
+
networkId,
|
|
142
|
+
);
|
|
143
|
+
return rows.map(r => r.key);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function vaultDelete(networkId: string, key: string): boolean {
|
|
147
|
+
const r: any = db.run("DELETE FROM network_secrets WHERE network_id = ?1 AND key = ?2", [networkId, key]);
|
|
148
|
+
return (r?.changes ?? 0) > 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Test/diagnostic: fingerprint of master key (sha256 hex first 8) so
|
|
152
|
+
* ops can verify two hubs share the same vault key without leaking it. */
|
|
153
|
+
export function vaultMasterKeyFingerprint(): string {
|
|
154
|
+
const key = getVaultMasterKey();
|
|
155
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
156
|
+
}
|