@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.50

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 (137) hide show
  1. package/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
  2. package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
  3. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
  4. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
  5. package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
  7. package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
  8. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  19. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  20. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  21. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  24. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  25. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  26. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  27. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
  28. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
  29. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  30. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  31. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  32. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  33. package/.tsbuild/test/observability-signals.test.d.ts +2 -0
  34. package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
  35. package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
  36. package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
  37. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  38. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  39. package/.tsbuild/test/snapshot-writer.d.ts +18 -0
  40. package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
  41. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  42. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  43. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  44. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  45. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  46. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  48. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  49. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  50. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  51. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  52. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  53. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  54. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  55. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  56. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  57. package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
  58. package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
  59. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
  60. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
  61. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  62. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  63. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  64. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  65. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  66. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  67. package/.tsbuild/vitest.config.d.ts.map +1 -1
  68. package/Auth.md +94 -0
  69. package/CHANGELOG.md +509 -0
  70. package/README.md +35 -0
  71. package/dist/index.d.mts +2 -1
  72. package/dist/index.mjs +48 -49
  73. package/dist/index.mjs.map +1 -1
  74. package/dist/install-packages.d.mts +2 -1
  75. package/dist/install-packages.mjs +2 -2
  76. package/dist/install-packages.mjs.map +1 -1
  77. package/dist/migrate.d.mts +2 -1
  78. package/dist/migrate.mjs +4 -3
  79. package/dist/migrate.mjs.map +1 -1
  80. package/dist/server-Zz7SqTwO.mjs +3335 -0
  81. package/dist/server-Zz7SqTwO.mjs.map +1 -0
  82. package/dist/server.d.mts +154 -30
  83. package/dist/server.d.mts.map +1 -1
  84. package/dist/server.mjs +4 -5
  85. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  86. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  87. package/dist/utils.d.mts +4 -5
  88. package/dist/utils.d.mts.map +1 -1
  89. package/dist/utils.mjs +3 -3
  90. package/package.json +22 -18
  91. package/test/attachment-reference-read-model.test.ts +6 -1
  92. package/test/attachments/auth.test.ts +85 -0
  93. package/test/attachments/byte-grants.test.ts +290 -0
  94. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  95. package/test/attachments/download-target.test.ts +201 -32
  96. package/test/attachments/index.test.ts +34 -3
  97. package/test/attachments/reserve-metadata-parity.test.ts +96 -0
  98. package/test/attachments/routes-integration.test.ts +60 -4
  99. package/test/attachments/routes.test.ts +166 -23
  100. package/test/attachments/service-config.test.ts +13 -3
  101. package/test/attachments/url-signer.test.ts +105 -0
  102. package/test/boot-create-signature-policy.test.ts +108 -0
  103. package/test/boot-mcp-enabled.test.ts +121 -0
  104. package/test/boot-privacy.test.ts +243 -0
  105. package/test/boot-unsupported-stored-documents.test.ts +213 -0
  106. package/test/boot-without-renown.test.ts +81 -0
  107. package/test/boot-without-require-signatures.test.ts +74 -0
  108. package/test/boot-worker-trust-policy.test.ts +90 -0
  109. package/test/builder-defaults.test.ts +80 -0
  110. package/test/create-signature-policy.test.ts +69 -0
  111. package/test/durable-node-fs-pg16.test.ts +80 -0
  112. package/test/fatal-shutdown.test.ts +165 -0
  113. package/test/mcp-flag.test.ts +56 -0
  114. package/test/observability-signals.test.ts +131 -0
  115. package/test/pglite-dialect.test.ts +159 -2
  116. package/test/pglite-preflight.test.ts +313 -0
  117. package/test/pglite-version.test.ts +9 -1
  118. package/test/renown-trust-policy.test.ts +315 -0
  119. package/test/snapshot-writer.ts +94 -0
  120. package/test/unsupported-stored-documents.test.ts +72 -0
  121. package/test/worker-pool.test.ts +27 -1
  122. package/test/workflow/harness.ts +289 -0
  123. package/test/workflow/oauth.test.ts +214 -0
  124. package/test/workflow/resolvers.test.ts +322 -0
  125. package/test/workflow/webhook-dedupe.test.ts +295 -0
  126. package/test/workflow/webhook-signed.test.ts +509 -0
  127. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  128. package/test/workflow/webhook-unsigned.test.ts +345 -0
  129. package/test/workflow-reactor-access.test.ts +384 -0
  130. package/test/workflow-reactor-enforcement.test.ts +805 -0
  131. package/test/workflow-read-gate.test.ts +210 -0
  132. package/test/workflow-runtime.test.ts +572 -0
  133. package/tsconfig.json +9 -3
  134. package/tsdown.config.ts +3 -1
  135. package/dist/server-CiKkVE6g.mjs +0 -1555
  136. package/dist/server-CiKkVE6g.mjs.map +0 -1
  137. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -9,15 +9,24 @@ import type {
9
9
  IAttachmentAccessService,
10
10
  } from "@powerhousedao/reactor-api";
