@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.
Files changed (203) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +5 -5
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +478 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/alias-filter.test.ts +72 -0
  15. package/src/alias-filter.ts +32 -0
  16. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  17. package/src/api-host-supervisors-fallback.test.ts +271 -0
  18. package/src/api-nodes-shape.test.ts +26 -10
  19. package/src/auth-sessions-http.test.ts +250 -0
  20. package/src/auth-validate.test.ts +5 -1
  21. package/src/auth.ts +450 -31
  22. package/src/avatar-validate.test.ts +163 -0
  23. package/src/avatar-validate.ts +103 -0
  24. package/src/blocked-exit-on-reply.test.ts +122 -0
  25. package/src/boot-side-effect.test.ts +124 -0
  26. package/src/commhub-bin-args.test.ts +85 -0
  27. package/src/config-apply-sec1.test.ts +107 -0
  28. package/src/config-apply-validate.test.ts +92 -0
  29. package/src/config-apply-validate.ts +81 -3
  30. package/src/create-node-blocked-gate.test.ts +356 -0
  31. package/src/create-node-tool-schema.test.ts +39 -0
  32. package/src/create-node-validate.test.ts +89 -1
  33. package/src/create-node-validate.ts +89 -7
  34. package/src/create-node-workdir.test.ts +255 -0
  35. package/src/create-node.ts +46 -1
  36. package/src/dashboard-slash-routing-http.test.ts +107 -0
  37. package/src/db-adapter-guard.test.ts +148 -0
  38. package/src/db-adapter-translate.test.ts +128 -0
  39. package/src/db-adapter.ts +323 -87
  40. package/src/db-timestamp.test.ts +38 -0
  41. package/src/db-timestamp.ts +15 -0
  42. package/src/db.ts +805 -33
  43. package/src/delivered-without-push.test.ts +140 -0
  44. package/src/desktop-message-seam.test.ts +150 -0
  45. package/src/desktop-message.test.ts +368 -0
  46. package/src/dm-file-scope-http.test.ts +178 -0
  47. package/src/ext-token-shared.test.ts +60 -0
  48. package/src/ext-validation-regression.test.ts +131 -0
  49. package/src/external-schedule-edits-http.test.ts +321 -0
  50. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  51. package/src/external-schedule-edits.ts +327 -0
  52. package/src/file-download-authz-dev-open.test.ts +135 -0
  53. package/src/file-download-authz.test.ts +404 -0
  54. package/src/file-download-date-bucket.test.ts +436 -0
  55. package/src/file-network-scope-dev-open.test.ts +97 -0
  56. package/src/file-network-scope.test.ts +732 -0
  57. package/src/health-redaction.test.ts +642 -0
  58. package/src/http-gzip.test.ts +94 -0
  59. package/src/http-gzip.ts +110 -0
  60. package/src/hub-timestamp.test.ts +38 -0
  61. package/src/hub-timestamp.ts +27 -0
  62. package/src/human-dm.ts +215 -0
  63. package/src/inbox-count-sse.test.ts +310 -0
  64. package/src/inbox-count.ts +29 -0
  65. package/src/index.ts +15 -2331
  66. package/src/lifecycle-guard.ts +50 -0
  67. package/src/mcp-route-label-http.test.ts +120 -0
  68. package/src/mcp-write-network-resolution.test.ts +429 -0
  69. package/src/member-audit-network-id-http.test.ts +93 -0
  70. package/src/member-presence-http.test.ts +206 -0
  71. package/src/messages-ack-by-agent-http.test.ts +142 -0
  72. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  73. package/src/migrate-sqlite-to-pg.ts +263 -0
  74. package/src/network-name-http.test.ts +75 -0
  75. package/src/network-scope.ts +224 -0
  76. package/src/node-attrs-validate.ts +88 -0
  77. package/src/node-attrs.test.ts +217 -0
  78. package/src/node-create-request-status-http.test.ts +92 -0
  79. package/src/node-files-transport.test.ts +344 -0
  80. package/src/node-id-alias.test.ts +188 -0
  81. package/src/node-lifecycle-controllable.test.ts +64 -0
  82. package/src/node-lifecycle-controllable.ts +51 -0
  83. package/src/node-logs-transport.test.ts +263 -0
  84. package/src/node-request-retention.test.ts +281 -0
  85. package/src/node-request-retention.ts +120 -0
  86. package/src/node-skills-transport.test.ts +209 -0
  87. package/src/node-token-privilege-http.test.ts +149 -0
  88. package/src/observer-avatar-http.test.ts +429 -0
  89. package/src/observer-push.test.ts +252 -0
  90. package/src/package-version-consistency.test.ts +25 -0
  91. package/src/peer-reply-atomic.test.ts +455 -0
  92. package/src/peer-reply-capability.test.ts +70 -0
  93. package/src/pg-worker.ts +87 -0
  94. package/src/project-viewer-can-http.test.ts +114 -0
  95. package/src/push.ts +284 -7
  96. package/src/redact-tokens.test.ts +65 -0
  97. package/src/redact-tokens.ts +40 -0
  98. package/src/rename.ts +7 -2
  99. package/src/reply-target-derivation.test.ts +96 -0
  100. package/src/report-status-host-schema.test.ts +211 -0
  101. package/src/report-status-resume-id-handover.test.ts +150 -0
  102. package/src/require-explicit-test-db.ts +9 -0
  103. package/src/requirement-events-http.test.ts +257 -0
  104. package/src/requirement-events.test.ts +45 -0
  105. package/src/requirement-events.ts +143 -0
  106. package/src/requirement-notify.ts +134 -0
  107. package/src/requirement-participant-notify-http.test.ts +309 -0
  108. package/src/requirement-tag-ops-http.test.ts +266 -0
  109. package/src/requirement-tags.ts +89 -0
  110. package/src/requirements-completed-at-migration.test.ts +70 -0
  111. package/src/requirements-etag-http.test.ts +91 -0
  112. package/src/requirements-http.test.ts +709 -0
  113. package/src/requirements-list-slim-http.test.ts +315 -0
  114. package/src/requirements-mcp.test.ts +163 -0
  115. package/src/requirements-migrate.ts +225 -0
  116. package/src/requirements-people-http.test.ts +114 -0
  117. package/src/requirements-priority-lowest.test.ts +114 -0
  118. package/src/requirements-search-http.test.ts +182 -0
  119. package/src/requirements-search.test.ts +32 -0
  120. package/src/requirements-search.ts +138 -0
  121. package/src/requirements-seq-http.test.ts +193 -0
  122. package/src/requirements-seq-migration.test.ts +63 -0
  123. package/src/requirements-start-date.test.ts +125 -0
  124. package/src/requirements-stats-http.test.ts +288 -0
  125. package/src/requirements-stats.ts +164 -0
  126. package/src/requirements-upgrade-e2e.test.ts +126 -0
  127. package/src/requirements-upgrade-migration.test.ts +98 -0
  128. package/src/requirements.ts +1443 -0
  129. package/src/resolve-port.test.ts +57 -0
  130. package/src/resolve-port.ts +44 -0
  131. package/src/response-charset.test.ts +130 -0
  132. package/src/rest-explicit-columns-http.test.ts +216 -0
  133. package/src/rest-identity.test.ts +129 -0
  134. package/src/rest-identity.ts +100 -0
  135. package/src/rest-projections.ts +68 -0
  136. package/src/rest-write-network-resolution.test.ts +133 -0
  137. package/src/restricted-files.ts +73 -0
  138. package/src/retention.ts +15 -3
  139. package/src/route-callers-http.test.ts +120 -0
  140. package/src/route-timing.test.ts +211 -0
  141. package/src/route-timing.ts +258 -0
  142. package/src/rules-file-duplicate-alias.test.ts +234 -0
  143. package/src/rules-file-session-target.test.ts +207 -0
  144. package/src/rules-file-transport.test.ts +237 -0
  145. package/src/runtime-label.test.ts +22 -0
  146. package/src/runtime-label.ts +21 -0
  147. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  148. package/src/scheduled-reply-unread-http.test.ts +212 -0
  149. package/src/scheduled-run-terminal.test.ts +268 -0
  150. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  151. package/src/scheduled-tasks-http.test.ts +638 -0
  152. package/src/scheduled-tasks.ts +597 -0
  153. package/src/send-dedup-request-id.test.ts +229 -0
  154. package/src/send-reply-agent-warning.test.ts +202 -0
  155. package/src/send-reply-alias-optional.test.ts +131 -0
  156. package/src/send-reply-attachments.test.ts +458 -0
  157. package/src/send_dedup.test.ts +54 -0
  158. package/src/send_dedup.ts +52 -6
  159. package/src/serve-error.test.ts +38 -0
  160. package/src/serve-error.ts +27 -0
  161. package/src/server.ts +4522 -0
  162. package/src/shared/external-schedule-contract.ts +60 -0
  163. package/src/side-thread-command-transport.test.ts +312 -0
  164. package/src/side-thread-command-transport.ts +318 -0
  165. package/src/side-thread-http-integration.test.ts +233 -0
  166. package/src/side-thread-http.ts +504 -0
  167. package/src/side-thread-production.test.ts +72 -0
  168. package/src/side-thread-production.ts +118 -0
  169. package/src/side-thread.test.ts +1067 -0
  170. package/src/side-thread.ts +2499 -0
  171. package/src/skillhub-http.test.ts +108 -0
  172. package/src/sqlite-close-checkpoint.test.ts +78 -0
  173. package/src/start-node.test.ts +109 -0
  174. package/src/status-alias-filter-http.test.ts +106 -0
  175. package/src/status-node-id-filter-http.test.ts +161 -0
  176. package/src/stop-delete-node.test.ts +1164 -0
  177. package/src/task-access-http.test.ts +379 -0
  178. package/src/task-access.ts +317 -0
  179. package/src/task-auth-origin-http.test.ts +91 -0
  180. package/src/task-auth-origin.test.ts +24 -0
  181. package/src/task-auth-origin.ts +7 -0
  182. package/src/task-consumption.test.ts +482 -0
  183. package/src/task-diagnostic-http.test.ts +149 -0
  184. package/src/task-diagnostic.test.ts +115 -0
  185. package/src/task-diagnostic.ts +118 -0
  186. package/src/task-idempotency-mcp.test.ts +142 -0
  187. package/src/task-idempotency.test.ts +60 -0
  188. package/src/task-idempotency.ts +75 -0
  189. package/src/task-lifecycle-watcher.test.ts +204 -0
  190. package/src/task-lifecycle-watcher.ts +49 -0
  191. package/src/task-network-resolution-http.test.ts +141 -0
  192. package/src/tasks-pagination-http.test.ts +141 -0
  193. package/src/tools-list-json-schema.test.ts +66 -0
  194. package/src/tools.ts +3534 -343
  195. package/src/update-provider.test.ts +65 -0
  196. package/src/upload-early-reject-guard.test.ts +155 -0
  197. package/src/uploads-http.test.ts +97 -15
  198. package/src/uploads.test.ts +66 -0
  199. package/src/uploads.ts +138 -5
  200. package/src/user-inbox-by-agent-http.test.ts +161 -0
  201. package/src/user-inbox-netid-notnull.test.ts +122 -0
  202. package/src/user-inbox-read-http.test.ts +223 -0
  203. 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
+ });