@sleep2agi/commhub-server 0.9.0-preview.8 â 0.9.0-preview.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +478 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +450 -31
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +46 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +805 -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/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +110 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +284 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirement-notify.ts +134 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1443 -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 +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4522 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +317 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3534 -343
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// äşşä¸äşşç§äżĄçćäťśĺŞçťç§äżĄéçäşşç(owner 2026-09-30ăçťäşşĺĽ˝ĺĺä¸äşĺžçăçĺçť)ă
|
|
2
|
+
//
|
|
3
|
+
// č§ĺ:ćäťśä¸äź ćśĺ¸Ś `?purpose=dm`(App ç§äżĄčžĺ
ĽćĄčżäšäź )= ĺŽçŹŹä¸ćŹĄç¨éĺ°ąćŻç§äżĄ â ç§äżĄćäťś(index scope=dm)ă
|
|
4
|
+
// ç§äżĄćäťśĺŞćä¸äź č
ă希çĺŽçç§äżĄçćśĺĺćšă玥çĺč˝ä¸č˝˝;ĺç˝çťçĺ
śäťćĺĺčçšäť¤ç(Agent)é˝ 404
|
|
5
|
+
// (ä¸ĺ
śĺŽćçťĺä¸ä¸Şĺ˝˘çś,ä¸ćłé˛ćäťśĺĺ¨)ăä¸ĺ¸Ś purpose ä¸äź çćäťś ââ äžĺŚĺ
ĺ¨ agent äźčŻéĺčż ââ ć°¸čżä¸äź
|
|
6
|
+
// ĺćç§äżĄćäťś,äšĺ轏čżç§äżĄäšç
§ć§ĺ
¨ç˝ćĺĺŻč§ăç§äżĄćäťśĺŞč˝çąçĺžč§ĺŽçäşşĺ轏ĺă
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { tmpdir } from "os";
|
|
12
|
+
import { register, addNetworkMember } from "./auth.js";
|
|
13
|
+
import { db } from "./db.js";
|
|
14
|
+
|
|
15
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-dm-file-scope-"));
|
|
16
|
+
const PW = "DmFileScopePassw0rd!";
|
|
17
|
+
let BASE = "";
|
|
18
|
+
let hub: any = null;
|
|
19
|
+
let NET = "";
|
|
20
|
+
type U = { token: string; id: string };
|
|
21
|
+
let admin: U, alice: U, bob: U, carol: U, dave: U;
|
|
22
|
+
let adminNtok = "";
|
|
23
|
+
|
|
24
|
+
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
|
25
|
+
async function upload(token: string, body: string, query: string) {
|
|
26
|
+
const form = new FormData();
|
|
27
|
+
form.append("file", new Blob([new TextEncoder().encode(body)], { type: "image/png" }), "shot.png");
|
|
28
|
+
const res = await fetch(`${BASE}/api/upload?network_id=${NET}${query}`, { method: "POST", body: form, headers: auth(token) });
|
|
29
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
30
|
+
}
|
|
31
|
+
async function uploadId(token: string, body: string, query = "") {
|
|
32
|
+
const r = await upload(token, body, query);
|
|
33
|
+
expect(r.status).toBe(200);
|
|
34
|
+
return r.body.file_id as string;
|
|
35
|
+
}
|
|
36
|
+
async function dm(from: U, to: U, fileId: string, message = "") {
|
|
37
|
+
const res = await fetch(`${BASE}/api/dm`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { ...auth(from.token), "Content-Type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ network_id: NET, to_user_id: to.id, message, attachments: [{ type: "file", file_id: fileId, name: "shot.png", mime: "image/png" }] }),
|
|
41
|
+
});
|
|
42
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
43
|
+
}
|
|
44
|
+
const fileStatus = async (token: string, fileId: string, method = "GET") =>
|
|
45
|
+
(await fetch(`${BASE}/api/files/${fileId}`, { method, headers: auth(token) })).status;
|
|
46
|
+
const entry = (fileId: string) => JSON.parse(readFileSync(join(DIR, "uploads", ".index", `${fileId}.json`), "utf8"));
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
50
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
51
|
+
process.env.HOST = "127.0.0.1";
|
|
52
|
+
const stamp = Date.now();
|
|
53
|
+
const a = register(`dmfs_admin_${stamp}`, PW);
|
|
54
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [a.user!.user_id]);
|
|
55
|
+
admin = { token: a.token!, id: a.user!.user_id };
|
|
56
|
+
adminNtok = a.network_token!;
|
|
57
|
+
NET = a.network_id!;
|
|
58
|
+
// alice / bob / carol see every agent (agent_access=all) â the members for whom a network-scoped file was readable
|
|
59
|
+
// before, i.e. where the gap was. dave is a restricted member (only granted agents).
|
|
60
|
+
const mk = (name: string, agentAccess: "all" | "granted"): U => {
|
|
61
|
+
const r = register(`${name}_${stamp}`, PW);
|
|
62
|
+
expect(addNetworkMember(NET, r.user!.user_id, "member", admin.id, { agentAccess }).ok).toBe(true);
|
|
63
|
+
return { token: r.token!, id: r.user!.user_id };
|
|
64
|
+
};
|
|
65
|
+
alice = mk("dmfs_alice", "all"); bob = mk("dmfs_bob", "all"); carol = mk("dmfs_carol", "all"); dave = mk("dmfs_dave", "granted");
|
|
66
|
+
const mod: any = await import("./server.js");
|
|
67
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
68
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
69
|
+
}, 30_000);
|
|
70
|
+
|
|
71
|
+
afterAll(() => {
|
|
72
|
+
try { hub?.stop?.(true); } catch {}
|
|
73
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("upload ?purpose=dm", () => {
|
|
77
|
+
test("marks the index entry; absent purpose writes no scope key (old shape)", async () => {
|
|
78
|
+
expect(entry(await uploadId(alice.token, "p1", "&purpose=dm")).scope).toBe("dm");
|
|
79
|
+
expect("scope" in entry(await uploadId(alice.token, "p2"))).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
test("an unknown purpose is refused, not silently treated as network scope", async () => {
|
|
82
|
+
const r = await upload(alice.token, "p3", "&purpose=DM");
|
|
83
|
+
expect(r.status).toBe(400);
|
|
84
|
+
expect(r.body.error).toBe("bad_purpose");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("a DM file is readable only by the people in the DM", () => {
|
|
89
|
+
test("alice â bob: alice and bob read it; carol (same network, not in the DM) and agents get 404; admin reads", async () => {
|
|
90
|
+
const fid = await uploadId(alice.token, "alice-to-bob", "&purpose=dm");
|
|
91
|
+
expect((await dm(alice, bob, fid)).status).toBe(200);
|
|
92
|
+
expect(await fileStatus(alice.token, fid)).toBe(200);
|
|
93
|
+
expect(await fileStatus(bob.token, fid)).toBe(200);
|
|
94
|
+
expect(await fileStatus(carol.token, fid)).toBe(404);
|
|
95
|
+
expect(await fileStatus(carol.token, fid, "HEAD")).toBe(404);
|
|
96
|
+
expect(await fileStatus(adminNtok, fid)).toBe(404);
|
|
97
|
+
expect(await fileStatus(admin.token, fid)).toBe(200);
|
|
98
|
+
});
|
|
99
|
+
test("bob â alice (other direction): same rule", async () => {
|
|
100
|
+
const fid = await uploadId(bob.token, "bob-to-alice", "&purpose=dm");
|
|
101
|
+
expect((await dm(bob, alice, fid)).status).toBe(200);
|
|
102
|
+
expect(await fileStatus(bob.token, fid)).toBe(200);
|
|
103
|
+
expect(await fileStatus(alice.token, fid)).toBe(200);
|
|
104
|
+
expect(await fileStatus(carol.token, fid)).toBe(404);
|
|
105
|
+
});
|
|
106
|
+
test("a restricted member in the DM reads it both ways; out of it, not", async () => {
|
|
107
|
+
const toDave = await uploadId(alice.token, "alice-to-dave", "&purpose=dm");
|
|
108
|
+
expect((await dm(alice, dave, toDave)).status).toBe(200);
|
|
109
|
+
expect(await fileStatus(dave.token, toDave)).toBe(200);
|
|
110
|
+
const fromDave = await uploadId(dave.token, "dave-to-alice", "&purpose=dm");
|
|
111
|
+
expect((await dm(dave, alice, fromDave)).status).toBe(200);
|
|
112
|
+
expect(await fileStatus(alice.token, fromDave)).toBe(200);
|
|
113
|
+
expect(await fileStatus(bob.token, fromDave)).toBe(404);
|
|
114
|
+
const other = await uploadId(alice.token, "alice-to-bob-2", "&purpose=dm");
|
|
115
|
+
await dm(alice, bob, other);
|
|
116
|
+
expect(await fileStatus(dave.token, other)).toBe(404);
|
|
117
|
+
});
|
|
118
|
+
test("before it is sent, only the uploader reads it", async () => {
|
|
119
|
+
const fid = await uploadId(alice.token, "draft", "&purpose=dm");
|
|
120
|
+
expect(await fileStatus(alice.token, fid)).toBe(200);
|
|
121
|
+
expect(await fileStatus(bob.token, fid)).toBe(404);
|
|
122
|
+
});
|
|
123
|
+
test("the DM thread still returns the attachment to both sides", async () => {
|
|
124
|
+
const fid = await uploadId(alice.token, "thread", "&purpose=dm");
|
|
125
|
+
await dm(alice, bob, fid, "çĺž");
|
|
126
|
+
const t = await (await fetch(`${BASE}/api/dm?network_id=${NET}&with=${alice.id}`, { headers: auth(bob.token) })).json() as any;
|
|
127
|
+
expect(JSON.parse(t.messages[0].meta_json).attachments[0].file_id).toBe(fid);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("nobody unlocks a DM file by putting its id into their own DM", () => {
|
|
132
|
+
test("carol DMs alice's DM file to bob â 403, and she still can't read it", async () => {
|
|
133
|
+
const fid = await uploadId(alice.token, "secret", "&purpose=dm");
|
|
134
|
+
expect((await dm(alice, bob, fid)).status).toBe(200);
|
|
135
|
+
const r = await dm(carol, bob, fid);
|
|
136
|
+
expect(r.status).toBe(403);
|
|
137
|
+
expect(r.body.error).toBe("attachment_not_accessible");
|
|
138
|
+
expect(await fileStatus(carol.token, fid)).toBe(404);
|
|
139
|
+
});
|
|
140
|
+
test("bob (who received it) may forward it to carol, and then carol reads it", async () => {
|
|
141
|
+
const fid = await uploadId(alice.token, "forwardable", "&purpose=dm");
|
|
142
|
+
expect((await dm(alice, bob, fid)).status).toBe(200);
|
|
143
|
+
expect((await dm(bob, carol, fid)).status).toBe(200);
|
|
144
|
+
expect(await fileStatus(carol.token, fid)).toBe(200);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("files whose first use was not a DM keep their scope", () => {
|
|
149
|
+
test("uploaded in an agent chat, then forwarded into a DM â still readable by every member", async () => {
|
|
150
|
+
const fid = await uploadId(alice.token, "agent-chat-first");
|
|
151
|
+
// first use: an agent task (what ChatScreen does: upload without purpose, send_task with the file id)
|
|
152
|
+
db.run(
|
|
153
|
+
`INSERT INTO tasks (task_id, from_name, to_name, status, content, network_id, meta_json) VALUES (?1, ?2, 'dmfs-agent', 'delivered', 'see image', ?3, ?4)`,
|
|
154
|
+
[`t_dmfs_${Date.now()}`, "dmfs_alice", NET, JSON.stringify({ attachments: [{ type: "file", file_id: fid }] })],
|
|
155
|
+
);
|
|
156
|
+
expect(await fileStatus(carol.token, fid)).toBe(200);
|
|
157
|
+
expect((await dm(alice, bob, fid)).status).toBe(200);
|
|
158
|
+
expect("scope" in entry(fid)).toBe(false);
|
|
159
|
+
expect(await fileStatus(carol.token, fid)).toBe(200);
|
|
160
|
+
expect(await fileStatus(bob.token, fid)).toBe(200);
|
|
161
|
+
expect(await fileStatus(adminNtok, fid)).toBe(200);
|
|
162
|
+
});
|
|
163
|
+
test("an old app's DM upload (no purpose) behaves exactly as before", async () => {
|
|
164
|
+
const fid = await uploadId(alice.token, "old-app");
|
|
165
|
+
expect((await dm(alice, bob, fid)).status).toBe(200);
|
|
166
|
+
expect(await fileStatus(carol.token, fid)).toBe(200);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("index entry integrity", () => {
|
|
171
|
+
test("a hand-edited unknown scope makes the entry invalid (404), never network-wide", async () => {
|
|
172
|
+
const fid = await uploadId(alice.token, "tamper", "&purpose=dm");
|
|
173
|
+
const path = join(DIR, "uploads", ".index", `${fid}.json`);
|
|
174
|
+
writeFileSync(path, JSON.stringify({ ...entry(fid), scope: "public" }));
|
|
175
|
+
expect(await fileStatus(alice.token, fid)).toBe(404);
|
|
176
|
+
expect(await fileStatus(carol.token, fid)).toBe(404);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildStoragePath,
|
|
4
|
+
pathForExistingBlob,
|
|
5
|
+
validateIndexEntry,
|
|
6
|
+
} from "./uploads.js";
|
|
7
|
+
|
|
8
|
+
const fileId = "0123456789abcdef0123456789abcdef";
|
|
9
|
+
const dateBucket = "2026-08-09";
|
|
10
|
+
const uploadsRoot = "/srv/uploads";
|
|
11
|
+
|
|
12
|
+
const malicious = [
|
|
13
|
+
"../x",
|
|
14
|
+
"/abs/x",
|
|
15
|
+
".png/../../y",
|
|
16
|
+
".p ng",
|
|
17
|
+
".p\0ng",
|
|
18
|
+
".\\..\\x",
|
|
19
|
+
"." + "a".repeat(17),
|
|
20
|
+
".tar.gz",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const legal = ["", ".png", ".PNG", ".mp4", "." + "a".repeat(16)];
|
|
24
|
+
|
|
25
|
+
describe("#527 shared extension-token invariant", () => {
|
|
26
|
+
test("new-upload path rejects every malformed token and accepts the shared legal set", () => {
|
|
27
|
+
for (const ext of malicious) {
|
|
28
|
+
expect(() => buildStoragePath(fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
29
|
+
}
|
|
30
|
+
for (const ext of legal) {
|
|
31
|
+
expect(buildStoragePath(fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("existing-blob path rejects every malformed token and accepts the shared legal set", () => {
|
|
36
|
+
for (const ext of malicious) {
|
|
37
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
38
|
+
}
|
|
39
|
+
for (const ext of legal) {
|
|
40
|
+
expect(pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stored-index gate rejects every malformed token and accepts the shared legal set", () => {
|
|
45
|
+
for (const ext of malicious) {
|
|
46
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
47
|
+
}
|
|
48
|
+
for (const ext of legal) {
|
|
49
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("all three boundaries fail closed on non-string runtime values", () => {
|
|
54
|
+
for (const ext of [undefined, null, 7, {}, []]) {
|
|
55
|
+
expect(() => buildStoragePath(fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
56
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
57
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Task #25 â regression pins for ext validation in validateIndexEntry.
|
|
2
|
+
//
|
|
3
|
+
// đ´ SCOPE: this suite exercises the stored-index boundary through
|
|
4
|
+
// `validateIndexEntry`. Issue #527 removed the three independent copies
|
|
5
|
+
// of the stored extension-token grammar: buildStoragePath,
|
|
6
|
+
// pathForExistingBlob and validateIndexEntry now use one shared private
|
|
7
|
+
// validator. `ext-token-shared.test.ts` separately pins all three call
|
|
8
|
+
// sites. sanitizeExt remains intentionally independent because it parses
|
|
9
|
+
// a client filename rather than validating an already-extracted token.
|
|
10
|
+
//
|
|
11
|
+
// Background (éäżĄéž d01bb8ce): the stored extension-token grammar
|
|
12
|
+
// `/^\.[A-Za-z0-9]{1,16}$/` correctly rejects 11 known malicious ext
|
|
13
|
+
// values (path-escape, traversal, absolute paths, doubles, NUL bytes,
|
|
14
|
+
// backslash forms, over-length, whitespace). Verified via real-run on
|
|
15
|
+
// origin/main by lead â 11/11 malicious values â false.
|
|
16
|
+
//
|
|
17
|
+
// But NO test locked that behavior. Anyone who widens the regex in the
|
|
18
|
+
// future (accidentally or on purpose) would not hit a red test â the
|
|
19
|
+
// live protection would degrade silently. This suite pins current
|
|
20
|
+
// behavior in two directions:
|
|
21
|
+
//
|
|
22
|
+
// 1. SAFETY (ćŹ äżŽ direction) â 11 malicious values â false, one row
|
|
23
|
+
// per value with the exact literal. If regex is widened to accept
|
|
24
|
+
// any of them, that row turns red.
|
|
25
|
+
// 2. NON-REGRESSION (čżäżŽ direction) â a set of common legitimate
|
|
26
|
+
// extensions â true. If regex is tightened to reject any of them
|
|
27
|
+
// (e.g. someone locks down to a whitelist), that row turns red.
|
|
28
|
+
//
|
|
29
|
+
// Witnessed-red for the SAFETY rows: temporarily widen the regex to
|
|
30
|
+
// `/./` (or comment it out) in validateIndexEntry, run this suite,
|
|
31
|
+
// confirm all 11 malicious rows turn red, then revert. The mutation
|
|
32
|
+
// itself is not committed; the assertion IS the mutation-red proof
|
|
33
|
+
// for the reader. Recording the mutation output in the followup PR
|
|
34
|
+
// body (per Constraint 3 discipline established in #503).
|
|
35
|
+
//
|
|
36
|
+
// Pure unit test â no server setup, no DB, no filesystem. The regex
|
|
37
|
+
// lives in a pure function, so the guard is testable at that level.
|
|
38
|
+
|
|
39
|
+
import { describe, expect, test } from "bun:test";
|
|
40
|
+
import { validateIndexEntry } from "./uploads.js";
|
|
41
|
+
|
|
42
|
+
// A structurally-valid base entry â file_id / date_bucket / size all
|
|
43
|
+
// pass their own checks â so `ext` is the only field a test row varies.
|
|
44
|
+
// This isolates the ext regex as the deciding factor.
|
|
45
|
+
const validBase = {
|
|
46
|
+
file_id: "0123456789abcdef0123456789abcdef",
|
|
47
|
+
date_bucket: "2026-07-30",
|
|
48
|
+
size: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("Task #25 (#503 followup) â ext validation regression pins", () => {
|
|
52
|
+
describe("SAFETY: the 11 malicious ext values from lead d01bb8ce all return false", () => {
|
|
53
|
+
// Each row uses the exact literal from lead's verification list so
|
|
54
|
+
// a widening regex catches on the same value the manual audit did.
|
|
55
|
+
const malicious: Array<{ label: string; ext: string }> = [
|
|
56
|
+
{ label: "path-escape prefix", ext: "../x" },
|
|
57
|
+
{ label: "deep traversal to /etc", ext: "../../etc/passwd" },
|
|
58
|
+
{ label: "absolute path", ext: "/abs/x" },
|
|
59
|
+
{ label: "double-dot alone", ext: ".." },
|
|
60
|
+
{ label: "single-dot alone", ext: "." },
|
|
61
|
+
{ label: "chained after legit prefix", ext: ".png/../../y" },
|
|
62
|
+
{ label: "embedded whitespace", ext: ".p ng" },
|
|
63
|
+
{ label: "over-length (>16 chars)", ext: "." + "a".repeat(17) },
|
|
64
|
+
{ label: "embedded NUL byte", ext: ".p\x00ng" },
|
|
65
|
+
{ label: "backslash traversal", ext: ".\\..\\x" },
|
|
66
|
+
{ label: "double-backslash prefix", ext: "..\\x" },
|
|
67
|
+
];
|
|
68
|
+
for (const { label, ext } of malicious) {
|
|
69
|
+
test(`rejects: ${label} â ext=${JSON.stringify(ext)}`, () => {
|
|
70
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("precondition: 11 malicious cases enumerated (defends against silent test-shrink)", () => {
|
|
75
|
+
// If someone deletes rows from the malicious list, this fails
|
|
76
|
+
// instead of the deletion silently narrowing the guard. Number
|
|
77
|
+
// matches lead's verification list â bump only when lead adds a
|
|
78
|
+
// case and confirms it via real-run.
|
|
79
|
+
expect(malicious.length).toBe(11);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("NON-REGRESSION: common legitimate extensions all return true (é˛čżäżŽ)", () => {
|
|
84
|
+
// These are the extensions the live upload path actually produces
|
|
85
|
+
// via sanitizeExt / mime-typed multipart uploads. If someone
|
|
86
|
+
// tightens the regex to a stricter whitelist, any of these turning
|
|
87
|
+
// red flags a break in a normal user path.
|
|
88
|
+
const legal = [".png", ".pdf", ".pptx", ".jpg", ".jpeg", ".txt", ".mp4", ".zip", ".json", ".md"];
|
|
89
|
+
for (const ext of legal) {
|
|
90
|
+
test(`accepts: ext=${JSON.stringify(ext)}`, () => {
|
|
91
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test("empty ext is accepted (file with no extension is a legitimate upload)", () => {
|
|
96
|
+
expect(validateIndexEntry({ ...validBase, ext: "" })).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("precondition: at least 10 legitimate ext values pinned", () => {
|
|
100
|
+
// Same discipline as the malicious side â reject silent shrink.
|
|
101
|
+
expect(legal.length).toBeGreaterThanOrEqual(10);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("regex shape assumptions (documented, not enforced by the function)", () => {
|
|
106
|
+
// These rows document facts the current regex enforces beyond the
|
|
107
|
+
// 11 malicious literals. They are close to the boundary and would
|
|
108
|
+
// be the first things to move if someone reworked the regex.
|
|
109
|
+
test("upper-case extensions accepted (regex is case-insensitive by [A-Za-z])", () => {
|
|
110
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PNG" })).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
test("mixed-case extensions accepted", () => {
|
|
113
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PdF" })).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
test("digits after dot accepted (e.g. .mp4, .mp3)", () => {
|
|
116
|
+
expect(validateIndexEntry({ ...validBase, ext: ".mp4" })).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
test("exactly 16 chars after dot is accepted (upper bound of {1,16})", () => {
|
|
119
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(16) })).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
test("exactly 17 chars after dot is rejected (immediately past upper bound)", () => {
|
|
122
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(17) })).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
test("compound extensions like .tar.gz are rejected (contains inner dot)", () => {
|
|
125
|
+
// Documented: the current regex does not support compound
|
|
126
|
+
// extensions. If we ever want .tar.gz support, it needs its
|
|
127
|
+
// own explicit design + this row updated with lead sign-off.
|
|
128
|
+
expect(validateIndexEntry({ ...validBase, ext: ".tar.gz" })).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|