11
11
  import type { IncomingMessage, ServerResponse } from "node:http";
12
- import { describe, expect, it, vi } from "vitest";
12
+ import { afterEach, describe, expect, it, vi } from "vitest";
13
13
  import type { AttachmentActorContext } from "../../src/attachments/auth.js";
14
14
  import { registerAttachmentRoutes } from "../../src/attachments/index.js";
15
15
  import { makeDownloadTargetHandler } from "../../src/attachments/routes.js";
16
+ import { AttachmentUrlSigner } from "../../src/attachments/url-signer.js";
16
17
 
17
18
  const HASH = "a".repeat(64) as AttachmentHash;
18
19
  const REF = `attachment://v1:${HASH}`;
19
20
  const DOC_ID = "doc-1";
20
21
  const EXPIRES = "2026-07-23T00:00:00.000Z";
22
+ const NOW_MS = Date.parse("2026-07-22T00:00:00.000Z");
23
+ const SIGNER = new AttachmentUrlSigner("s".repeat(32), () => NOW_MS);
24
+
25
+ function signedUrlParts(body: string) {
26
+ const target = JSON.parse(body) as { url: string; expiresAtUtc: string };
27
+ const url = new URL(target.url);
28
+ return { target, url, params: url.searchParams };
29
+ }
21
30
 
