@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.41
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +107 -13
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +654 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -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 +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -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 +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -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/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/push.ts +163 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/require-explicit-test-db.ts +9 -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 +212 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -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 +437 -0
- package/src/server.ts +3664 -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/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -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 +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +471 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -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 +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2994 -191
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-read-http.test.ts +220 -0
- package/src/user-inbox-schema.test.ts +53 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
package/src/uploads.ts
CHANGED
|
@@ -53,6 +53,17 @@ export const UPLOAD_RATE_MAX_ENTRIES = 50_000;
|
|
|
53
53
|
// traversal.
|
|
54
54
|
export const FILE_ID_REGEX = /^[A-Za-z0-9_-]{8,64}$/;
|
|
55
55
|
|
|
56
|
+
// Stored extension tokens have one invariant at every path boundary:
|
|
57
|
+
// either no extension, or a leading dot followed by 1-16 ASCII
|
|
58
|
+
// alphanumeric characters. Keep this separate from sanitizeExt(), which
|
|
59
|
+
// parses a client filename and therefore deliberately has different
|
|
60
|
+
// capture/anchoring semantics.
|
|
61
|
+
const EXT_TOKEN_REGEX = /^\.[A-Za-z0-9]{1,16}$/;
|
|
62
|
+
|
|
63
|
+
function isValidExtToken(ext: unknown): ext is string {
|
|
64
|
+
return typeof ext === "string" && (ext === "" || EXT_TOKEN_REGEX.test(ext));
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
export type GeneratedFile = {
|
|
57
68
|
fileId: string;
|
|
58
69
|
ext: string;
|
|
@@ -94,14 +105,51 @@ export function sanitizeExt(filename: string | undefined | null): string {
|
|
|
94
105
|
return match ? "." + match[1].toLowerCase() : "";
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
// YYYY-MM-DD bucket string regex — matches what getDateBucket produces
|
|
109
|
+
// and what an index entry's `date_bucket` field is validated against.
|
|
110
|
+
const DATE_BUCKET_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
111
|
+
|
|
97
112
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
113
|
+
* Validate a date_bucket string against BOTH shape (YYYY-MM-DD regex)
|
|
114
|
+
* AND real-calendar semantics: rejects "2026-02-30", "2026-13-01",
|
|
115
|
+
* "2026-00-15", leap-day-on-non-leap-year, etc. Shape-only check is not
|
|
116
|
+
* enough because the regex accepts any digit combination in each
|
|
117
|
+
* position.
|
|
118
|
+
*
|
|
119
|
+
* Used as a pre-check by the download handler before it calls into
|
|
120
|
+
* pathForExistingBlob — a non-calendar bucket is a 404 (invalid input
|
|
121
|
+
* rejected at the boundary), NOT a 500 (uncaught throw from deep in
|
|
122
|
+
* the read path). Distinguishing these matters: 500 says "server
|
|
123
|
+
* failed"; 404 says "we know your request is bad, refused early".
|
|
124
|
+
*/
|
|
125
|
+
export function isValidCalendarBucket(bucket: unknown): bucket is string {
|
|
126
|
+
if (typeof bucket !== "string") return false;
|
|
127
|
+
if (!DATE_BUCKET_REGEX.test(bucket)) return false;
|
|
128
|
+
// Round-trip via Date to reject non-calendar shapes like "2026-02-30"
|
|
129
|
+
// or "2026-13-01". `Date.UTC` coerces overflow ("2026-02-30" becomes
|
|
130
|
+
// "2026-03-02") — if the ISO round-trip doesn't equal the input, the
|
|
131
|
+
// caller supplied a non-calendar bucket.
|
|
132
|
+
const [yStr, mStr, dStr] = bucket.split("-");
|
|
133
|
+
const y = Number(yStr), m = Number(mStr), d = Number(dStr);
|
|
134
|
+
if (m < 1 || m > 12) return false;
|
|
135
|
+
if (d < 1 || d > 31) return false;
|
|
136
|
+
const roundtrip = new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
|
|
137
|
+
return roundtrip === bucket;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build the storage path for a NEW upload. Computes today's date bucket
|
|
142
|
+
* from the runtime clock (or `opts.now` for tests). Use ONLY for creating
|
|
143
|
+
* a fresh blob; for reading an existing blob whose bucket is already
|
|
144
|
+
* recorded in the index, use pathForExistingBlob() instead.
|
|
145
|
+
*
|
|
146
|
+
* The file_id and ext are validated against strict regexes; any
|
|
147
|
+
* path-separator or `..` segment from a malicious client gets rejected
|
|
148
|
+
* at the regex layer before any I/O.
|
|
101
149
|
*/
|
|
102
150
|
export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
|
|
103
151
|
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
104
|
-
if (
|
|
152
|
+
if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
|
|
105
153
|
const dateBucket = getDateBucket(opts.now);
|
|
106
154
|
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
107
155
|
const relativePath = join(dateBucket, fileId + ext);
|
|
@@ -109,6 +157,32 @@ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoo
|
|
|
109
157
|
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
110
158
|
}
|
|
111
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Build the storage path for an EXISTING blob, using the caller-supplied
|
|
162
|
+
* `dateBucket` (typically read from the index entry). Does NOT compute
|
|
163
|
+
* a date from the runtime clock — that is the write-side concern.
|
|
164
|
+
*
|
|
165
|
+
* This split exists because sharing one date-computing function between
|
|
166
|
+
* write and read semantics caused #509: yesterday's file downloaded
|
|
167
|
+
* today would look under today's bucket → blob_missing → 404, even
|
|
168
|
+
* though the blob was still sitting in yesterday's directory.
|
|
169
|
+
*
|
|
170
|
+
* The file_id, ext and dateBucket are validated against strict regexes
|
|
171
|
+
* before any I/O; a poisoned index bucket (e.g. `../etc`) is rejected
|
|
172
|
+
* here just as file_id path-escape attempts are.
|
|
173
|
+
*/
|
|
174
|
+
export function pathForExistingBlob(dateBucket: string, fileId: string, ext: string, opts: { uploadsRoot?: string } = {}): GeneratedFile {
|
|
175
|
+
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
176
|
+
if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
|
|
177
|
+
if (typeof dateBucket !== "string" || !DATE_BUCKET_REGEX.test(dateBucket)) {
|
|
178
|
+
throw new Error(`date_bucket "${dateBucket}" is invalid`);
|
|
179
|
+
}
|
|
180
|
+
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
181
|
+
const relativePath = join(dateBucket, fileId + ext);
|
|
182
|
+
const absolutePath = join(root, relativePath);
|
|
183
|
+
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
184
|
+
}
|
|
185
|
+
|
|
112
186
|
export type UploadIndexEntry = {
|
|
113
187
|
file_id: string;
|
|
114
188
|
date_bucket: string; // YYYY-MM-DD
|
|
@@ -117,6 +191,15 @@ export type UploadIndexEntry = {
|
|
|
117
191
|
mime: string; // best-effort mime from the multipart Content-Type
|
|
118
192
|
size: number; // byte length of the binary
|
|
119
193
|
owner?: string; // upload token id / username, for traceability
|
|
194
|
+
// Written by the upload handler as `authCtx?.userId ?? null`, so `null`
|
|
195
|
+
// is a real on-disk value — the type must say so rather than pretend
|
|
196
|
+
// the field is `string | undefined`.
|
|
197
|
+
owner_id?: string | null;
|
|
198
|
+
// #503 — network the upload is attributed to. The key is OMITTED (not
|
|
199
|
+
// written as null) when there is no attribution, so "absent" and
|
|
200
|
+
// "unattributed" are the same on-disk shape and legacy entries written
|
|
201
|
+
// before #503 are indistinguishable from new unattributed ones.
|
|
202
|
+
network_id?: string;
|
|
120
203
|
uploaded_at: string; // ISO 8601
|
|
121
204
|
};
|
|
122
205
|
|
|
@@ -135,10 +218,16 @@ export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } =
|
|
|
135
218
|
export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
|
|
136
219
|
if (!entry || typeof entry !== "object") return false;
|
|
137
220
|
const e: any = entry;
|
|
221
|
+
// #503 — if the network_id key is present it must be a non-empty string.
|
|
222
|
+
// An absent key is valid (legacy entries + unattributed uploads); that
|
|
223
|
+
// keeps this in sync with the writer, which omits the key entirely
|
|
224
|
+
// rather than writing null. owner_id is deliberately NOT constrained
|
|
225
|
+
// here — existing entries legitimately carry owner_id=null.
|
|
226
|
+
if ("network_id" in e && !(typeof e.network_id === "string" && e.network_id.length > 0)) return false;
|
|
138
227
|
return (
|
|
139
228
|
typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
|
|
140
229
|
typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&
|
|
141
|
-
|
|
230
|
+
isValidExtToken(e.ext) &&
|
|
142
231
|
typeof e.size === "number" && e.size >= 0 && e.size <= MAX_UPLOAD_BYTES
|
|
143
232
|
);
|
|
144
233
|
}
|
|
@@ -241,6 +330,42 @@ export type TaskAttachment = {
|
|
|
241
330
|
size?: number;
|
|
242
331
|
};
|
|
243
332
|
|
|
333
|
+
/**
|
|
334
|
+
* #222 cross-host attachment safety — strip `path` from meta.attachments
|
|
335
|
+
* entries that ALSO carry a `file_id`. Reason: the hub's /api/upload
|
|
336
|
+
* returns `{file_id, path}` where `path` is a hub-machine-local
|
|
337
|
+
* absolute path. Senders sometimes echo `path` back into meta.attachments.
|
|
338
|
+
* When the receiving agent runs on a different host (Vincent 2026-06-30
|
|
339
|
+
* toodadev2 case), that path string is meaningless. agent-node's
|
|
340
|
+
* fetch-attachment.ts will fetch via `file_id` regardless, but leaving
|
|
341
|
+
* the stale `path` in the JSON misleads any tooling that reads it.
|
|
342
|
+
*
|
|
343
|
+
* CRITICAL — conditional, not unconditional (通信龙 PR #222 pre-review
|
|
344
|
+
* nit): feishu-bridge currently writes `/work/feishu-attachments/<conn>/
|
|
345
|
+
* <chat>/` directly and ships path-only attachments (NO file_id). If we
|
|
346
|
+
* strip path unconditionally those break too. Only strip when file_id
|
|
347
|
+
* is present (the "safe to drop" signal). Single-host feishu fallback
|
|
348
|
+
* preserved.
|
|
349
|
+
*
|
|
350
|
+
* Shared by tools.ts (MCP send_task) and index.ts (REST /api/task) so
|
|
351
|
+
* both transports apply the same sanitization to meta.
|
|
352
|
+
*/
|
|
353
|
+
export function stripHostLocalPathsForCrossHostSafe(meta: any): any {
|
|
354
|
+
if (!meta || typeof meta !== "object") return meta;
|
|
355
|
+
if (!Array.isArray((meta as any).attachments)) return meta;
|
|
356
|
+
let mutated = false;
|
|
357
|
+
const sanitized = (meta as any).attachments.map((a: any) => {
|
|
358
|
+
if (a && typeof a === "object" && typeof a.file_id === "string" && a.file_id && typeof a.path === "string") {
|
|
359
|
+
mutated = true;
|
|
360
|
+
const { path: _stripped, ...rest } = a;
|
|
361
|
+
return rest;
|
|
362
|
+
}
|
|
363
|
+
return a;
|
|
364
|
+
});
|
|
365
|
+
if (!mutated) return meta;
|
|
366
|
+
return { ...meta, attachments: sanitized };
|
|
367
|
+
}
|
|
368
|
+
|
|
244
369
|
/** Light validator usable from the REST handler. Returns null on success or an error message string. */
|
|
245
370
|
export function validateAttachments(input: unknown): { ok: true; attachments: TaskAttachment[] } | { ok: false; error: string } {
|
|
246
371
|
if (input === undefined || input === null) return { ok: true, attachments: [] };
|
|
@@ -0,0 +1,220 @@
|
|
|
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 = "";
|
|
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
|
+
|
|
60
|
+
const a = register(`ui_a_${stamp}`, "UiA-Strong-1!");
|
|
61
|
+
const b = register(`ui_b_${stamp}`, "UiB-Strong-1!");
|
|
62
|
+
expect(a.ok && b.ok).toBe(true);
|
|
63
|
+
aToken = a.token!; bToken = b.token!;
|
|
64
|
+
aNet = a.network_id!; bNet = b.network_id!;
|
|
65
|
+
aUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_a_${stamp}`)!.user_id;
|
|
66
|
+
bUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_b_${stamp}`)!.user_id;
|
|
67
|
+
|
|
68
|
+
// 前提断言:探针不是 admin。靠"注册顺序"是隐式前提,会随夹具变化悄悄失效,
|
|
69
|
+
// 所以显式断言。
|
|
70
|
+
// 承重的是 role ≠ "admin";这里钉死具体值(auth.ts:88 非首用户写 "user"),
|
|
71
|
+
// 因为 `not.toBe("admin")` 在 role 为 undefined(查不到行)时也会通过 —— 松判据
|
|
72
|
+
// 会朝"没问题"那边失败。
|
|
73
|
+
for (const [name, id] of [["A", aUserId], ["B", bUserId]] as const) {
|
|
74
|
+
const role = db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role;
|
|
75
|
+
expect([name, role]).toEqual([name, "user"]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 🔴 B 必须在**同一个 network** 里也有一条私信。
|
|
79
|
+
// 第一版夹具让 A/B 各处一网,于是 addNetworkScope 顺手挡住了 B 的行 ——
|
|
80
|
+
// 变异掉 `WHERE user_id = ?1` 后隔离断言照样通过(M1/M2/M4 全部存活)。
|
|
81
|
+
// 同网同事之间,user_id 过滤是**唯一**的隔离手段,夹具必须落在那一格。
|
|
82
|
+
expect(addNetworkMember(aNet, bUserId, "member").ok).toBe(true);
|
|
83
|
+
|
|
84
|
+
seedMessage("dm_a1", aUserId, aNet, "A 的第一条");
|
|
85
|
+
seedMessage("dm_a2", aUserId, aNet, "A 的第二条");
|
|
86
|
+
seedMessage("dm_b_same_net", bUserId, aNet, "B 在 A 同网里的私信");
|
|
87
|
+
seedMessage("dm_b1", bUserId, bNet, "B 的私信");
|
|
88
|
+
// A 自己的、但落在 A 不是成员的 network 上的行(例如 A 被移出该网后的遗留)。
|
|
89
|
+
// 它是 addNetworkScope 这次调用的唯一 witnessed-red 对象:user_id 过滤对它无效。
|
|
90
|
+
seedMessage("dm_a_foreign_net", aUserId, bNet, "A 在外网的遗留私信");
|
|
91
|
+
// network_id 为 NULL 的行。**写路径产不出这种行** —— send_desktop_message 有
|
|
92
|
+
// 三道闸(canWrite / `if (!effectiveNetId)` / targetRole),拆掉任意一道都还有
|
|
93
|
+
// 别的挡着,见 desktop-message-seam.test.ts 的两条孤儿断言。这里是手写夹具,
|
|
94
|
+
// 用来钉「万一库里有这种行(历史数据、将来的第二个写入方),谁读得到」。
|
|
95
|
+
seedMessage("dm_a_nonet", aUserId, null as any, "A 的无网络私信");
|
|
96
|
+
seedMessage("dm_admin_nonet", adminUserId, null as any, "admin 的无网络私信");
|
|
97
|
+
seedMessage("dm_a_read", aUserId, aNet, "A 已读的一条", undefined, 1);
|
|
98
|
+
seedMessage("dm_a_secret", aUserId, aNet,
|
|
99
|
+
"凭据在正文里 ntok_abcdef123456", JSON.stringify({ k: "ghp_" + "A".repeat(24) }));
|
|
100
|
+
|
|
101
|
+
const mod = await import("./server.js");
|
|
102
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
103
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(() => {
|
|
107
|
+
try { server?.stop(true); } catch {}
|
|
108
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("#1459 ① P3 GET /api/messages?scope=user", () => {
|
|
112
|
+
test("🔴 只返回调用者自己的消息 —— A 读不到 B 的", async () => {
|
|
113
|
+
const { status, body } = await get("/api/messages?scope=user", aToken);
|
|
114
|
+
expect(status).toBe(200);
|
|
115
|
+
expect(body.ok).toBe(true);
|
|
116
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
117
|
+
expect(ids).toContain("dm_a1");
|
|
118
|
+
expect(ids).toContain("dm_a_read"); // 不加 unacked 时已读也要回来
|
|
119
|
+
expect(ids).not.toContain("dm_b_same_net"); // 承重:同网,只有 user_id 过滤挡得住
|
|
120
|
+
expect(ids).not.toContain("dm_b1"); // 跨网,scope 助手挡
|
|
121
|
+
expect(ids).not.toContain("dm_a_foreign_net"); // 承重:A 自己的行,只有 scope 助手挡得住
|
|
122
|
+
expect(body.messages.every((m: any) => m.user_id === aUserId)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("🔴 收件人取自 auth ctx —— query 里指定别人的 user_id 无效", async () => {
|
|
126
|
+
const { body } = await get(`/api/messages?scope=user&user_id=${bUserId}`, aToken);
|
|
127
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
128
|
+
expect(ids).not.toContain("dm_b_same_net");
|
|
129
|
+
expect(ids).not.toContain("dm_b1");
|
|
130
|
+
expect(body.messages.every((m: any) => m.user_id === aUserId)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("🔴 redact-at-read:正文与 meta 里的凭据形状被遮住", async () => {
|
|
134
|
+
const { body } = await get("/api/messages?scope=user", aToken);
|
|
135
|
+
const row = body.messages.find((m: any) => m.message_id === "dm_a_secret");
|
|
136
|
+
expect(row).toBeTruthy();
|
|
137
|
+
expect(row.content).toContain("ntok_***redacted***");
|
|
138
|
+
expect(row.content).not.toContain("ntok_abcdef123456");
|
|
139
|
+
expect(row.meta_json).toContain("ghp_***redacted***");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// NULL 网络行的可见性**取决于调用者的 scope,不取决于行本身**:
|
|
143
|
+
// 受网络限制的成员 → addNetworkScope 生成 `network_id IN (...)`,NULL 不匹配 ⇒ 读不到
|
|
144
|
+
// admin(resolveRestNetworkScope 给 networkIds=null,助手是 no-op) ⇒ 读得到
|
|
145
|
+
// 两条一起才说明"NULL 读不到"是 scope 造成的,不是 user_id 过滤造成的。
|
|
146
|
+
test("NULL 网络行:受网络限制的成员读不到", async () => {
|
|
147
|
+
const { body } = await get("/api/messages?scope=user", aToken);
|
|
148
|
+
expect(body.messages.map((m: any) => m.message_id)).not.toContain("dm_a_nonet");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("NULL 网络行:scope 为 no-op 的调用者(admin)读得到自己的那条", async () => {
|
|
152
|
+
const { body } = await get("/api/messages?scope=user", adminToken);
|
|
153
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
154
|
+
expect(ids).toContain("dm_admin_nonet");
|
|
155
|
+
expect(ids).not.toContain("dm_a1"); // 仍然只读自己的:admin 不绕过 user_id 过滤
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("unread 是未读数而非总数(A:4 条里 3 条未读)", async () => {
|
|
159
|
+
const { body } = await get("/api/messages?scope=user", aToken);
|
|
160
|
+
expect(body.messages.length).toBe(4);
|
|
161
|
+
expect(body.unread).toBe(3);
|
|
162
|
+
expect(body.pending_count).toBe(body.unread);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("unacked=1 排掉已读那条", async () => {
|
|
166
|
+
const { body } = await get("/api/messages?scope=user&unacked=1", aToken);
|
|
167
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
168
|
+
expect(ids).not.toContain("dm_a_read"); // 承重:集合里确实有已读行才有分辨力
|
|
169
|
+
expect(ids.sort()).toEqual(["dm_a1", "dm_a2", "dm_a_secret"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("未鉴权 ⇒ 401(/api 全局闸)", async () => {
|
|
173
|
+
const r = await fetch(`${base}/api/messages?scope=user`);
|
|
174
|
+
expect(r.status).toBe(401);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("🔴 legacy master token 过得了全局闸但没有 user ⇒ 401,不能回一个空列表", async () => {
|
|
178
|
+
// 这条才是分支内 `if (!callerUserId)` 的 witnessed-red:把它换成
|
|
179
|
+
// `?? ""` 后本条从 401 变成 200 + 空列表 —— "看起来正常"的错误答案。
|
|
180
|
+
const { status, body } = await get("/api/messages?scope=user", MASTER_TOKEN);
|
|
181
|
+
expect([status, body.error]).toEqual([401, "auth_required"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("alias 分支未受影响(dashboard 依赖它)", async () => {
|
|
185
|
+
const { status, body } = await get("/api/messages", adminToken);
|
|
186
|
+
expect(status).toBe(200);
|
|
187
|
+
expect(body.ok).toBe(true);
|
|
188
|
+
expect(Array.isArray(body.messages)).toBe(true);
|
|
189
|
+
expect(body.unread).toBeUndefined(); // 老分支不该多出新字段
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("#1459 ① P3 POST /api/messages/ack", () => {
|
|
194
|
+
test("ack 自己的消息 ⇒ acked 置位、未读数下降", async () => {
|
|
195
|
+
const before = (await get("/api/messages?scope=user", aToken)).body.unread;
|
|
196
|
+
const { status, body } = await post("/api/messages/ack", aToken, { message_id: "dm_a1" });
|
|
197
|
+
expect(status).toBe(200);
|
|
198
|
+
expect(body.acked).toBe(1);
|
|
199
|
+
const after = (await get("/api/messages?scope=user", aToken)).body.unread;
|
|
200
|
+
expect(after).toBe(before - 1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("🔴 ack 不了同网同事的消息 —— 改动行数为 0,且对方仍是未读", async () => {
|
|
204
|
+
const { body } = await post("/api/messages/ack", aToken, { message_id: "dm_b_same_net" });
|
|
205
|
+
expect(body.acked).toBe(0);
|
|
206
|
+
const bView = (await get("/api/messages?scope=user", bToken)).body;
|
|
207
|
+
expect(bView.messages.find((m: any) => m.message_id === "dm_b_same_net").acked).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("ack 跨网的消息同样为 0", async () => {
|
|
211
|
+
const { body } = await post("/api/messages/ack", aToken, { message_id: "dm_b1" });
|
|
212
|
+
expect(body.acked).toBe(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("缺参 ⇒ 400;未鉴权 ⇒ 401", async () => {
|
|
216
|
+
expect((await post("/api/messages/ack", aToken, {})).status).toBe(400);
|
|
217
|
+
const r = await fetch(`${base}/api/messages/ack`, { method: "POST", body: "{}" });
|
|
218
|
+
expect(r.status).toBe(401);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
`INSERT INTO user_inbox (message_id, user_id, content) VALUES (?1, ?2, ?3)`,
|
|
41
|
+
[mid, "u_def_1459", "body"],
|
|
42
|
+
);
|
|
43
|
+
const row = db.get<{ acked: number; kind: string; severity: string }>(
|
|
44
|
+
"SELECT acked, kind, severity FROM user_inbox WHERE message_id = ?1", mid,
|
|
45
|
+
);
|
|
46
|
+
expect(row?.acked).toBe(0);
|
|
47
|
+
expect(row?.kind).toBe("info");
|
|
48
|
+
expect(row?.severity).toBe("info");
|
|
49
|
+
} finally {
|
|
50
|
+
db.run("DELETE FROM user_inbox WHERE message_id = ?1", mid);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -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
|
+
});
|