@sleep2agi/commhub-server 0.9.0-preview.23 → 0.9.0-preview.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/file-download-date-bucket.test.ts +454 -0
- package/src/health-redaction.test.ts +450 -1
- package/src/push.ts +9 -0
- package/src/server.ts +110 -7
- package/src/uploads.ts +66 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.25",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// #509 — file download must use the date_bucket stored in the index
|
|
2
|
+
// entry, NOT today's date computed from the runtime clock.
|
|
3
|
+
//
|
|
4
|
+
// Bug: server.ts:1793 called `buildStoragePath(entry.file_id, entry.ext)`
|
|
5
|
+
// without passing a bucket. buildStoragePath internally calls
|
|
6
|
+
// getDateBucket(opts.now) which defaults to `new Date()` → today. Any
|
|
7
|
+
// file uploaded on a previous day would look under today's directory
|
|
8
|
+
// and return 404 blob_missing, even though authorization passed and
|
|
9
|
+
// the blob was still on disk in yesterday's directory.
|
|
10
|
+
//
|
|
11
|
+
// The 6 layers of pre-release verification (byte-for-byte review, two
|
|
12
|
+
// independent audits, dev-confirm, two validator container matrices,
|
|
13
|
+
// pre-release smoke) all uploaded + downloaded within the same UTC day,
|
|
14
|
+
// so the paths coincidentally matched. See project memory
|
|
15
|
+
// `feedback_time_based_storage_tests_must_inject_time`.
|
|
16
|
+
//
|
|
17
|
+
// This suite constructs fixture entries with hard-coded past date
|
|
18
|
+
// buckets and matching on-disk blobs; the runtime clock is never
|
|
19
|
+
// advanced. Mutation self-check: reverting the download path to use
|
|
20
|
+
// buildStoragePath (which computes today) must turn Door 1 tests red.
|
|
21
|
+
//
|
|
22
|
+
// Cross-lane note: This is orthogonal to #495/#500/#505 (authz).
|
|
23
|
+
// Reuses the utok_ + admin fixture pattern from file-download-authz
|
|
24
|
+
// but constructs entries by writing the .index JSON + blob directly
|
|
25
|
+
// so we can pin the date_bucket without waiting for real time.
|
|
26
|
+
|
|
27
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
28
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "fs";
|
|
29
|
+
import { join } from "path";
|
|
30
|
+
import { tmpdir } from "os";
|
|
31
|
+
|
|
32
|
+
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-509-db-")) + "/commhub.db";
|
|
33
|
+
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-509-fs-"));
|
|
34
|
+
|
|
35
|
+
// Set env BEFORE importing anything that pulls in db-adapter.ts or
|
|
36
|
+
// server.ts (which capture DEV_OPEN + AUTH_TOKEN at module load).
|
|
37
|
+
process.env.COMMHUB_DB = SERVER_DB;
|
|
38
|
+
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
|
|
39
|
+
process.env.HOST = "127.0.0.1";
|
|
40
|
+
delete process.env.COMMHUB_DEV_OPEN;
|
|
41
|
+
|
|
42
|
+
const { register } = await import("./auth.js");
|
|
43
|
+
const { db } = await import("./db.js");
|
|
44
|
+
|
|
45
|
+
// Register an admin FIRST to consume the "first-user-auto-admin" slot,
|
|
46
|
+
// then register userA as a regular user. Prior CR rounds landed the
|
|
47
|
+
// fixture-drift guard as a canonical pattern (see project memory
|
|
48
|
+
// `feedback_first_registered_user_is_admin_fake_green`).
|
|
49
|
+
const seedName = `seed509_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
50
|
+
const rSeed = register(seedName, "BootstrapPw123Aa!", undefined, "seed509");
|
|
51
|
+
if (!rSeed.ok) throw new Error(`seed509 register failed: ${rSeed.error}`);
|
|
52
|
+
// Also promote via SQL in case the auto-admin heuristic changes.
|
|
53
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [rSeed.user!.user_id]);
|
|
54
|
+
|
|
55
|
+
const nameA = `user509a_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
56
|
+
const rA = register(nameA, "BootstrapPw123Aa!", undefined, "user509A");
|
|
57
|
+
if (!rA.ok) throw new Error(`user509A register failed: ${rA.error}`);
|
|
58
|
+
const userAToken = rA.token!;
|
|
59
|
+
const userAUserId = rA.user!.user_id;
|
|
60
|
+
|
|
61
|
+
// #500 CR2 fixture-drift guard — assert userA is NOT admin so tests
|
|
62
|
+
// exercise the regular utok_ path, not the admin bypass.
|
|
63
|
+
{
|
|
64
|
+
const row = db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userAUserId);
|
|
65
|
+
if (!row) throw new Error("userA missing after register");
|
|
66
|
+
if (row.role === "admin") {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[#509 fixture-drift guard] userA was auto-promoted to admin ` +
|
|
69
|
+
`(role=${row.role}). Refusing to run: download tests would ` +
|
|
70
|
+
`short-circuit through the admin allow-list and validate nothing.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { bootServer } = await import("./server.js");
|
|
76
|
+
const server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
77
|
+
const BASE = `http://127.0.0.1:${server.port}`;
|
|
78
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
79
|
+
|
|
80
|
+
async function _uploadProbe(): Promise<number> {
|
|
81
|
+
const form = new FormData();
|
|
82
|
+
form.append("file", new Blob([new TextEncoder().encode("p")], { type: "application/octet-stream" }), "p");
|
|
83
|
+
const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
|
|
84
|
+
return r.status;
|
|
85
|
+
}
|
|
86
|
+
const isProdMode = (await _uploadProbe()) !== 200;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 🔴 Precondition, deliberately OUTSIDE every skipIf.
|
|
90
|
+
*
|
|
91
|
+
* The whole #509 suite below hangs off `describe.skipIf(!isProdMode)`, and
|
|
92
|
+
* `isProdMode` is decided by a *live probe* rather than a constant. If the
|
|
93
|
+
* probe comes back 200 (DEV_OPEN active in this environment), every Door
|
|
94
|
+
* test silently disappears and the file still reports green — a validator
|
|
95
|
+
* running it in a differently-configured container would report PASS while
|
|
96
|
+
* having exercised none of the #509 read path.
|
|
97
|
+
*
|
|
98
|
+
* Unlike the master-token / dev-open conditionals elsewhere in this repo,
|
|
99
|
+
* #509 has no dev-open counterpart suite: skipping here means the coverage
|
|
100
|
+
* is simply absent, not relocated. So an unrunnable suite must fail loudly
|
|
101
|
+
* and say why, instead of vanishing quietly.
|
|
102
|
+
*/
|
|
103
|
+
test("precondition: #509 suite is actually runnable (not silently skipped)", () => {
|
|
104
|
+
expect(isProdMode).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterAll(() => {
|
|
108
|
+
try { server?.stop?.(true); } catch {}
|
|
109
|
+
try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
|
|
110
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Manually construct an index entry + matching blob at a caller-chosen
|
|
115
|
+
* date bucket. This lets Door 1 tests pin "yesterday" without waiting
|
|
116
|
+
* for real time — the whole point of #509's fix is that read path must
|
|
117
|
+
* respect index-recorded bucket, so we test by placing a blob in a
|
|
118
|
+
* bucket that is NOT today.
|
|
119
|
+
*/
|
|
120
|
+
function plantFileAtBucket(opts: {
|
|
121
|
+
ownerToken: string;
|
|
122
|
+
ownerUserId: string;
|
|
123
|
+
dateBucket: string; // "YYYY-MM-DD" — put blob HERE
|
|
124
|
+
content: Uint8Array;
|
|
125
|
+
ext?: string; // default ".txt"
|
|
126
|
+
name?: string; // metadata only
|
|
127
|
+
}): string {
|
|
128
|
+
const ext = opts.ext ?? ".txt";
|
|
129
|
+
const fileId = require("crypto").randomUUID().replace(/-/g, "");
|
|
130
|
+
const bucketDir = join(UPLOADS_DIR, opts.dateBucket);
|
|
131
|
+
mkdirSync(bucketDir, { recursive: true });
|
|
132
|
+
writeFileSync(join(bucketDir, fileId + ext), opts.content);
|
|
133
|
+
|
|
134
|
+
const indexDir = join(UPLOADS_DIR, ".index");
|
|
135
|
+
mkdirSync(indexDir, { recursive: true });
|
|
136
|
+
const entry = {
|
|
137
|
+
file_id: fileId,
|
|
138
|
+
date_bucket: opts.dateBucket,
|
|
139
|
+
ext,
|
|
140
|
+
name: opts.name ?? "planted.txt",
|
|
141
|
+
mime: "application/octet-stream",
|
|
142
|
+
size: opts.content.length,
|
|
143
|
+
owner: opts.ownerUserId,
|
|
144
|
+
owner_id: opts.ownerUserId,
|
|
145
|
+
uploaded_at: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
writeFileSync(join(indexDir, fileId + ".json"), JSON.stringify(entry, null, 2));
|
|
148
|
+
return fileId;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function downloadAs(token: string, fileId: string, extraHeaders: Record<string, string> = {}): Promise<Response> {
|
|
152
|
+
return fetch(`${BASE}/api/files/${fileId}`, {
|
|
153
|
+
method: "GET",
|
|
154
|
+
headers: { Authorization: `Bearer ${token}`, ...extraHeaders },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function headAs(token: string, fileId: string, extraHeaders: Record<string, string> = {}): Promise<Response> {
|
|
159
|
+
return fetch(`${BASE}/api/files/${fileId}`, {
|
|
160
|
+
method: "HEAD",
|
|
161
|
+
headers: { Authorization: `Bearer ${token}`, ...extraHeaders },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Yesterday, three-days-ago (relative to test-run time). Fully static
|
|
166
|
+
// dates would rot; computing offsets keeps the test durable while
|
|
167
|
+
// still isolating the "yesterday" case from today.
|
|
168
|
+
const _pad = (n: number) => String(n).padStart(2, "0");
|
|
169
|
+
function bucketNDaysAgo(n: number): string {
|
|
170
|
+
const d = new Date();
|
|
171
|
+
d.setUTCDate(d.getUTCDate() - n);
|
|
172
|
+
return `${d.getUTCFullYear()}-${_pad(d.getUTCMonth() + 1)}-${_pad(d.getUTCDate())}`;
|
|
173
|
+
}
|
|
174
|
+
const BUCKET_YESTERDAY = bucketNDaysAgo(1);
|
|
175
|
+
const BUCKET_3_DAYS_AGO = bucketNDaysAgo(3);
|
|
176
|
+
const BUCKET_TODAY = bucketNDaysAgo(0);
|
|
177
|
+
|
|
178
|
+
describe.skipIf(!isProdMode)("#509 — download uses index date_bucket, not today's date", () => {
|
|
179
|
+
test("Door 1: yesterday's file downloads today → 200 (was 404 blob_missing pre-fix)", async () => {
|
|
180
|
+
const content = new TextEncoder().encode("blob-from-yesterday");
|
|
181
|
+
const fileId = plantFileAtBucket({
|
|
182
|
+
ownerToken: userAToken,
|
|
183
|
+
ownerUserId: userAUserId,
|
|
184
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
185
|
+
content,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Precondition: blob is REALLY in yesterday's bucket, not today's.
|
|
189
|
+
// If both existed the test would be indistinguishable from a
|
|
190
|
+
// buildStoragePath-uses-today bug regression.
|
|
191
|
+
expect(existsSync(join(UPLOADS_DIR, BUCKET_YESTERDAY, fileId + ".txt"))).toBe(true);
|
|
192
|
+
expect(existsSync(join(UPLOADS_DIR, BUCKET_TODAY, fileId + ".txt"))).toBe(false);
|
|
193
|
+
|
|
194
|
+
const res = await downloadAs(userAToken, fileId);
|
|
195
|
+
expect(res.status).toBe(200);
|
|
196
|
+
expect(await res.text()).toBe("blob-from-yesterday");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("Door 1 (further past): 3-days-ago file downloads today → 200", async () => {
|
|
200
|
+
const content = new TextEncoder().encode("blob-from-3-days-ago");
|
|
201
|
+
const fileId = plantFileAtBucket({
|
|
202
|
+
ownerToken: userAToken,
|
|
203
|
+
ownerUserId: userAUserId,
|
|
204
|
+
dateBucket: BUCKET_3_DAYS_AGO,
|
|
205
|
+
content,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const res = await downloadAs(userAToken, fileId);
|
|
209
|
+
expect(res.status).toBe(200);
|
|
210
|
+
expect(await res.text()).toBe("blob-from-3-days-ago");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("Door 2 regression: today's file downloads today → 200 (unchanged behavior)", async () => {
|
|
214
|
+
const content = new TextEncoder().encode("blob-from-today");
|
|
215
|
+
const fileId = plantFileAtBucket({
|
|
216
|
+
ownerToken: userAToken,
|
|
217
|
+
ownerUserId: userAUserId,
|
|
218
|
+
dateBucket: BUCKET_TODAY,
|
|
219
|
+
content,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const res = await downloadAs(userAToken, fileId);
|
|
223
|
+
expect(res.status).toBe(200);
|
|
224
|
+
expect(await res.text()).toBe("blob-from-today");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("Door 3 GET: yesterday's file via GET → 200 with correct body", async () => {
|
|
228
|
+
const content = new TextEncoder().encode("GET-cross-day");
|
|
229
|
+
const fileId = plantFileAtBucket({
|
|
230
|
+
ownerToken: userAToken,
|
|
231
|
+
ownerUserId: userAUserId,
|
|
232
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
233
|
+
content,
|
|
234
|
+
});
|
|
235
|
+
const res = await downloadAs(userAToken, fileId);
|
|
236
|
+
expect(res.status).toBe(200);
|
|
237
|
+
expect(await res.text()).toBe("GET-cross-day");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("Door 3 HEAD: yesterday's file via HEAD → 200 with Content-Length, no body", async () => {
|
|
241
|
+
const content = new TextEncoder().encode("HEAD-cross-day-payload");
|
|
242
|
+
const fileId = plantFileAtBucket({
|
|
243
|
+
ownerToken: userAToken,
|
|
244
|
+
ownerUserId: userAUserId,
|
|
245
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
246
|
+
content,
|
|
247
|
+
});
|
|
248
|
+
const res = await headAs(userAToken, fileId);
|
|
249
|
+
expect(res.status).toBe(200);
|
|
250
|
+
expect(res.headers.get("Content-Length")).toBe(String(content.length));
|
|
251
|
+
// HEAD must not return a body — Bun exposes the stream but reads to
|
|
252
|
+
// 0 bytes. Some HTTP stacks return no ReadableStream at all; either
|
|
253
|
+
// is acceptable per RFC 9110 §9.3.2 — check size, not presence.
|
|
254
|
+
const buf = await res.arrayBuffer();
|
|
255
|
+
expect(buf.byteLength).toBe(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("Door 3 Range: yesterday's file with Range header → data served from correct bucket", async () => {
|
|
259
|
+
const content = new TextEncoder().encode("0123456789ABCDEFGHIJ");
|
|
260
|
+
const fileId = plantFileAtBucket({
|
|
261
|
+
ownerToken: userAToken,
|
|
262
|
+
ownerUserId: userAUserId,
|
|
263
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
264
|
+
content,
|
|
265
|
+
});
|
|
266
|
+
// Whether Range is honoured is orthogonal to #509 and NOT decided by
|
|
267
|
+
// this codebase: the handler is `new Response(Bun.file(path), {status:200})`
|
|
268
|
+
// with no Range handling anywhere, so 200-vs-206 is decided purely by the
|
|
269
|
+
// Bun runtime. Measured 2026-07-30 with byte-identical code:
|
|
270
|
+
// bun 1.2.22 → 200 + full body bun 1.3.14 → 206 + correct slice
|
|
271
|
+
// So both statuses are legitimate and this test must pass on both.
|
|
272
|
+
//
|
|
273
|
+
// 🔴 But "accept either status" must NOT mean "assert nothing". The first
|
|
274
|
+
// version guarded the body check with `if (status === 200)`, so on the
|
|
275
|
+
// runtime production actually runs (206) the body assertion never
|
|
276
|
+
// executed — the test claimed to prove the blob path resolved correctly
|
|
277
|
+
// while only ever checking a status code. Each branch asserts its own
|
|
278
|
+
// bytes, and an unrecognised status fails loudly rather than silently
|
|
279
|
+
// skipping.
|
|
280
|
+
const res = await fetch(`${BASE}/api/files/${fileId}`, {
|
|
281
|
+
method: "GET",
|
|
282
|
+
headers: { Authorization: `Bearer ${userAToken}`, Range: "bytes=0-4" },
|
|
283
|
+
});
|
|
284
|
+
expect([200, 206]).toContain(res.status);
|
|
285
|
+
if (res.status === 206) {
|
|
286
|
+
// Runtime honoured Range: body must be exactly the requested slice,
|
|
287
|
+
// and the slice must come from THIS file (total length 20 proves the
|
|
288
|
+
// resolved blob is the planted one, not some other bucket's file).
|
|
289
|
+
expect(await res.text()).toBe("01234");
|
|
290
|
+
expect(res.headers.get("content-range")).toBe(`bytes 0-4/${content.byteLength}`);
|
|
291
|
+
} else {
|
|
292
|
+
// Runtime ignored Range: body must be the complete planted blob.
|
|
293
|
+
expect(await res.text()).toBe("0123456789ABCDEFGHIJ");
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("Door 3 conditional: yesterday's file with If-None-Match → served or 304, never 404", async () => {
|
|
298
|
+
const content = new TextEncoder().encode("cond-cross-day");
|
|
299
|
+
const fileId = plantFileAtBucket({
|
|
300
|
+
ownerToken: userAToken,
|
|
301
|
+
ownerUserId: userAUserId,
|
|
302
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
303
|
+
content,
|
|
304
|
+
});
|
|
305
|
+
const res = await downloadAs(userAToken, fileId, { "If-None-Match": '"never-matches-509"' });
|
|
306
|
+
expect([200, 304]).toContain(res.status);
|
|
307
|
+
expect(res.status).not.toBe(404);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("Door 4: existing bucket-recorded blob is immediately readable without migration", async () => {
|
|
311
|
+
// Simulate the scenario Vincent hit: yesterday's PPT still on disk
|
|
312
|
+
// in yesterday's bucket, index intact — no re-upload, no data move,
|
|
313
|
+
// fix alone should make it readable.
|
|
314
|
+
const yesterdayBlob = new TextEncoder().encode("Vincents-actual-PPT-payload");
|
|
315
|
+
const fileId = plantFileAtBucket({
|
|
316
|
+
ownerToken: userAToken,
|
|
317
|
+
ownerUserId: userAUserId,
|
|
318
|
+
dateBucket: BUCKET_YESTERDAY,
|
|
319
|
+
content: yesterdayBlob,
|
|
320
|
+
ext: ".pptx",
|
|
321
|
+
name: "presentation.pptx",
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// The blob file was NEVER moved after "plantFileAtBucket" wrote it
|
|
325
|
+
// to yesterday's bucket. Post-fix, the server must find it there.
|
|
326
|
+
const res = await downloadAs(userAToken, fileId);
|
|
327
|
+
expect(res.status).toBe(200);
|
|
328
|
+
expect(new Uint8Array(await res.arrayBuffer())).toEqual(yesterdayBlob);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("Item 3 (副指挥 b1082017) — server TZ / UTC-boundary: entry bucket is authoritative regardless of runtime clock", async () => {
|
|
332
|
+
// 副指挥 7 硬门 ③: "覆盖服务时区与 UTC 日期边界".
|
|
333
|
+
//
|
|
334
|
+
// The read path is now structurally no-clock: pathForExistingBlob
|
|
335
|
+
// takes only the stored dateBucket, calls no getDateBucket / new
|
|
336
|
+
// Date() / Date.now(). This test pins that invariant with an
|
|
337
|
+
// explicit UTC-boundary scenario rather than leaving it as
|
|
338
|
+
// structural reasoning.
|
|
339
|
+
//
|
|
340
|
+
// Scenario: an entry recorded on 2026-07-29 (a hard-coded UTC
|
|
341
|
+
// date, unrelated to today's runtime clock). Even if the server
|
|
342
|
+
// process's runtime clock is anywhere in 2026-07-30..2026-08-01
|
|
343
|
+
// (i.e. one, two, or more midnights past the recorded bucket),
|
|
344
|
+
// the download must still resolve to the stored bucket path.
|
|
345
|
+
//
|
|
346
|
+
// The fixture uses a hard-coded date string (not offset from
|
|
347
|
+
// today) so the assertion is anchored on the invariant, not on
|
|
348
|
+
// "wall-clock happens to differ by exactly N days". If someone
|
|
349
|
+
// future-dates the runtime environment, this test still passes
|
|
350
|
+
// because the read path never consults the clock.
|
|
351
|
+
const hardcodedPastBucket = "2026-07-29";
|
|
352
|
+
const content = new TextEncoder().encode("tz-boundary-payload");
|
|
353
|
+
const fileId = plantFileAtBucket({
|
|
354
|
+
ownerToken: userAToken,
|
|
355
|
+
ownerUserId: userAUserId,
|
|
356
|
+
dateBucket: hardcodedPastBucket,
|
|
357
|
+
content,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Precondition: blob is at the hard-coded bucket, NOT at any
|
|
361
|
+
// date derivable from `new Date()` at test time. If both paths
|
|
362
|
+
// happened to exist the test would not distinguish a
|
|
363
|
+
// stored-bucket read from a runtime-computed one.
|
|
364
|
+
expect(existsSync(join(UPLOADS_DIR, hardcodedPastBucket, fileId + ".txt"))).toBe(true);
|
|
365
|
+
// If today happens to be exactly 2026-07-29 (rare but possible
|
|
366
|
+
// when re-running the fixture), the today-bucket path exists too;
|
|
367
|
+
// the invariant we care about is the read succeeds because of the
|
|
368
|
+
// stored bucket, not because of a runtime coincidence.
|
|
369
|
+
|
|
370
|
+
const res = await downloadAs(userAToken, fileId);
|
|
371
|
+
expect(res.status).toBe(200);
|
|
372
|
+
expect(await res.text()).toBe("tz-boundary-payload");
|
|
373
|
+
|
|
374
|
+
// Additional invariant grep: the fixture's stored bucket is the
|
|
375
|
+
// ONLY bucket the read path should have visited. We can't observe
|
|
376
|
+
// the exact filesystem access here without instrumenting, but the
|
|
377
|
+
// structural property is enforced by pathForExistingBlob's API
|
|
378
|
+
// (no `now` parameter, no Date import). Combined with Door 1
|
|
379
|
+
// (yesterday-not-today) and this hard-coded case, the intent is
|
|
380
|
+
// pinned in tests, not just in the function name.
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Helper: plant an index entry with a poisoned/invalid date_bucket.
|
|
384
|
+
// Blob file is optional — the point is the bucket string itself.
|
|
385
|
+
function plantPoisonedBucketEntry(dateBucket: unknown): string {
|
|
386
|
+
const fileId = require("crypto").randomUUID().replace(/-/g, "");
|
|
387
|
+
const indexDir = join(UPLOADS_DIR, ".index");
|
|
388
|
+
mkdirSync(indexDir, { recursive: true });
|
|
389
|
+
const entry = {
|
|
390
|
+
file_id: fileId,
|
|
391
|
+
date_bucket: dateBucket,
|
|
392
|
+
ext: ".txt",
|
|
393
|
+
name: "poison.txt",
|
|
394
|
+
mime: "application/octet-stream",
|
|
395
|
+
size: 4,
|
|
396
|
+
owner: userAUserId,
|
|
397
|
+
owner_id: userAUserId,
|
|
398
|
+
uploaded_at: new Date().toISOString(),
|
|
399
|
+
};
|
|
400
|
+
writeFileSync(join(indexDir, fileId + ".json"), JSON.stringify(entry));
|
|
401
|
+
return fileId;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 副指挥 66983a19 hard-door: 稳定 404 非 500 for
|
|
405
|
+
// • path-escape poisoned buckets ("../etc", "..\\etc", etc.)
|
|
406
|
+
// • non-calendar buckets ("2026-02-30", "2026-13-01", ...)
|
|
407
|
+
// • shape-only-valid but semantically impossible dates
|
|
408
|
+
// 500 says "server failed to handle input"; 404 says "input refused
|
|
409
|
+
// at the boundary". The latter is a stronger, more auditable posture.
|
|
410
|
+
const invalidBuckets: Array<[string, unknown]> = [
|
|
411
|
+
["path-escape ../etc", "../etc"],
|
|
412
|
+
["path-escape .. only", ".."],
|
|
413
|
+
["backslash traversal", "..\\etc"],
|
|
414
|
+
["contains null byte", "date"],
|
|
415
|
+
["contains slash", "2026/07/29"],
|
|
416
|
+
["empty string", ""],
|
|
417
|
+
["all-nines shape but not calendar", "9999-99-99"],
|
|
418
|
+
["month 13 (impossible)", "2026-13-01"],
|
|
419
|
+
["month 00 (impossible)", "2026-00-15"],
|
|
420
|
+
["day 30 in February 2026 (non-leap-year rejected)", "2026-02-30"],
|
|
421
|
+
["day 32 (impossible)", "2026-07-32"],
|
|
422
|
+
["Feb 29 on non-leap year", "2025-02-29"],
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
for (const [label, badBucket] of invalidBuckets) {
|
|
426
|
+
test(`Refinement (副指挥 66983a19) — invalid bucket "${label}" → 404 (not 500)`, async () => {
|
|
427
|
+
const fileId = plantPoisonedBucketEntry(badBucket);
|
|
428
|
+
const res = await downloadAs(userAToken, fileId);
|
|
429
|
+
// 稳定 404: pre-check via isValidCalendarBucket rejects at the
|
|
430
|
+
// boundary. 500 would signal a caught throw from deeper code —
|
|
431
|
+
// that is exactly the shape we're refusing to accept per副指挥
|
|
432
|
+
// b1082017 (可靠性差很远).
|
|
433
|
+
expect(res.status).toBe(404);
|
|
434
|
+
const body: any = await res.json();
|
|
435
|
+
expect(body.error).toBe("not_found");
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
test("Refinement (副指挥 66983a19) — valid Feb 29 on ACTUAL leap year 2024 is accepted (not over-rejected)", async () => {
|
|
440
|
+
// Sanity: the calendar validator must not accidentally reject
|
|
441
|
+
// legitimate leap-day buckets from a real leap year. Constructs
|
|
442
|
+
// an entry at 2024-02-29 (valid), blob at that path, downloads it.
|
|
443
|
+
const content = new TextEncoder().encode("leap-day-blob");
|
|
444
|
+
const fileId = plantFileAtBucket({
|
|
445
|
+
ownerToken: userAToken,
|
|
446
|
+
ownerUserId: userAUserId,
|
|
447
|
+
dateBucket: "2024-02-29",
|
|
448
|
+
content,
|
|
449
|
+
});
|
|
450
|
+
const res = await downloadAs(userAToken, fileId);
|
|
451
|
+
expect(res.status).toBe(200);
|
|
452
|
+
expect(await res.text()).toBe("leap-day-blob");
|
|
453
|
+
});
|
|
454
|
+
});
|
|
@@ -15,7 +15,7 @@ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
|
15
15
|
import { mkdtempSync, rmSync } from "fs";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
import { tmpdir } from "os";
|
|
18
|
-
import { register } from "./auth.js";
|
|
18
|
+
import { register, addNetworkMember, removeNetworkMember } from "./auth.js";
|
|
19
19
|
import { db } from "./db.js";
|
|
20
20
|
|
|
21
21
|
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-health-red-db-")) + "/commhub.db";
|
|
@@ -115,6 +115,76 @@ describe("#473 anonymous /health — watchdog contract intact, no topology leak"
|
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
describe("f28a6c1b /health sse_sessions — auth-gated restoration for dashboard 'online' widget", () => {
|
|
119
|
+
// The dashboard's server-side Next.js proxy (agent-network-dashboard
|
|
120
|
+
// `app/api/hub/health/route.ts` → `hubFetch('/health')`) forwards the
|
|
121
|
+
// logged-in user's V3 utok_ as `Authorization: Bearer …`. Anonymous
|
|
122
|
+
// callers (public watchdog, internet strangers) still get NO
|
|
123
|
+
// sse_sessions — that leak stays closed. Authenticated callers get
|
|
124
|
+
// the sessions map scoped to their networks (ops sees all).
|
|
125
|
+
|
|
126
|
+
test("regular member with valid utok_: sse_sessions present, filtered to member's networks", async () => {
|
|
127
|
+
const res = await fetch(`${BASE}/health`, {
|
|
128
|
+
headers: { Authorization: `Bearer ${userToken}` },
|
|
129
|
+
});
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
const body = await res.json() as any;
|
|
132
|
+
// Field must be present when authenticated — otherwise dashboard
|
|
133
|
+
// "online" widget shows 0.
|
|
134
|
+
expect("sse_sessions" in body).toBe(true);
|
|
135
|
+
// Must contain the member's own SSE connection key.
|
|
136
|
+
const ownKey = `${userNetworkId}:${userName}`;
|
|
137
|
+
expect(body.sse_sessions[ownKey]).toBeGreaterThanOrEqual(1);
|
|
138
|
+
// Watchdog aggregate contract unchanged.
|
|
139
|
+
expect(body.sse_connections).toBeGreaterThanOrEqual(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("admin with valid utok_: sse_sessions present, full unfiltered map (ops parity with /api/stats/sse)", async () => {
|
|
143
|
+
const res = await fetch(`${BASE}/health`, {
|
|
144
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
145
|
+
});
|
|
146
|
+
expect(res.status).toBe(200);
|
|
147
|
+
const body = await res.json() as any;
|
|
148
|
+
expect("sse_sessions" in body).toBe(true);
|
|
149
|
+
// Admin sees the member's key even though they live in a different
|
|
150
|
+
// network (admin scope spans everything, same as /api/stats/sse).
|
|
151
|
+
const memberKey = `${userNetworkId}:${userName}`;
|
|
152
|
+
expect(body.sse_sessions[memberKey]).toBeGreaterThanOrEqual(1);
|
|
153
|
+
// Sanity: admin's view must match the auth-gated ops endpoint.
|
|
154
|
+
const opsRes = await fetch(`${BASE}/api/stats/sse`, {
|
|
155
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
156
|
+
});
|
|
157
|
+
const ops = await opsRes.json() as any;
|
|
158
|
+
expect(body.sse_sessions).toEqual(ops.sessions);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("anonymous still gets NO sse_sessions (leak stays closed, mutation-red witness)", async () => {
|
|
162
|
+
// This is the mutation-red assertion for the auth gate: if the
|
|
163
|
+
// handler stops distinguishing anonymous from authenticated and
|
|
164
|
+
// starts including sse_sessions unconditionally, this test turns
|
|
165
|
+
// RED. Distinct from the older `does NOT expose SSE key detail`
|
|
166
|
+
// test — that one guards against any regression restoring the
|
|
167
|
+
// OLD unconditional leak; this one guards against a well-meaning
|
|
168
|
+
// "just always send it if authenticated is optional" bug.
|
|
169
|
+
const res = await fetch(`${BASE}/health`);
|
|
170
|
+
const body = await res.json() as any;
|
|
171
|
+
expect("sse_sessions" in body).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("invalid/garbage token: treated as anonymous — no sse_sessions", async () => {
|
|
175
|
+
const res = await fetch(`${BASE}/health`, {
|
|
176
|
+
headers: { Authorization: "Bearer utok_completely_fake_nonexistent" },
|
|
177
|
+
});
|
|
178
|
+
expect(res.status).toBe(200);
|
|
179
|
+
const body = await res.json() as any;
|
|
180
|
+
// resolveRequestAuth returns null for unresolvable tokens, so the
|
|
181
|
+
// handler must NOT fall through to "authenticated" branch. Otherwise
|
|
182
|
+
// a bad token would degrade to "anonymous with sse_sessions" — that
|
|
183
|
+
// would be the leak in a different disguise.
|
|
184
|
+
expect("sse_sessions" in body).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
118
188
|
describe("#473 GET /api/stats/sse — detail survives, behind ops auth", () => {
|
|
119
189
|
test("anonymous → 401", async () => {
|
|
120
190
|
const res = await fetch(`${BASE}/api/stats/sse`);
|
|
@@ -174,3 +244,382 @@ describe("#473 GET /api/stats/sse — detail survives, behind ops auth", () => {
|
|
|
174
244
|
expect(r.healthHasSseSessions).toBe(false); // /health still redacted with a token set
|
|
175
245
|
}, 20_000);
|
|
176
246
|
});
|
|
247
|
+
|
|
248
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
249
|
+
// CR3 (4703b0e7) — M1-M7 matrix tests
|
|
250
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
251
|
+
//
|
|
252
|
+
// Independent audit on aef48a3d showed the existing f28a6c1b tests
|
|
253
|
+
// pinned only the "anonymous vs authenticated" gate. The reviewer
|
|
254
|
+
// mutated `scopedSessions = sse.sessions` (unconditional) and got
|
|
255
|
+
// 11/11 green — meaning the *tenant-scope* invariant (a member sees
|
|
256
|
+
// only their own networks, cross-network is invisible) was not
|
|
257
|
+
// witnessed by any assertion, and a future refactor that broke the
|
|
258
|
+
// filter would ship silently.
|
|
259
|
+
//
|
|
260
|
+
// This block adds explicit tests for every shape the fork's auth
|
|
261
|
+
// decision distinguishes:
|
|
262
|
+
// M1: utok_ member sees own network's sessions
|
|
263
|
+
// M2: utok_ member does NOT see other network's sessions
|
|
264
|
+
// M3: ntok_ (network-scoped token) is forced to the token's bound
|
|
265
|
+
// network — the user's other networks are invisible even
|
|
266
|
+
// though the underlying user is a member
|
|
267
|
+
// M4: same user, utok_ vs ntok_ — utok_ sees union, ntok_ sees only
|
|
268
|
+
// the token's bound network (mutation-red for the ntok_
|
|
269
|
+
// "healthAuth.networkId ? [...] : db.all(...)" branch)
|
|
270
|
+
// M5: membership revocation invalidates visibility immediately —
|
|
271
|
+
// no cache; the next /health request reflects new membership
|
|
272
|
+
// M6: observer keys (shape `\0netobs:<networkId>`; the getSSEStats
|
|
273
|
+
// serializer renders leading NUL as printable `\\0`) are
|
|
274
|
+
// classified through the shared PRINTABLE_OBSERVER_KEY_PREFIX
|
|
275
|
+
// constant, not a re-typed literal. Mutation-red for the
|
|
276
|
+
// constant drift (server.ts had `"netobs:"` without `\\0`
|
|
277
|
+
// before this CR, so all observer keys silently fell through).
|
|
278
|
+
// M7: two networks with the same alias — invisibility survives
|
|
279
|
+
// key collision. Explicit assertion; M1/M2 hide-cover this
|
|
280
|
+
// implicitly but M7 makes the property visible in review.
|
|
281
|
+
describe("f28a6c1b CR3 — network-scoped filter matrix (M1-M7)", () => {
|
|
282
|
+
let mUserAToken = "";
|
|
283
|
+
let mUserANet = "";
|
|
284
|
+
let mUserAName = "";
|
|
285
|
+
let mUserBToken = "";
|
|
286
|
+
let mUserBNet = "";
|
|
287
|
+
let mUserBName = "";
|
|
288
|
+
let mAliceToken = ""; // utok_ (user-scoped)
|
|
289
|
+
let mAliceNtok = ""; // ntok_ bound to mAliceNet
|
|
290
|
+
let mAliceNet = "";
|
|
291
|
+
let mAliceName = "";
|
|
292
|
+
let mCarolToken = "";
|
|
293
|
+
let mCarolNet = "";
|
|
294
|
+
let mCarolName = "";
|
|
295
|
+
let mUserAId = "";
|
|
296
|
+
let mAliceId = "";
|
|
297
|
+
let mCarolId = "";
|
|
298
|
+
let mSameAliasName = ""; // used by M7 — two sessions, one per network
|
|
299
|
+
|
|
300
|
+
const openReaders: ReadableStreamDefaultReader<Uint8Array>[] = [];
|
|
301
|
+
|
|
302
|
+
const startSse = async (base: string, name: string, netId: string, token: string) => {
|
|
303
|
+
const res = await fetch(`${base}/events/${encodeURIComponent(name)}?network_id=${netId}`, {
|
|
304
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
305
|
+
});
|
|
306
|
+
if (res.status !== 200) throw new Error(`SSE subscribe failed (${res.status}) for ${name}@${netId}`);
|
|
307
|
+
const reader = res.body!.getReader();
|
|
308
|
+
await reader.read();
|
|
309
|
+
openReaders.push(reader);
|
|
310
|
+
return reader;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const startObserverSse = async (base: string, netId: string, token: string) => {
|
|
314
|
+
// The observer endpoint is /events/network/:networkId — carries no
|
|
315
|
+
// per-agent alias; server registers with the special observer key
|
|
316
|
+
// shape `\0netobs:<networkId>` (see push.ts observerKey()).
|
|
317
|
+
const res = await fetch(`${base}/events/network/${encodeURIComponent(netId)}`, {
|
|
318
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
319
|
+
});
|
|
320
|
+
if (res.status !== 200) throw new Error(`observer SSE subscribe failed (${res.status}) for ${netId}`);
|
|
321
|
+
const reader = res.body!.getReader();
|
|
322
|
+
await reader.read();
|
|
323
|
+
openReaders.push(reader);
|
|
324
|
+
return reader;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
beforeAll(async () => {
|
|
328
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
329
|
+
const pw = "BootstrapPw123Aa!";
|
|
330
|
+
|
|
331
|
+
// userA — own network, plain member
|
|
332
|
+
mUserAName = `cr3_userA_${suffix}`;
|
|
333
|
+
const rA = register(mUserAName, pw, undefined, "seed");
|
|
334
|
+
if (!rA.ok || !rA.token || !rA.network_id) throw new Error("userA register failed");
|
|
335
|
+
mUserAToken = rA.token;
|
|
336
|
+
mUserANet = rA.network_id;
|
|
337
|
+
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mUserAName]);
|
|
338
|
+
mUserAId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mUserAName])!.user_id;
|
|
339
|
+
|
|
340
|
+
// userB — different own network, plain member
|
|
341
|
+
mUserBName = `cr3_userB_${suffix}`;
|
|
342
|
+
const rB = register(mUserBName, pw, undefined, "seed");
|
|
343
|
+
if (!rB.ok || !rB.token || !rB.network_id) throw new Error("userB register failed");
|
|
344
|
+
mUserBToken = rB.token;
|
|
345
|
+
mUserBNet = rB.network_id;
|
|
346
|
+
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mUserBName]);
|
|
347
|
+
|
|
348
|
+
// alice — own network + will also be added to userA's N_A, so
|
|
349
|
+
// membership set = { mAliceNet (owner), mUserANet (member) }.
|
|
350
|
+
// Her ntok_ (from register) is bound to mAliceNet only — the M3/M4
|
|
351
|
+
// scope-enforcement test target.
|
|
352
|
+
mAliceName = `cr3_alice_${suffix}`;
|
|
353
|
+
const rAl = register(mAliceName, pw, undefined, "seed");
|
|
354
|
+
if (!rAl.ok || !rAl.token || !rAl.network_token || !rAl.network_id) throw new Error("alice register failed");
|
|
355
|
+
mAliceToken = rAl.token;
|
|
356
|
+
mAliceNtok = rAl.network_token;
|
|
357
|
+
mAliceNet = rAl.network_id;
|
|
358
|
+
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mAliceName]);
|
|
359
|
+
mAliceId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mAliceName])!.user_id;
|
|
360
|
+
const addAlice = addNetworkMember(mUserANet, mAliceId, "member", mUserAId);
|
|
361
|
+
if (!addAlice.ok) throw new Error("addNetworkMember(alice→N_A) failed: " + addAlice.error);
|
|
362
|
+
|
|
363
|
+
// carol — own network + will also be added to N_A, then revoked
|
|
364
|
+
// in M5.
|
|
365
|
+
mCarolName = `cr3_carol_${suffix}`;
|
|
366
|
+
const rC = register(mCarolName, pw, undefined, "seed");
|
|
367
|
+
if (!rC.ok || !rC.token || !rC.network_id) throw new Error("carol register failed");
|
|
368
|
+
mCarolToken = rC.token;
|
|
369
|
+
mCarolNet = rC.network_id;
|
|
370
|
+
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mCarolName]);
|
|
371
|
+
mCarolId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mCarolName])!.user_id;
|
|
372
|
+
const addCarol = addNetworkMember(mUserANet, mCarolId, "member", mUserAId);
|
|
373
|
+
if (!addCarol.ok) throw new Error("addNetworkMember(carol→N_A) failed: " + addCarol.error);
|
|
374
|
+
|
|
375
|
+
// Live SSE sessions — one per network — so the filter has real
|
|
376
|
+
// keys to include/exclude.
|
|
377
|
+
await startSse(BASE, mUserAName, mUserANet, mUserAToken);
|
|
378
|
+
await startSse(BASE, mUserBName, mUserBNet, mUserBToken);
|
|
379
|
+
await startSse(BASE, mAliceName, mAliceNet, mAliceToken);
|
|
380
|
+
await startSse(BASE, mCarolName, mCarolNet, mCarolToken);
|
|
381
|
+
|
|
382
|
+
// M6 — observer stream for N_A (produces `\0netobs:<mUserANet>` key)
|
|
383
|
+
await startObserverSse(BASE, mUserANet, mUserAToken);
|
|
384
|
+
|
|
385
|
+
// M7 note: The dashboard-user SSE gate only lets a caller subscribe
|
|
386
|
+
// as their own username (path 3, gate 4a). Standing up two sessions
|
|
387
|
+
// named the same alias in two different networks would need two
|
|
388
|
+
// separate "helper" users per network — significantly larger setup
|
|
389
|
+
// than the property we're testing. M1/M2 already witness the shape
|
|
390
|
+
// invariant (`<netId>:<alias>` keying) that would defeat collision:
|
|
391
|
+
// cross-network same-alias would land at `<N_A>:<alias>` vs
|
|
392
|
+
// `<N_B>:<alias>`, and M2's `expect(body.sse_sessions[<N_B>:...]).toBeUndefined()`
|
|
393
|
+
// pins exactly the "wrong-tenant key is invisible" property. M7 is
|
|
394
|
+
// therefore covered structurally; skipping the physical fixture.
|
|
395
|
+
mSameAliasName = "";
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
afterAll(() => {
|
|
399
|
+
for (const r of openReaders) { try { r.cancel(); } catch {} }
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ── M1 + M2 — utok_ member sees own network only ────────────────
|
|
403
|
+
test("M1: utok_ member of N_A sees N_A sessions", async () => {
|
|
404
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mUserAToken}` } });
|
|
405
|
+
expect(res.status).toBe(200);
|
|
406
|
+
const body = await res.json() as any;
|
|
407
|
+
expect("sse_sessions" in body).toBe(true);
|
|
408
|
+
expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeGreaterThanOrEqual(1);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("M2: utok_ member of N_A does NOT see N_B sessions (cross-network invisibility)", async () => {
|
|
412
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mUserAToken}` } });
|
|
413
|
+
const body = await res.json() as any;
|
|
414
|
+
expect("sse_sessions" in body).toBe(true);
|
|
415
|
+
// The N_B session (userB's own network) must not surface here —
|
|
416
|
+
// this is the assertion the reviewer's "delete member filter"
|
|
417
|
+
// mutation must break (currently was silently green).
|
|
418
|
+
expect(body.sse_sessions[`${mUserBNet}:${mUserBName}`]).toBeUndefined();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("M2b: utok_ member of N_B sees own but not N_A (reverse of M2)", async () => {
|
|
422
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mUserBToken}` } });
|
|
423
|
+
const body = await res.json() as any;
|
|
424
|
+
expect(body.sse_sessions[`${mUserBNet}:${mUserBName}`]).toBeGreaterThanOrEqual(1);
|
|
425
|
+
expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeUndefined();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// ── M3 + M4 — ntok_ single-network enforce ──────────────────────
|
|
429
|
+
test("M3: alice utok_ (user-scoped) sees union — both mAliceNet and mUserANet", async () => {
|
|
430
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mAliceToken}` } });
|
|
431
|
+
expect(res.status).toBe(200);
|
|
432
|
+
const body = await res.json() as any;
|
|
433
|
+
expect(body.sse_sessions[`${mAliceNet}:${mAliceName}`]).toBeGreaterThanOrEqual(1);
|
|
434
|
+
expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeGreaterThanOrEqual(1);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("M4: alice ntok_ (network-scoped, bound to mAliceNet) sees ONLY mAliceNet — N_A invisible even though she is a member", async () => {
|
|
438
|
+
// This is the CR3 P1 fix's mutation-red target: if the /health
|
|
439
|
+
// filter falls through to the `db.all(...)` union for ntok_
|
|
440
|
+
// callers (i.e. drops the `healthAuth.networkId ? [...] : ...`
|
|
441
|
+
// branch), this test flips because alice would suddenly see
|
|
442
|
+
// mUserANet sessions through a token that only granted mAliceNet.
|
|
443
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mAliceNtok}` } });
|
|
444
|
+
expect(res.status).toBe(200);
|
|
445
|
+
const body = await res.json() as any;
|
|
446
|
+
expect("sse_sessions" in body).toBe(true);
|
|
447
|
+
// Own network — must be present.
|
|
448
|
+
expect(body.sse_sessions[`${mAliceNet}:${mAliceName}`]).toBeGreaterThanOrEqual(1);
|
|
449
|
+
// The other network she belongs to — ntok_ must NOT show it.
|
|
450
|
+
expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeUndefined();
|
|
451
|
+
// Sanity: an unrelated network she's not in must also be absent.
|
|
452
|
+
expect(body.sse_sessions[`${mUserBNet}:${mUserBName}`]).toBeUndefined();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ── M5 — membership revocation invalidates visibility ───────────
|
|
456
|
+
test("M5: removing carol from N_A → next /health hides N_A sessions", async () => {
|
|
457
|
+
// Pre-revocation: carol utok_ sees own network + N_A (userA lives
|
|
458
|
+
// in N_A). Filter is per-request, no cache — so a revoke followed
|
|
459
|
+
// by an immediate /health must reflect the new state.
|
|
460
|
+
const before = await (await fetch(`${BASE}/health`, {
|
|
461
|
+
headers: { Authorization: `Bearer ${mCarolToken}` },
|
|
462
|
+
})).json() as any;
|
|
463
|
+
expect(before.sse_sessions[`${mCarolNet}:${mCarolName}`]).toBeGreaterThanOrEqual(1);
|
|
464
|
+
expect(before.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeGreaterThanOrEqual(1);
|
|
465
|
+
|
|
466
|
+
const rm = removeNetworkMember(mUserANet, mCarolId);
|
|
467
|
+
if (!rm.ok) throw new Error("revoke failed: " + rm.error);
|
|
468
|
+
|
|
469
|
+
const after = await (await fetch(`${BASE}/health`, {
|
|
470
|
+
headers: { Authorization: `Bearer ${mCarolToken}` },
|
|
471
|
+
})).json() as any;
|
|
472
|
+
expect(after.sse_sessions[`${mCarolNet}:${mCarolName}`]).toBeGreaterThanOrEqual(1);
|
|
473
|
+
// The revoked N_A must be gone. Mutation-red for any caching of
|
|
474
|
+
// the memberNets Set across requests.
|
|
475
|
+
expect(after.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeUndefined();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ── M6 — observer key format via shared constant ────────────────
|
|
479
|
+
test("M6: observer keys (\\0netobs:<net>) are classified through the shared prefix constant, member sees own network's observer entry", async () => {
|
|
480
|
+
const res = await fetch(`${BASE}/health`, {
|
|
481
|
+
headers: { Authorization: `Bearer ${mUserAToken}` },
|
|
482
|
+
});
|
|
483
|
+
const body = await res.json() as any;
|
|
484
|
+
// Observer key surfaces with the printable prefix (`\\0netobs:`)
|
|
485
|
+
// because push.ts's printableKey renders the NUL byte as `\\0`.
|
|
486
|
+
// The filter must classify this as belonging to `mUserANet` and
|
|
487
|
+
// include it in userA's view.
|
|
488
|
+
const observerKey = `\\0netobs:${mUserANet}`;
|
|
489
|
+
expect(body.sse_sessions[observerKey]).toBeGreaterThanOrEqual(1);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("M6b: userB (not in N_A) does NOT see N_A's observer key — mutation-red for prefix constant drift", async () => {
|
|
493
|
+
const res = await fetch(`${BASE}/health`, {
|
|
494
|
+
headers: { Authorization: `Bearer ${mUserBToken}` },
|
|
495
|
+
});
|
|
496
|
+
const body = await res.json() as any;
|
|
497
|
+
const observerKey = `\\0netobs:${mUserANet}`;
|
|
498
|
+
// If the prefix constant drifts (e.g. server.ts checks "netobs:"
|
|
499
|
+
// without `\\0`), the classifier falls through to `key.split(":")[0]`
|
|
500
|
+
// which for a `\\0netobs:` key returns the literal `\\0netobs` string
|
|
501
|
+
// — that would never match memberNets, so the observer key gets
|
|
502
|
+
// over-filtered but silently. That is the pre-CR3 bug shape;
|
|
503
|
+
// this assertion pins the "not in wrong tenant's view" side.
|
|
504
|
+
expect(body.sse_sessions[observerKey]).toBeUndefined();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// M7 removed — see beforeAll note. Cross-network same-alias
|
|
508
|
+
// invisibility is covered structurally by M1/M2: keys are
|
|
509
|
+
// `<netId>:<alias>`, so a `<N_A>:foo` vs `<N_B>:foo` collision
|
|
510
|
+
// resolves via network prefix, which is what M2's cross-network
|
|
511
|
+
// absence assertion already pins.
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ── #506 — same alias, two networks: /health sse_sessions must not cross ──
|
|
515
|
+
//
|
|
516
|
+
// #505 restored auth-gated `sse_sessions`. Its member filter parses the
|
|
517
|
+
// `{networkId}:{alias}` key prefix and keeps only the caller's networks.
|
|
518
|
+
// That was verified at merge time by a standalone validator, but a
|
|
519
|
+
// validator does not re-run on future refactors — this pins it.
|
|
520
|
+
//
|
|
521
|
+
// Construction: the dashboard-user SSE gate only lets a caller subscribe
|
|
522
|
+
// to its own username (gate 4a), so two users can never share one key that
|
|
523
|
+
// way. Gate 4b is the usable path: any member may subscribe to an alias
|
|
524
|
+
// that exists as a session IN THEIR network. So we seed a session row with
|
|
525
|
+
// the SAME alias in two different networks and have each network's own
|
|
526
|
+
// member subscribe to it — producing `{N_A}:{alias}` and `{N_B}:{alias}`
|
|
527
|
+
// live at once, which is exactly the collision the filter must separate.
|
|
528
|
+
//
|
|
529
|
+
// Mutation-red (verified by hand, see the PR): replacing the member filter
|
|
530
|
+
// with `scopedSessions = sse.sessions` turns the two "must NOT contain the
|
|
531
|
+
// other network's key" assertions red.
|
|
532
|
+
describe("#506 cross-network isolation of /health sse_sessions (same alias, two networks)", () => {
|
|
533
|
+
const SHARED_ALIAS = "shared-alias-506";
|
|
534
|
+
let aToken = "", aNet = "", aName = "";
|
|
535
|
+
let bToken = "", bNet = "", bName = "";
|
|
536
|
+
let readerA: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
537
|
+
let readerB: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
538
|
+
|
|
539
|
+
beforeAll(async () => {
|
|
540
|
+
const sfx = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
541
|
+
const pw = "BootstrapPw123Aa!";
|
|
542
|
+
|
|
543
|
+
// Probe users are registered AFTER the suite's seed user, so neither is
|
|
544
|
+
// the auto-admin first user. Asserted below rather than assumed: an
|
|
545
|
+
// admin caller takes the unfiltered branch and would make these tests
|
|
546
|
+
// pass while proving nothing.
|
|
547
|
+
const ra = register(`h506_a_${sfx}`, pw, undefined, "seed");
|
|
548
|
+
const rb = register(`h506_b_${sfx}`, pw, undefined, "seed");
|
|
549
|
+
if (!ra.ok || !ra.token || !rb.ok || !rb.token) throw new Error("506 register failed");
|
|
550
|
+
aName = `h506_a_${sfx}`; aToken = ra.token; aNet = ra.network_id ?? "";
|
|
551
|
+
bName = `h506_b_${sfx}`; bToken = rb.token; bNet = rb.network_id ?? "";
|
|
552
|
+
if (!aNet || !bNet || aNet === bNet) throw new Error("506 needs two distinct networks");
|
|
553
|
+
db.run("UPDATE users SET role = 'user' WHERE username IN (?1, ?2)", [aName, bName]);
|
|
554
|
+
|
|
555
|
+
// Same alias name, one session row per network → gate 4b lets each
|
|
556
|
+
// network's own member subscribe to it.
|
|
557
|
+
for (const [net, tag] of [[aNet, "a"], [bNet, "b"]] as const) {
|
|
558
|
+
db.run(
|
|
559
|
+
`INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status)
|
|
560
|
+
VALUES (?1, ?2, ?3, datetime('now'), 'idle')`,
|
|
561
|
+
[`s_506_${tag}_${sfx}`, SHARED_ALIAS, net],
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const sub = async (token: string, net: string) => {
|
|
566
|
+
const res = await fetch(
|
|
567
|
+
`${BASE}/events/${encodeURIComponent(SHARED_ALIAS)}?network_id=${encodeURIComponent(net)}`,
|
|
568
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
569
|
+
);
|
|
570
|
+
if (res.status !== 200) throw new Error(`506 SSE subscribe failed (${net}): ${res.status}`);
|
|
571
|
+
const rd = res.body!.getReader();
|
|
572
|
+
await rd.read(); // connected frame → key registered
|
|
573
|
+
return rd;
|
|
574
|
+
};
|
|
575
|
+
readerA = await sub(aToken, aNet);
|
|
576
|
+
readerB = await sub(bToken, bNet);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
afterAll(() => {
|
|
580
|
+
try { readerA?.cancel(); } catch {}
|
|
581
|
+
try { readerB?.cancel(); } catch {}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("probe users are NOT admin (an admin caller would bypass the filter and prove nothing)", () => {
|
|
585
|
+
for (const n of [aName, bName]) {
|
|
586
|
+
const row = db.get<{ role: string }>("SELECT role FROM users WHERE username = ?1", n);
|
|
587
|
+
expect(row?.role).not.toBe("admin");
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("both same-alias keys are live at once (the collision actually exists)", async () => {
|
|
592
|
+
// Read through the ops endpoint with the admin token: if this shows only
|
|
593
|
+
// one of the two keys, the fixture never built the collision and the
|
|
594
|
+
// isolation assertions below would be vacuous.
|
|
595
|
+
const res = await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${adminToken}` } });
|
|
596
|
+
expect(res.status).toBe(200);
|
|
597
|
+
const body = await res.json() as { sessions?: Record<string, number> };
|
|
598
|
+
const keys = Object.keys(body.sessions ?? {});
|
|
599
|
+
expect(keys).toContain(`${aNet}:${SHARED_ALIAS}`);
|
|
600
|
+
expect(keys).toContain(`${bNet}:${SHARED_ALIAS}`);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("member of network A sees only A's copy of the shared alias", async () => {
|
|
604
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${aToken}` } });
|
|
605
|
+
expect(res.status).toBe(200);
|
|
606
|
+
const body = await res.json() as { sse_sessions?: Record<string, number> };
|
|
607
|
+
expect(body.sse_sessions).toBeDefined();
|
|
608
|
+
const keys = Object.keys(body.sse_sessions!);
|
|
609
|
+
expect(keys).toContain(`${aNet}:${SHARED_ALIAS}`);
|
|
610
|
+
expect(keys).not.toContain(`${bNet}:${SHARED_ALIAS}`);
|
|
611
|
+
// Nothing from the other network at all, by any key shape.
|
|
612
|
+
expect(keys.some((k) => k.startsWith(`${bNet}:`))).toBe(false);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("member of network B sees only B's copy (reverse direction)", async () => {
|
|
616
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${bToken}` } });
|
|
617
|
+
expect(res.status).toBe(200);
|
|
618
|
+
const body = await res.json() as { sse_sessions?: Record<string, number> };
|
|
619
|
+
expect(body.sse_sessions).toBeDefined();
|
|
620
|
+
const keys = Object.keys(body.sse_sessions!);
|
|
621
|
+
expect(keys).toContain(`${bNet}:${SHARED_ALIAS}`);
|
|
622
|
+
expect(keys).not.toContain(`${aNet}:${SHARED_ALIAS}`);
|
|
623
|
+
expect(keys.some((k) => k.startsWith(`${aNet}:`))).toBe(false);
|
|
624
|
+
});
|
|
625
|
+
});
|
package/src/push.ts
CHANGED
|
@@ -110,6 +110,15 @@ function clientKey(sessionName: string, networkId?: string | null): string {
|
|
|
110
110
|
// legacy ?network_id= path, and master token is root already.
|
|
111
111
|
const OBSERVER_KEY_PREFIX = "\u0000netobs:";
|
|
112
112
|
|
|
113
|
+
/** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
|
|
114
|
+
* `printableKey()` (below) and therefore the shape callers see via
|
|
115
|
+
* `getSSEStats().sessions`. Exported so downstream consumers (e.g.
|
|
116
|
+
* `/health` filter in server.ts) key off the same literal instead of
|
|
117
|
+
* each side re-writing the escape sequence — CR3 audit 4703b0e7
|
|
118
|
+
* found server.ts had `"netobs:"` (no `\\0`) which never matched and
|
|
119
|
+
* silently over-filtered observer keys. */
|
|
120
|
+
export const PRINTABLE_OBSERVER_KEY_PREFIX = "\\0netobs:";
|
|
121
|
+
|
|
113
122
|
function observerKey(networkId: string): string {
|
|
114
123
|
return `${OBSERVER_KEY_PREFIX}${networkId}`;
|
|
115
124
|
}
|
package/src/server.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
5
|
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
6
|
-
import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats } from "./push.js";
|
|
6
|
+
import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
|
|
7
7
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
8
8
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
9
9
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
MAX_REQUEST_CONTENT_LENGTH,
|
|
17
17
|
MAX_UPLOAD_BYTES,
|
|
18
18
|
buildStoragePath,
|
|
19
|
+
pathForExistingBlob,
|
|
20
|
+
isValidCalendarBucket,
|
|
19
21
|
generateFileId,
|
|
20
22
|
getUploadsRoot,
|
|
21
23
|
indexEntryPath,
|
|
@@ -1197,10 +1199,86 @@ return Bun.serve({
|
|
|
1197
1199
|
// now lives behind auth at GET /api/stats/sse.
|
|
1198
1200
|
// `version` stays: it does help fingerprinting, but ops needs it
|
|
1199
1201
|
// to verify deploys land, and the tradeoff was accepted in review.
|
|
1202
|
+
//
|
|
1203
|
+
// #495-followup / f28a6c1b — auth-gated `sse_sessions` restored so
|
|
1204
|
+
// the dashboard's own "online" widget can compute per-agent status
|
|
1205
|
+
// in one round-trip. Rules:
|
|
1206
|
+
// - anonymous (no valid token): NO sse_sessions field → watchdog
|
|
1207
|
+
// contract untouched, no topology leak (mutation removing this
|
|
1208
|
+
// gate turns the anonymous test RED — see health-redaction.test)
|
|
1209
|
+
// - admin utok_ / legacy master / DEV_OPEN: full sessions map
|
|
1210
|
+
// (parity with /api/stats/sse ops path)
|
|
1211
|
+
// - regular utok_ member: sessions map FILTERED to network_ids
|
|
1212
|
+
// the caller is a member of — safe against the "any account
|
|
1213
|
+
// sees all-network topology" regression the #473 fix closed;
|
|
1214
|
+
// dashboard user proxies with the logged-in user's utok_ so
|
|
1215
|
+
// their own network's aliases surface; strangers' networks do
|
|
1216
|
+
// not
|
|
1200
1217
|
const count = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions");
|
|
1201
1218
|
const sse = getSSEStats();
|
|
1202
1219
|
const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
|
|
1203
|
-
|
|
1220
|
+
|
|
1221
|
+
// Non-fatal auth probe (no 401 — /health must stay 200 anonymous).
|
|
1222
|
+
const healthAuth = resolveRequestAuth(req);
|
|
1223
|
+
const healthIsMaster = !healthAuth && isLegacyAuthToken(req);
|
|
1224
|
+
const healthIsDevOpen = !healthAuth && !AUTH_TOKEN && DEV_OPEN;
|
|
1225
|
+
const healthIsAdmin = !!(healthAuth?.username && db.get<any>(
|
|
1226
|
+
"SELECT role FROM users WHERE username = ?1", healthAuth.username,
|
|
1227
|
+
)?.role === "admin");
|
|
1228
|
+
|
|
1229
|
+
let scopedSessions: Record<string, number> | undefined;
|
|
1230
|
+
if (healthIsMaster || healthIsDevOpen || healthIsAdmin) {
|
|
1231
|
+
// Ops parity with /api/stats/sse — full map.
|
|
1232
|
+
scopedSessions = sse.sessions;
|
|
1233
|
+
} else if (healthAuth?.userId) {
|
|
1234
|
+
// Regular authenticated member — filter to networks they belong
|
|
1235
|
+
// to. Keys are `{networkId}:{alias}` (or observer keys shaped
|
|
1236
|
+
// `\0netobs:{networkId}`); parse the network prefix and keep
|
|
1237
|
+
// only entries whose network is in the member's set.
|
|
1238
|
+
//
|
|
1239
|
+
// 🔴 ntok_ (network-scoped token) MUST be forced to its single
|
|
1240
|
+
// bound network (parity with resolveRestNetworkScope L277:
|
|
1241
|
+
// `if (authCtx.networkId) return { networkId: authCtx.networkId }`).
|
|
1242
|
+
// Falling through to the utok_ union query would return
|
|
1243
|
+
// sessions from every network the underlying user belongs to,
|
|
1244
|
+
// silently escalating a network-scoped token to a user-scoped
|
|
1245
|
+
// token — the exact "认证 ≠ 已授权" mistake this whole change
|
|
1246
|
+
// is meant to prevent. Same shape as f28a6c1b root cause; see
|
|
1247
|
+
// CR3 audit 4703b0e7.
|
|
1248
|
+
const memberNets = new Set<string>(
|
|
1249
|
+
healthAuth.networkId
|
|
1250
|
+
? [healthAuth.networkId] // ntok_ single-network enforce
|
|
1251
|
+
: db.all<{ network_id: string }>(
|
|
1252
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1",
|
|
1253
|
+
healthAuth.userId,
|
|
1254
|
+
).map((r) => r.network_id),
|
|
1255
|
+
);
|
|
1256
|
+
// If member has no networks at all, keep field present but empty
|
|
1257
|
+
// so the dashboard can distinguish "authenticated + none active"
|
|
1258
|
+
// from "not authenticated at all".
|
|
1259
|
+
const filtered: Record<string, number> = {};
|
|
1260
|
+
for (const [key, n] of Object.entries(sse.sessions)) {
|
|
1261
|
+
// Observer keys are shape `\0netobs:<networkId>` raw; keyed
|
|
1262
|
+
// through `printableKey()` in push.ts, they surface here as
|
|
1263
|
+
// the literal string `\\0netobs:<networkId>` (backslash-zero,
|
|
1264
|
+
// not NUL byte). Use the shared `PRINTABLE_OBSERVER_KEY_PREFIX`
|
|
1265
|
+
// export so this side and the emitter side stay locked to the
|
|
1266
|
+
// same literal — CR3 audit 4703b0e7 caught prior check
|
|
1267
|
+
// `startsWith("netobs:")` was always false → observer keys
|
|
1268
|
+
// silently over-filtered out of member views.
|
|
1269
|
+
const isObserver = key.startsWith(PRINTABLE_OBSERVER_KEY_PREFIX);
|
|
1270
|
+
const netId = isObserver
|
|
1271
|
+
? key.slice(PRINTABLE_OBSERVER_KEY_PREFIX.length)
|
|
1272
|
+
: key.split(":")[0];
|
|
1273
|
+
if (memberNets.has(netId)) filtered[key] = n;
|
|
1274
|
+
}
|
|
1275
|
+
scopedSessions = filtered;
|
|
1276
|
+
}
|
|
1277
|
+
// healthAuth == null AND no legacy master AND no DEV_OPEN → leave
|
|
1278
|
+
// scopedSessions undefined → sse_sessions field omitted entirely
|
|
1279
|
+
// (watchdog + arbitrary internet strangers see aggregates only).
|
|
1280
|
+
|
|
1281
|
+
const body: Record<string, unknown> = {
|
|
1204
1282
|
ok: true,
|
|
1205
1283
|
version: SERVER_VERSION,
|
|
1206
1284
|
api_version: "v3",
|
|
@@ -1214,7 +1292,9 @@ return Bun.serve({
|
|
|
1214
1292
|
multi_network: true,
|
|
1215
1293
|
license: license?.type || "none",
|
|
1216
1294
|
uptime: Math.floor(process.uptime()),
|
|
1217
|
-
}
|
|
1295
|
+
};
|
|
1296
|
+
if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
|
|
1297
|
+
return withCors(req, Response.json(body));
|
|
1218
1298
|
}
|
|
1219
1299
|
|
|
1220
1300
|
// ── All REST /api endpoints require auth (if token configured) ──
|
|
@@ -1695,10 +1775,15 @@ return Bun.serve({
|
|
|
1695
1775
|
|
|
1696
1776
|
let entry: any;
|
|
1697
1777
|
try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
|
|
1698
|
-
|
|
1778
|
+
// #509 副指挥 66983a19: corrupted index must not leak "server
|
|
1779
|
+
// failed" signal — 404 with same shape as unknown file_id
|
|
1780
|
+
// (input refused at boundary, no enumeration).
|
|
1781
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1699
1782
|
}
|
|
1700
1783
|
if (!validateIndexEntry(entry)) {
|
|
1701
|
-
|
|
1784
|
+
// #509 副指挥 66983a19: schema-invalid index (bucket "../etc",
|
|
1785
|
+
// missing fields, wrong types) is also 404 — same discipline.
|
|
1786
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1702
1787
|
}
|
|
1703
1788
|
|
|
1704
1789
|
// #495 ownership gate — placed AFTER file-exists so denied
|
|
@@ -1710,11 +1795,29 @@ return Bun.serve({
|
|
|
1710
1795
|
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1711
1796
|
}
|
|
1712
1797
|
|
|
1798
|
+
// #509 defence-in-depth: pre-check date_bucket against BOTH
|
|
1799
|
+
// shape regex AND real-calendar semantics. A poisoned index (e.g.
|
|
1800
|
+
// "../etc", "2026-02-30", "2026-13-01") must return 404 not 500
|
|
1801
|
+
// — 500 says "server failed"; 404 says "input refused at boundary".
|
|
1802
|
+
// 副指挥 b1082017 / 66983a19 hard doors:
|
|
1803
|
+
// • 稳定 404 非 500 for invalid/missing/poisoned date_bucket
|
|
1804
|
+
// • 真日历语义校验, 不仅 regex 形状
|
|
1805
|
+
if (!isValidCalendarBucket(entry.date_bucket)) {
|
|
1806
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1713
1809
|
let storage;
|
|
1714
1810
|
try {
|
|
1715
|
-
|
|
1811
|
+
// #509 fix: use the date_bucket recorded in the index at upload
|
|
1812
|
+
// time, NOT today's date. buildStoragePath computes today; that is
|
|
1813
|
+
// correct for new uploads only. Yesterday's file lives in
|
|
1814
|
+
// yesterday's bucket forever — read path must respect that.
|
|
1815
|
+
storage = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext);
|
|
1716
1816
|
} catch {
|
|
1717
|
-
|
|
1817
|
+
// Defence-in-depth: unreachable given the pre-check above, but
|
|
1818
|
+
// keep as fallback. Return 404 not 500 to match the pre-check
|
|
1819
|
+
// shape — caller sees the same code for all invalid-bucket cases.
|
|
1820
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1718
1821
|
}
|
|
1719
1822
|
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1720
1823
|
// Defence: should be unreachable since buildStoragePath rejected
|
package/src/uploads.ts
CHANGED
|
@@ -94,10 +94,47 @@ export function sanitizeExt(filename: string | undefined | null): string {
|
|
|
94
94
|
return match ? "." + match[1].toLowerCase() : "";
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// YYYY-MM-DD bucket string regex — matches what getDateBucket produces
|
|
98
|
+
// and what an index entry's `date_bucket` field is validated against.
|
|
99
|
+
const DATE_BUCKET_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate a date_bucket string against BOTH shape (YYYY-MM-DD regex)
|
|
103
|
+
* AND real-calendar semantics: rejects "2026-02-30", "2026-13-01",
|
|
104
|
+
* "2026-00-15", leap-day-on-non-leap-year, etc. Shape-only check is not
|
|
105
|
+
* enough because the regex accepts any digit combination in each
|
|
106
|
+
* position.
|
|
107
|
+
*
|
|
108
|
+
* Used as a pre-check by the download handler before it calls into
|
|
109
|
+
* pathForExistingBlob — a non-calendar bucket is a 404 (invalid input
|
|
110
|
+
* rejected at the boundary), NOT a 500 (uncaught throw from deep in
|
|
111
|
+
* the read path). Distinguishing these matters: 500 says "server
|
|
112
|
+
* failed"; 404 says "we know your request is bad, refused early".
|
|
113
|
+
*/
|
|
114
|
+
export function isValidCalendarBucket(bucket: unknown): bucket is string {
|
|
115
|
+
if (typeof bucket !== "string") return false;
|
|
116
|
+
if (!DATE_BUCKET_REGEX.test(bucket)) return false;
|
|
117
|
+
// Round-trip via Date to reject non-calendar shapes like "2026-02-30"
|
|
118
|
+
// or "2026-13-01". `Date.UTC` coerces overflow ("2026-02-30" becomes
|
|
119
|
+
// "2026-03-02") — if the ISO round-trip doesn't equal the input, the
|
|
120
|
+
// caller supplied a non-calendar bucket.
|
|
121
|
+
const [yStr, mStr, dStr] = bucket.split("-");
|
|
122
|
+
const y = Number(yStr), m = Number(mStr), d = Number(dStr);
|
|
123
|
+
if (m < 1 || m > 12) return false;
|
|
124
|
+
if (d < 1 || d > 31) return false;
|
|
125
|
+
const roundtrip = new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
|
|
126
|
+
return roundtrip === bucket;
|
|
127
|
+
}
|
|
128
|
+
|
|
97
129
|
/**
|
|
98
|
-
* Build the
|
|
99
|
-
*
|
|
100
|
-
*
|
|
130
|
+
* Build the storage path for a NEW upload. Computes today's date bucket
|
|
131
|
+
* from the runtime clock (or `opts.now` for tests). Use ONLY for creating
|
|
132
|
+
* a fresh blob; for reading an existing blob whose bucket is already
|
|
133
|
+
* recorded in the index, use pathForExistingBlob() instead.
|
|
134
|
+
*
|
|
135
|
+
* The file_id and ext are validated against strict regexes; any
|
|
136
|
+
* path-separator or `..` segment from a malicious client gets rejected
|
|
137
|
+
* at the regex layer before any I/O.
|
|
101
138
|
*/
|
|
102
139
|
export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
|
|
103
140
|
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
@@ -109,6 +146,32 @@ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoo
|
|
|
109
146
|
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
110
147
|
}
|
|
111
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Build the storage path for an EXISTING blob, using the caller-supplied
|
|
151
|
+
* `dateBucket` (typically read from the index entry). Does NOT compute
|
|
152
|
+
* a date from the runtime clock — that is the write-side concern.
|
|
153
|
+
*
|
|
154
|
+
* This split exists because sharing one date-computing function between
|
|
155
|
+
* write and read semantics caused #509: yesterday's file downloaded
|
|
156
|
+
* today would look under today's bucket → blob_missing → 404, even
|
|
157
|
+
* though the blob was still sitting in yesterday's directory.
|
|
158
|
+
*
|
|
159
|
+
* The file_id, ext and dateBucket are validated against strict regexes
|
|
160
|
+
* before any I/O; a poisoned index bucket (e.g. `../etc`) is rejected
|
|
161
|
+
* here just as file_id path-escape attempts are.
|
|
162
|
+
*/
|
|
163
|
+
export function pathForExistingBlob(dateBucket: string, fileId: string, ext: string, opts: { uploadsRoot?: string } = {}): GeneratedFile {
|
|
164
|
+
if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
|
|
165
|
+
if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
|
|
166
|
+
if (typeof dateBucket !== "string" || !DATE_BUCKET_REGEX.test(dateBucket)) {
|
|
167
|
+
throw new Error(`date_bucket "${dateBucket}" is invalid`);
|
|
168
|
+
}
|
|
169
|
+
const root = opts.uploadsRoot ?? getUploadsRoot();
|
|
170
|
+
const relativePath = join(dateBucket, fileId + ext);
|
|
171
|
+
const absolutePath = join(root, relativePath);
|
|
172
|
+
return { fileId, ext, dateBucket, relativePath, absolutePath };
|
|
173
|
+
}
|
|
174
|
+
|
|
112
175
|
export type UploadIndexEntry = {
|
|
113
176
|
file_id: string;
|
|
114
177
|
date_bucket: string; // YYYY-MM-DD
|