22
31
  const ACTOR: AttachmentActorContext = {
23
32
  user: {
@@ -82,7 +91,13 @@ function makeAccess(result: AttachmentAccessResult): {
82
91
  spy: ReturnType<typeof vi.fn>;
83
92
  } {
84
93
  const spy = vi.fn().mockResolvedValue(result);
85
- return { access: { canReadAttachment: spy }, spy };
94
+ return {
95
+ access: {
96
+ canReadAttachment: spy,
97
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
98
+ },
99
+ spy,
100
+ };
86
101
  }
87
102
 
88
103
  function makeAttachments(opts: {
@@ -106,27 +121,118 @@ const ALLOWED: AttachmentAccessResult = {
106
121
  };
107
122
 
108
123
  describe("makeDownloadTargetHandler", () => {
109
- it("returns a switchboard target for filesystem with no-store", async () => {
124
+ it("returns a signed switchboard target for filesystem with no-store", async () => {
110
125
  const { access, spy } = makeAccess(ALLOWED);
111
126
  const attachments = makeAttachments({});
112
- const handler = makeDownloadTargetHandler(attachments, access);
127
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
113
128
  const res = makeRes();
114
129
 
115
130
  await handler(makeReq({}), res, undefined, ACTOR);
116
131
 
117
132
  expect(res.statusCode).toBe(200);
118
133
  expect(res._headers["cache-control"]).toBe("no-store");
119
- expect(JSON.parse(res._body)).toEqual({
134
+ const { target, url, params } = signedUrlParts(res._body);
135
+ expect(target).toMatchObject({
120
136
  kind: "switchboard",
121
137
  method: "GET",
122
- url: `http://sb.example.com/attachments/${HASH}`,
123
138
  headers: {},
139
+ expiresAtUtc: new Date(NOW_MS + 300_000).toISOString(),
124
140
  });
141
+ expect(`${url.origin}${url.pathname}`).toBe(
142
+ `http://sb.example.com/attachments/${HASH}`,
143
+ );
144
+ expect(params.get("documentId")).toBe(DOC_ID);
145
+ expect(
146
+ SIGNER.verify(
147
+ HASH,
148
+ DOC_ID,
149
+ params.get("expires")!,
150
+ params.get("signature")!,
151
+ ),
152
+ ).toBe(true);
125
153
  expect(spy).toHaveBeenCalledWith({
126
154
  documentId: DOC_ID,
127
155
  attachmentRef: REF,
128
156
  userAddress: "0xverified",
157
+ appKey: "did:key:zAppTest",
158
+ });
159
+ });
160
+
161
+ it("signs for the requested expiresIn, clamped to the download-target ceiling", async () => {
162
+ const { access } = makeAccess(ALLOWED);
163
+ const handler = makeDownloadTargetHandler(
164
+ makeAttachments({}),
165
+ access,
166
+ SIGNER,
167
+ );
168
+
169
+ for (const [requested, granted] of [
170
+ [60, 60],
171
+ [3600, 300],
172
+ [99999999, 300],
173
+ ]) {
174
+ const res = makeRes();
175
+ await handler(
176
+ makeReq({
177
+ url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=${requested}`,
178
+ }),
179
+ res,
180
+ undefined,
181
+ ACTOR,
182
+ );
183
+ expect(signedUrlParts(res._body).target.expiresAtUtc).toBe(
184
+ new Date(NOW_MS + granted * 1000).toISOString(),
185
+ );
186
+ }
187
+ });
188
+
189
+ it("signs the canonical document id the access decision resolved", async () => {
190
+ const { access } = makeAccess({ ...ALLOWED, documentId: "canon" as never });
191
+ const handler = makeDownloadTargetHandler(
192
+ makeAttachments({}),
193
+ access,
194
+ SIGNER,
195
+ );
196
+ const res = makeRes();
197
+
198
+ await handler(makeReq({}), res, undefined, ACTOR);
199
+
200
+ expect(signedUrlParts(res._body).params.get("documentId")).toBe("canon");
201
+ });
202
+
203
+ it("fails closed with 503 on filesystem when no signer is configured", async () => {
204
+ const { access } = makeAccess(ALLOWED);
205
+ const handler = makeDownloadTargetHandler(
206
+ makeAttachments({}),
207
+ access,
208
+ null,
209
+ );
210
+ const res = makeRes();
211
+
212
+ await handler(makeReq({}), res, undefined, ACTOR);
213
+
214
+ expect(res.statusCode).toBe(503);
215
+ expect(res._body).not.toContain("/attachments/");
216
+ });
217
+
218
+ it("needs no signer for S3 presigned targets", async () => {
219
+ const { access } = makeAccess(ALLOWED);
220
+ const prepareDownloadTarget = vi.fn().mockResolvedValue({
221
+ kind: "presigned-get",
222
+ method: "GET",
223
+ url: "https://bucket.example.com/attachments/aa?sig=1",
224
+ headers: {},
225
+ expiresAtUtc: EXPIRES,
226
+ });
227
+ const attachments = makeAttachments({
228
+ backend: { kind: "s3", prepareDownloadTarget } as never,
129
229
  });
230
+ const handler = makeDownloadTargetHandler(attachments, access, null);
231
+ const res = makeRes();
232
+
233
+ await handler(makeReq({}), res, undefined, ACTOR);
234
+
235
+ expect(res.statusCode).toBe(200);
130
236
  });
131
237
 
132
238
  it("returns the backend presigned-get target in S3 mode", async () => {
@@ -141,14 +247,14 @@ describe("makeDownloadTargetHandler", () => {
141
247
  const attachments = makeAttachments({
142
248
  backend: { kind: "s3", prepareDownloadTarget } as never,
143
249
  });
144
- const handler = makeDownloadTargetHandler(attachments, access);
250
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
145
251
  const res = makeRes();
146
252
 
147
253
  await handler(makeReq({}), res, undefined, ACTOR);
148
254
 
149
255
  expect(res.statusCode).toBe(200);
150
256
  expect(JSON.parse(res._body)).toMatchObject({ kind: "presigned-get" });
151
- expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, undefined);
257
+ expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
152
258
  });
153
259
 
154
260
  it("passes a requested expiresIn through to the backend presigner", async () => {
@@ -163,12 +269,12 @@ describe("makeDownloadTargetHandler", () => {
163
269
  const attachments = makeAttachments({
164
270
  backend: { kind: "s3", prepareDownloadTarget } as never,
165
271
  });
166
- const handler = makeDownloadTargetHandler(attachments, access);
272
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
167
273
  const res = makeRes();
168
274
 
169
275
  await handler(
170
276
  makeReq({
171
- url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=3600`,
277
+ url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=60`,
172
278
  }),
173
279
  res,
174
280
  undefined,
@@ -176,10 +282,10 @@ describe("makeDownloadTargetHandler", () => {
176
282
  );
177
283
 
178
284
  expect(res.statusCode).toBe(200);
179
- expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 3600);
285
+ expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 60);
180
286
  });
181
287
 
182
- it("clamps expiresIn to the 7-day presigning ceiling", async () => {
288
+ it("clamps a requested expiresIn to the ceiling", async () => {
183
289
  const { access } = makeAccess(ALLOWED);
184
290
  const prepareDownloadTarget = vi.fn().mockResolvedValue({
185
291
  kind: "presigned-get",
@@ -191,7 +297,7 @@ describe("makeDownloadTargetHandler", () => {
191
297
  const attachments = makeAttachments({
192
298
  backend: { kind: "s3", prepareDownloadTarget } as never,
193
299
  });
194
- const handler = makeDownloadTargetHandler(attachments, access);
300
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
195
301
  const res = makeRes();
196
302
 
197
303
  await handler(
@@ -203,10 +309,10 @@ describe("makeDownloadTargetHandler", () => {
203
309
  ACTOR,
204
310
  );
205
311
 
206
- expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 604800);
312
+ expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
207
313
  });
208
314
 
209
- it("omits the TTL override when expiresIn is absent", async () => {
315
+ it("applies the ceiling when expiresIn is absent, rather than the backend default", async () => {
210
316
  const { access } = makeAccess(ALLOWED);
211
317
  const prepareDownloadTarget = vi.fn().mockResolvedValue({
212
318
  kind: "presigned-get",
@@ -218,11 +324,73 @@ describe("makeDownloadTargetHandler", () => {
218
324
  const attachments = makeAttachments({
219
325
  backend: { kind: "s3", prepareDownloadTarget } as never,
220
326
  });
221
- const handler = makeDownloadTargetHandler(attachments, access);
327
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
222
328
 
223
329
  await handler(makeReq({}), makeRes(), undefined, ACTOR);
224
330
 
225
- expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, undefined);
331
+ expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
332
+ });
333
+
334
+ describe("ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS", () => {
335
+ const ORIGINAL = process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
336
+
337
+ afterEach(() => {
338
+ if (ORIGINAL === undefined) {
339
+ delete process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
340
+ } else {
341
+ process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS = ORIGINAL;
342
+ }
343
+ });
344
+
345
+ async function ttlFor(configured: string | undefined, query = "") {
346
+ if (configured === undefined) {
347
+ delete process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
348
+ } else {
349
+ process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS = configured;
350
+ }
351
+ const { access } = makeAccess(ALLOWED);
352
+ const prepareDownloadTarget = vi.fn().mockResolvedValue({
353
+ kind: "presigned-get",
354
+ method: "GET",
355
+ url: "https://bucket.example.com/attachments/aa?sig=1",
356
+ headers: {},
357
+ expiresAtUtc: EXPIRES,
358
+ });
359
+ const attachments = makeAttachments({
360
+ backend: { kind: "s3", prepareDownloadTarget } as never,
361
+ });
362
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
363
+ await handler(
364
+ makeReq({
365
+ url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}${query}`,
366
+ }),
367
+ makeRes(),
368
+ undefined,
369
+ ACTOR,
370
+ );
371
+ return (prepareDownloadTarget.mock.calls[0] as unknown[])[1];
372
+ }
373
+
374
+ it("raises the ceiling when a deployment configures one", async () => {
375
+ await expect(ttlFor("1800")).resolves.toBe(1800);
376
+ });
377
+
378
+ it("never lets a configured ceiling exceed the signing maximum", async () => {
379
+ await expect(ttlFor("99999999")).resolves.toBe(604800);
380
+ });
381
+
382
+ it.each(["not-a-number", "0", "-5", "12.5"])(
383
+ "falls back to the default, not the maximum, on %s",
384
+ async (configured) => {
385
+ // A typo must narrow nothing and widen nothing: falling back to the
386
+ // signing ceiling would turn a misconfiguration into a week-long URL.
387
+ await expect(ttlFor(configured)).resolves.toBe(300);
388
+ },
389
+ );
390
+
391
+ it("still clamps a caller's request to the configured ceiling", async () => {
392
+ await expect(ttlFor("1800", "&expiresIn=99999")).resolves.toBe(1800);
393
+ });
226
394
  });
227
395
 
228
396
  it.each([
@@ -234,7 +402,7 @@ describe("makeDownloadTargetHandler", () => {
234
402
  ])("rejects a %s expiresIn with 400 before authorization", async (_, qs) => {
235
403
  const { access, spy } = makeAccess(ALLOWED);
236
404
  const attachments = makeAttachments({});
237
- const handler = makeDownloadTargetHandler(attachments, access);
405
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
238
406
  const res = makeRes();
239
407
 
240
408
  await handler(
@@ -253,7 +421,7 @@ describe("makeDownloadTargetHandler", () => {
253
421
  it("normalizes an uppercase path hash before authorization and lookup", async () => {
254
422
  const { access, spy } = makeAccess(ALLOWED);
255
423
  const attachments = makeAttachments({});
256
- const handler = makeDownloadTargetHandler(attachments, access);
424
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
257
425
  const res = makeRes();
258
426
 
259
427
  await handler(makeReq({ hash: HASH.toUpperCase() }), res, undefined, ACTOR);
@@ -268,7 +436,7 @@ describe("makeDownloadTargetHandler", () => {
268
436
  it("rejects an invalid hash before any access or storage call", async () => {
269
437
  const { access, spy } = makeAccess(ALLOWED);
270
438
  const attachments = makeAttachments({});
271
- const handler = makeDownloadTargetHandler(attachments, access);
439
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
272
440
  const res = makeRes();
273
441
 
274
442
  await handler(makeReq({ hash: "nothex" }), res, undefined, ACTOR);
@@ -292,7 +460,7 @@ describe("makeDownloadTargetHandler", () => {
292
460
  ])("rejects a %s documentId before authorization", async (_, url) => {
293
461
  const { access, spy } = makeAccess(ALLOWED);
294
462
  const attachments = makeAttachments({});
295
- const handler = makeDownloadTargetHandler(attachments, access);
463
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
296
464
  const res = makeRes();
297
465
 
298
466
  await handler(makeReq({ url }), res, undefined, ACTOR);
@@ -307,7 +475,7 @@ describe("makeDownloadTargetHandler", () => {
307
475
  const attachments = makeAttachments({
308
476
  backend: { kind: "s3", prepareDownloadTarget } as never,
309
477
  });
310
- const handler = makeDownloadTargetHandler(attachments, access);
478
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
311
479
  const res = makeRes();
312
480
 
313
481
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -321,7 +489,7 @@ describe("makeDownloadTargetHandler", () => {
321
489
  it("maps projection-unavailable to a generic non-cacheable 503 before storage", async () => {
322
490
  const { access } = makeAccess({ kind: "projection-unavailable" });
323
491
  const attachments = makeAttachments({});
324
- const handler = makeDownloadTargetHandler(attachments, access);
492
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
325
493
  const res = makeRes();
326
494
 
327
495
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -337,7 +505,7 @@ describe("makeDownloadTargetHandler", () => {
337
505
  const { AttachmentNotFound } =
338
506
  await import("@powerhousedao/reactor-attachments");
339
507
  attachments.statSpy.mockRejectedValue(new AttachmentNotFound(HASH));
340
- const handler = makeDownloadTargetHandler(attachments, access);
508
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
341
509
  const res = makeRes();
342
510
 
343
511
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -351,7 +519,7 @@ describe("makeDownloadTargetHandler", () => {
351
519
  const attachments = makeAttachments({
352
520
  stat: () => Promise.resolve({ ...AVAILABLE_HEADER, status: "pending" }),
353
521
  });
354
- const handler = makeDownloadTargetHandler(attachments, access);
522
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
355
523
  const res = makeRes();
356
524
 
357
525
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -371,7 +539,7 @@ describe("makeDownloadTargetHandler", () => {
371
539
  const attachments = makeAttachments({
372
540
  backend: { kind: "s3", prepareDownloadTarget } as never,
373
541
  });
374
- const handler = makeDownloadTargetHandler(attachments, access);
542
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
375
543
  const res = makeRes();
376
544
 
377
545
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -384,7 +552,7 @@ describe("makeDownloadTargetHandler", () => {
384
552
  it("passes an anonymous actor as an undefined address (OPEN mode)", async () => {
385
553
  const { access, spy } = makeAccess(ALLOWED);
386
554
  const attachments = makeAttachments({});
387
- const handler = makeDownloadTargetHandler(attachments, access);
555
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
388
556
  const res = makeRes();
389
557
 
390
558
  await handler(makeReq({}), res, undefined, {
@@ -400,7 +568,7 @@ describe("makeDownloadTargetHandler", () => {
400
568
  it("honours x-forwarded proto/host when building the switchboard target", async () => {
401
569
  const { access } = makeAccess(ALLOWED);
402
570
  const attachments = makeAttachments({});
403
- const handler = makeDownloadTargetHandler(attachments, access);
571
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
404
572
  const res = makeRes();
405
573
 
406
574
  await handler(
@@ -415,9 +583,10 @@ describe("makeDownloadTargetHandler", () => {
415
583
  ACTOR,
416
584
  );
417
585
 
418
- expect(JSON.parse(res._body)).toMatchObject({
419
- url: `https://public.example.com/attachments/${HASH}`,
420
- });
586
+ const { url } = signedUrlParts(res._body);
587
+ expect(`${url.origin}${url.pathname}`).toBe(
588
+ `https://public.example.com/attachments/${HASH}`,
589
+ );
421
590
  });
422
591
  });
423
592
 
@@ -435,7 +604,7 @@ describe("registerAttachmentRoutes inventory", () => {
435
604
  attachmentAccess: { canReadAttachment: vi.fn() },
436
605
  } as unknown as API;
437
606
 
438
- registerAttachmentRoutes(api);
607
+ registerAttachmentRoutes(api, { urlSigner: null });
439
608
 
440
609
  expect(captured).toEqual([
441
610
  { method: "POST", path: "/attachments/reservations" },
@@ -13,8 +13,16 @@ type Captured = {
13
13
  ) => void | Promise<void>;
14
14
  };
15
15
 
16
- function makeFakeApi(authService: AuthService | undefined): {
17
- api: Pick<API, "httpAdapter" | "authService">;
16
+ type FakeApi = Pick<
17
+ API,
18
+ "httpAdapter" | "authService" | "requireAuthenticatedCaller"
19
+ >;
20
+
21
+ function makeFakeApi(
22
+ authService: AuthService | undefined,
23
+ requireAuthenticatedCaller = false,
24
+ ): {
25
+ api: FakeApi;
18
26
  captured: Captured[];
19
27
  } {
20
28
  const captured: Captured[] = [];
@@ -29,7 +37,8 @@ function makeFakeApi(authService: AuthService | undefined): {
29
37
  },
30
38
  },
31
39
  authService,
32
- } as unknown as Pick<API, "httpAdapter" | "authService">;
40
+ requireAuthenticatedCaller,
41
+ } as unknown as FakeApi;
33
42
  return { api, captured };
34
43
  }
35
44
 
@@ -138,6 +147,28 @@ describe("mountAuthenticatedNodeRoute", () => {
138
147
  });
139
148
  });
140
149
 
150
+ it("carries the deployment's authenticated-caller floor onto the route, over allowAnonymous", async () => {
151
+ // auth_enabled false is the shape a deployment running the document
152
+ // policies has: the boundary policy is OPEN, and this floor is what keeps
153
+ // an anonymous caller off the attachment routes.
154
+ const verifyBearer = vi.fn(() =>
155
+ Promise.resolve({ user: undefined, admins: [], auth_enabled: false }),
156
+ );
157
+ const authService = { verifyBearer } as unknown as AuthService;
158
+ const { api, captured } = makeFakeApi(authService, true);
159
+ const inner = vi.fn();
160
+
161
+ mountAuthenticatedNodeRoute(api, "GET", "/x", inner, {
162
+ allowAnonymous: true,
163
+ });
164
+
165
+ const res = makeRes();
166
+ await captured[0].handler(makeReq(), res);
167
+
168
+ expect(res.statusCode).toBe(401);
169
+ expect(inner).not.toHaveBeenCalled();
170
+ });
171
+
141
172
  it("mounts a wrapper that forwards the anonymous actor when authService is undefined", async () => {
142
173
  const { api, captured } = makeFakeApi(undefined);
143
174
  const inner = vi.fn();
@@ -0,0 +1,96 @@
1
+ import {
2
+ InvalidAttachmentMetadata,
3
+ validateReserveMetadata,
4
+ type ReserveMetadata,
5
+ } from "@powerhousedao/reactor-attachments";
6
+ import { describe, expect, it } from "vitest";
7
+ import { parseReserveOptions } from "../../src/attachments/routes.js";
8
+
9
+ type Case = [name: string, meta: Record<string, unknown>];
10
+
11
+ const base = { mimeType: "text/plain", fileName: "note.txt" };
12
+
13
+ // The download route's setHeader throws on each of these.
14
+ const headerBreaking: Case[] = [
15
+ ["CRLF before a parameter", { ...base, mimeType: "text/plain\r\n;a=b" }],
16
+ ["LF after a ;", { ...base, mimeType: "text/plain;\na=b" }],
17
+ ["a control char quoted", { ...base, mimeType: 'text/plain; a="\x01"' }],
18
+ ["a DEL quoted", { ...base, mimeType: 'text/plain; a="\x7f"' }],
19
+ ["a char above 0xFF quoted", { ...base, mimeType: 'text/plain;a="\u0100"' }],
20
+ ];
21
+
22
+ const cases: Case[] = [
23
+ ["plain metadata", base],
24
+ ["an extension", { ...base, extension: "txt" }],
25
+ ["a null extension", { ...base, extension: null }],
26
+ ["a parameter", { ...base, mimeType: "text/plain; charset=utf-8" }],
27
+ ["a quoted parameter", { ...base, mimeType: 'text/plain; a="b c"' }],
28
+ ["a unicode file name", { ...base, fileName: "résumé.pdf" }],
29
+ ["a 255-char mimeType", { ...base, mimeType: `a/${"b".repeat(253)}` }],
30
+ ["a 256-char mimeType", { ...base, mimeType: `a/${"b".repeat(254)}` }],
31
+ ["a 255-char fileName", { ...base, fileName: "f".repeat(255) }],
32
+ ["a 256-char fileName", { ...base, fileName: "f".repeat(256) }],
33
+ ["an empty mimeType", { ...base, mimeType: "" }],
34
+ ["an empty fileName", { ...base, fileName: "" }],
35
+ ["an empty extension", { ...base, extension: "" }],
36
+ ["a mimeType with no slash", { ...base, mimeType: "text" }],
37
+ ["a mimeType with no subtype", { ...base, mimeType: "text/" }],
38
+ ["a mimeType with no type", { ...base, mimeType: "/plain" }],
39
+ ["a mimeType with two slashes", { ...base, mimeType: "text/plain/x" }],
40
+ ["a mimeType with a space", { ...base, mimeType: "text plain" }],
41
+ ["a mimeType with a dangling ;", { ...base, mimeType: "text/plain;" }],
42
+ ["a non-string mimeType", { ...base, mimeType: 5 }],
43
+ ["a trailing CRLF in mimeType", { ...base, mimeType: "text/plain\r\n" }],
44
+ [
45
+ "an injected header in mimeType",
46
+ { ...base, mimeType: "text/plain\nX-Injected: 1" },
47
+ ],
48
+ ["a NUL in mimeType", { ...base, mimeType: "text/pl\x00ain" }],
49
+ ...headerBreaking,
50
+ ["a NUL in fileName", { ...base, fileName: "a\x00b" }],
51
+ ["a LF in fileName", { ...base, fileName: "a\nb" }],
52
+ ["a CR in fileName", { ...base, fileName: "a\rb" }],
53
+ ["a tab in fileName", { ...base, fileName: "a\tb" }],
54
+ ["a DEL in fileName", { ...base, fileName: "a\x7fb" }],
55
+ ["a non-string fileName", { ...base, fileName: null }],
56
+ ["a slash in extension", { ...base, extension: "a/b" }],
57
+ ["a backslash in extension", { ...base, extension: "a\\b" }],
58
+ ["a bare slash extension", { ...base, extension: "/" }],
59
+ ["a non-string extension", { ...base, extension: 5 }],
60
+ ];
61
+
62
+ function routeAccepts(meta: Record<string, unknown>): boolean {
63
+ return parseReserveOptions(meta) !== null;
64
+ }
65
+
66
+ function validatorAccepts(meta: Record<string, unknown>): boolean {
67
+ try {
68
+ validateReserveMetadata(meta as ReserveMetadata);
69
+ return true;
70
+ } catch (err) {
71
+ if (err instanceof InvalidAttachmentMetadata) return false;
72
+ throw err;
73
+ }
74
+ }
75
+
76
+ describe("reserve metadata parity between the route and validateReserveMetadata", () => {
77
+ it("covers both accepted and rejected metadata", () => {
78
+ const verdicts = cases.map(([, meta]) => routeAccepts(meta));
79
+ expect(verdicts).toContain(true);
80
+ expect(verdicts).toContain(false);
81
+ });
82
+
83
+ it.each(cases)("agrees on %s", (_name, meta) => {
84
+ expect(validatorAccepts(meta)).toBe(routeAccepts(meta));
85
+ });
86
+ });
87
+
88
+ describe("header-breaking mimeType values", () => {
89
+ it.each(headerBreaking)("the route rejects %s", (_name, meta) => {
90
+ expect(routeAccepts(meta)).toBe(false);
91
+ });
92
+
93
+ it.each(headerBreaking)("the validator rejects %s", (_name, meta) => {
94
+ expect(validatorAccepts(meta)).toBe(false);
95
+ });
96
+ });
@@ -1,11 +1,12 @@
1
1
  import { PGlite } from "@electric-sql/pglite";
2
2
  import {
3
+ AttachmentAlreadyExists,
3
4
  AttachmentBuilder,
4
5
  S3AttachmentBackend,
5
6
  type AttachmentBuildResult,
6
7
  createRemoteAttachmentService,
7
8
  } from "@powerhousedao/reactor-attachments";
8
- import type { API } from "@powerhousedao/reactor-api";
9
+ import type { API, IAttachmentAccessService } from "@powerhousedao/reactor-api";
9
10
  import { createHttpAdapter } from "@powerhousedao/reactor-api";
10
11
  import { Kysely } from "kysely";
11
12
  import { PGliteDialect } from "kysely-pglite-dialect";
@@ -22,6 +23,17 @@ import { registerAttachmentRoutes } from "../../src/attachments/index.js";
22
23
  const EMPTY_STRING_SHA256 =
23
24
  "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
24
25
 
26
+ const DOC_ID = "doc-1";
27
+ const ALLOW_ALL: IAttachmentAccessService = {
28
+ canReadAttachment: (request) =>
29
+ Promise.resolve({
30
+ kind: "allowed",
31
+ documentId: request.documentId as never,
32
+ ref: request.attachmentRef as never,
33
+ }),
34
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
35
+ };
36
+
25
37
  describe("attachment routes through the real Express middleware stack", () => {
26
38
  let attachments: AttachmentBuildResult;
27
39
  let kysely: Kysely<unknown>;
@@ -42,10 +54,11 @@ describe("attachment routes through the real Express middleware stack", () => {
42
54
  registerAttachmentRoutes({
43
55
  httpAdapter: adapter,
44
56
  attachments,
57
+ attachmentAccess: ALLOW_ALL,
45
58
  authService: undefined,
46
59
  } as unknown as API);
47
60
 
48
- server = await adapter.listen(0);
61
+ server = await adapter.listen(0, undefined, "127.0.0.1");
49
62
  const addr = server.address();
50
63
  if (!addr || typeof addr === "string") throw new Error("no addr");
51
64
  baseUrl = `http://127.0.0.1:${addr.port}`;
@@ -82,7 +95,7 @@ describe("attachment routes through the real Express middleware stack", () => {
82
95
  expect(result.hash).not.toBe(EMPTY_STRING_SHA256);
83
96
  expect(result.header.sizeBytes).toBe(bytes.byteLength);
84
97
 
85
- const got = await service.get(result.ref);
98
+ const got = await service.get(result.ref, { documentId: DOC_ID });
86
99
  expect(got.header.sizeBytes).toBe(bytes.byteLength);
87
100
  expect(got.header.mimeType).toBe("application/json");
88
101
  const reader = got.body.getReader();
@@ -101,6 +114,48 @@ describe("attachment routes through the real Express middleware stack", () => {
101
114
  }
102
115
  expect(new TextDecoder().decode(merged)).toBe(payload);
103
116
  });
117
+
118
+ it("HEAD and GET /attachments/:hash serve a file name above U+00FF", async () => {
119
+ const fileName = "opłata – 報告 📎.pdf";
120
+ const service = createRemoteAttachmentService({ remoteUrl: baseUrl });
121
+ const upload = await service.reserve({
122
+ mimeType: "application/pdf",
123
+ fileName,
124
+ });
125
+ const bytes = new TextEncoder().encode("%PDF-unicode-name");
126
+ const { hash } = await upload.send(
127
+ new ReadableStream<Uint8Array>({
128
+ start(controller) {
129
+ controller.enqueue(bytes);
130
+ controller.close();
131
+ },
132
+ }),
133
+ );
134
+
135
+ for (const method of ["HEAD", "GET"]) {
136
+ const res = await fetch(
137
+ `${baseUrl}/attachments/${hash}?documentId=${DOC_ID}`,
138
+ { method },
139
+ );
140
+ expect(res.status).toBe(200);
141
+ const meta = JSON.parse(res.headers.get("attachment-metadata")!) as {
142
+ fileName: string;
143
+ };
144
+ expect(meta.fileName).toBe(fileName);
145
+ await res.arrayBuffer();
146
+ }
147
+
148
+ // A hash-first reserve of the same bytes is what the client sends on the
149
+ // next upload; it stats the hash first.
150
+ await expect(
151
+ service.reserve({
152
+ mimeType: "application/pdf",
153
+ fileName,
154
+ clientHash: hash,
155
+ sizeBytes: bytes.byteLength,
156
+ }),
157
+ ).rejects.toBeInstanceOf(AttachmentAlreadyExists);
158
+ });
104
159
  });
105
160
 
106
161
  describe("authenticated S3 reservation production path", () => {
@@ -138,9 +193,10 @@ describe("authenticated S3 reservation production path", () => {
138
193
  registerAttachmentRoutes({
139
194
  httpAdapter: adapter,
140
195
  attachments,
196
+ attachmentAccess: ALLOW_ALL,
141
197
  authService: { verifyBearer },
142
198
  } as unknown as API);
143
- const server = await adapter.listen(0);
199
+ const server = await adapter.listen(0, undefined, "127.0.0.1");
144
200
  const address = server.address();
145
201
  if (!address || typeof address === "string") throw new Error("no addr");
146
202
  const baseUrl = `http://127.0.0.1:${address.port}`;