@powerhousedao/switchboard 6.2.3-dev.9 → 6.2.3-staging.0

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 (108) 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/url-signer.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  7. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  8. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  19. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  20. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  21. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  24. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  25. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  26. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  27. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  28. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  29. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  30. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  31. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  32. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  33. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  34. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  35. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  36. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  37. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  38. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  39. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  40. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  41. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  42. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  43. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  44. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  45. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  46. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  48. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  49. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  50. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  51. package/.tsbuild/vitest.config.d.ts.map +1 -1
  52. package/Auth.md +94 -0
  53. package/CHANGELOG.md +298 -0
  54. package/README.md +35 -0
  55. package/dist/index.d.mts +2 -1
  56. package/dist/index.mjs +10 -8
  57. package/dist/index.mjs.map +1 -1
  58. package/dist/install-packages.d.mts +2 -1
  59. package/dist/install-packages.mjs +2 -2
  60. package/dist/install-packages.mjs.map +1 -1
  61. package/dist/migrate.d.mts +2 -1
  62. package/dist/migrate.mjs +4 -3
  63. package/dist/migrate.mjs.map +1 -1
  64. package/dist/{server-CiKkVE6g.mjs → server-sQoWp21G.mjs} +1454 -70
  65. package/dist/server-sQoWp21G.mjs.map +1 -0
  66. package/dist/server.d.mts +103 -29
  67. package/dist/server.d.mts.map +1 -1
  68. package/dist/server.mjs +4 -5
  69. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  70. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  71. package/dist/utils.d.mts +4 -5
  72. package/dist/utils.d.mts.map +1 -1
  73. package/dist/utils.mjs +3 -3
  74. package/package.json +21 -17
  75. package/test/attachments/auth.test.ts +85 -0
  76. package/test/attachments/byte-grants.test.ts +287 -0
  77. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  78. package/test/attachments/download-target.test.ts +194 -31
  79. package/test/attachments/index.test.ts +34 -3
  80. package/test/attachments/routes-integration.test.ts +59 -4
  81. package/test/attachments/routes.test.ts +165 -23
  82. package/test/attachments/service-config.test.ts +13 -3
  83. package/test/attachments/url-signer.test.ts +105 -0
  84. package/test/boot-create-signature-policy.test.ts +108 -0
  85. package/test/boot-mcp-enabled.test.ts +121 -0
  86. package/test/boot-unsupported-stored-documents.test.ts +210 -0
  87. package/test/boot-without-renown.test.ts +81 -0
  88. package/test/boot-without-require-signatures.test.ts +74 -0
  89. package/test/boot-worker-trust-policy.test.ts +90 -0
  90. package/test/builder-defaults.test.ts +80 -0
  91. package/test/create-signature-policy.test.ts +69 -0
  92. package/test/fatal-shutdown.test.ts +102 -0
  93. package/test/mcp-flag.test.ts +56 -0
  94. package/test/renown-trust-policy.test.ts +315 -0
  95. package/test/unsupported-stored-documents.test.ts +72 -0
  96. package/test/workflow/harness.ts +289 -0
  97. package/test/workflow/oauth.test.ts +214 -0
  98. package/test/workflow/resolvers.test.ts +296 -0
  99. package/test/workflow/webhook-dedupe.test.ts +295 -0
  100. package/test/workflow/webhook-signed.test.ts +509 -0
  101. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  102. package/test/workflow/webhook-unsigned.test.ts +345 -0
  103. package/test/workflow-read-gate.test.ts +145 -0
  104. package/test/workflow-runtime.test.ts +486 -0
  105. package/tsconfig.json +6 -0
  106. package/tsdown.config.ts +3 -1
  107. package/dist/server-CiKkVE6g.mjs.map +0 -1
  108. 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: {
@@ -106,29 +115,120 @@ const ALLOWED: AttachmentAccessResult = {
106
115
  };
107
116
 
108
117
  describe("makeDownloadTargetHandler", () => {
109
- it("returns a switchboard target for filesystem with no-store", async () => {
118
+ it("returns a signed switchboard target for filesystem with no-store", async () => {
110
119
  const { access, spy } = makeAccess(ALLOWED);
111
120
  const attachments = makeAttachments({});
112
- const handler = makeDownloadTargetHandler(attachments, access);
121
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
113
122
  const res = makeRes();
114
123
 
115
124
  await handler(makeReq({}), res, undefined, ACTOR);
116
125
 
117
126
  expect(res.statusCode).toBe(200);
118
127
  expect(res._headers["cache-control"]).toBe("no-store");
119
- expect(JSON.parse(res._body)).toEqual({
128
+ const { target, url, params } = signedUrlParts(res._body);
129
+ expect(target).toMatchObject({
120
130
  kind: "switchboard",
121
131
  method: "GET",
122
- url: `http://sb.example.com/attachments/${HASH}`,
123
132
  headers: {},
133
+ expiresAtUtc: new Date(NOW_MS + 300_000).toISOString(),
124
134
  });
135
+ expect(`${url.origin}${url.pathname}`).toBe(
136
+ `http://sb.example.com/attachments/${HASH}`,
137
+ );
138
+ expect(params.get("documentId")).toBe(DOC_ID);
139
+ expect(
140
+ SIGNER.verify(
141
+ HASH,
142
+ DOC_ID,
143
+ params.get("expires")!,
144
+ params.get("signature")!,
145
+ ),
146
+ ).toBe(true);
125
147
  expect(spy).toHaveBeenCalledWith({
126
148
  documentId: DOC_ID,
127
149
  attachmentRef: REF,
128
150
  userAddress: "0xverified",
151
+ appKey: "did:key:zAppTest",
129
152
  });
130
153
  });
131
154
 
155
+ it("signs for the requested expiresIn, clamped to the download-target ceiling", async () => {
156
+ const { access } = makeAccess(ALLOWED);
157
+ const handler = makeDownloadTargetHandler(
158
+ makeAttachments({}),
159
+ access,
160
+ SIGNER,
161
+ );
162
+
163
+ for (const [requested, granted] of [
164
+ [60, 60],
165
+ [3600, 300],
166
+ [99999999, 300],
167
+ ]) {
168
+ const res = makeRes();
169
+ await handler(
170
+ makeReq({
171
+ url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=${requested}`,
172
+ }),
173
+ res,
174
+ undefined,
175
+ ACTOR,
176
+ );
177
+ expect(signedUrlParts(res._body).target.expiresAtUtc).toBe(
178
+ new Date(NOW_MS + granted * 1000).toISOString(),
179
+ );
180
+ }
181
+ });
182
+
183
+ it("signs the canonical document id the access decision resolved", async () => {
184
+ const { access } = makeAccess({ ...ALLOWED, documentId: "canon" as never });
185
+ const handler = makeDownloadTargetHandler(
186
+ makeAttachments({}),
187
+ access,
188
+ SIGNER,
189
+ );
190
+ const res = makeRes();
191
+
192
+ await handler(makeReq({}), res, undefined, ACTOR);
193
+
194
+ expect(signedUrlParts(res._body).params.get("documentId")).toBe("canon");
195
+ });
196
+
197
+ it("fails closed with 503 on filesystem when no signer is configured", async () => {
198
+ const { access } = makeAccess(ALLOWED);
199
+ const handler = makeDownloadTargetHandler(
200
+ makeAttachments({}),
201
+ access,
202
+ null,
203
+ );
204
+ const res = makeRes();
205
+
206
+ await handler(makeReq({}), res, undefined, ACTOR);
207
+
208
+ expect(res.statusCode).toBe(503);
209
+ expect(res._body).not.toContain("/attachments/");
210
+ });
211
+
212
+ it("needs no signer for S3 presigned targets", async () => {
213
+ const { access } = makeAccess(ALLOWED);
214
+ const prepareDownloadTarget = vi.fn().mockResolvedValue({
215
+ kind: "presigned-get",
216
+ method: "GET",
217
+ url: "https://bucket.example.com/attachments/aa?sig=1",
218
+ headers: {},
219
+ expiresAtUtc: EXPIRES,
220
+ });
221
+ const attachments = makeAttachments({
222
+ backend: { kind: "s3", prepareDownloadTarget } as never,
223
+ });
224
+ const handler = makeDownloadTargetHandler(attachments, access, null);
225
+ const res = makeRes();
226
+
227
+ await handler(makeReq({}), res, undefined, ACTOR);
228
+
229
+ expect(res.statusCode).toBe(200);
230
+ });
231
+
132
232
  it("returns the backend presigned-get target in S3 mode", async () => {
133
233
  const { access } = makeAccess(ALLOWED);
134
234
  const prepareDownloadTarget = vi.fn().mockResolvedValue({
@@ -141,14 +241,14 @@ describe("makeDownloadTargetHandler", () => {
141
241
  const attachments = makeAttachments({
142
242
  backend: { kind: "s3", prepareDownloadTarget } as never,
143
243
  });
144
- const handler = makeDownloadTargetHandler(attachments, access);
244
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
145
245
  const res = makeRes();
146
246
 
147
247
  await handler(makeReq({}), res, undefined, ACTOR);
148
248
 
149
249
  expect(res.statusCode).toBe(200);
150
250
  expect(JSON.parse(res._body)).toMatchObject({ kind: "presigned-get" });
151
- expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, undefined);
251
+ expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
152
252
  });
153
253
 
154
254
  it("passes a requested expiresIn through to the backend presigner", async () => {
@@ -163,12 +263,12 @@ describe("makeDownloadTargetHandler", () => {
163
263
  const attachments = makeAttachments({
164
264
  backend: { kind: "s3", prepareDownloadTarget } as never,
165
265
  });
166
- const handler = makeDownloadTargetHandler(attachments, access);
266
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
167
267
  const res = makeRes();
168
268
 
169
269
  await handler(
170
270
  makeReq({
171
- url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=3600`,
271
+ url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}&expiresIn=60`,
172
272
  }),
173
273
  res,
174
274
  undefined,
@@ -176,10 +276,10 @@ describe("makeDownloadTargetHandler", () => {
176
276
  );
177
277
 
178
278
  expect(res.statusCode).toBe(200);
179
- expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 3600);
279
+ expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 60);
180
280
  });
181
281
 
182
- it("clamps expiresIn to the 7-day presigning ceiling", async () => {
282
+ it("clamps a requested expiresIn to the ceiling", async () => {
183
283
  const { access } = makeAccess(ALLOWED);
184
284
  const prepareDownloadTarget = vi.fn().mockResolvedValue({
185
285
  kind: "presigned-get",
@@ -191,7 +291,7 @@ describe("makeDownloadTargetHandler", () => {
191
291
  const attachments = makeAttachments({
192
292
  backend: { kind: "s3", prepareDownloadTarget } as never,
193
293
  });
194
- const handler = makeDownloadTargetHandler(attachments, access);
294
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
195
295
  const res = makeRes();
196
296
 
197
297
  await handler(
@@ -203,10 +303,10 @@ describe("makeDownloadTargetHandler", () => {
203
303
  ACTOR,
204
304
  );
205
305
 
206
- expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 604800);
306
+ expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
207
307
  });
208
308
 
209
- it("omits the TTL override when expiresIn is absent", async () => {
309
+ it("applies the ceiling when expiresIn is absent, rather than the backend default", async () => {
210
310
  const { access } = makeAccess(ALLOWED);
211
311
  const prepareDownloadTarget = vi.fn().mockResolvedValue({
212
312
  kind: "presigned-get",
@@ -218,11 +318,73 @@ describe("makeDownloadTargetHandler", () => {
218
318
  const attachments = makeAttachments({
219
319
  backend: { kind: "s3", prepareDownloadTarget } as never,
220
320
  });
221
- const handler = makeDownloadTargetHandler(attachments, access);
321
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
222
322
 
223
323
  await handler(makeReq({}), makeRes(), undefined, ACTOR);
224
324
 
225
- expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, undefined);
325
+ expect(prepareDownloadTarget).toHaveBeenCalledWith(HASH, 300);
326
+ });
327
+
328
+ describe("ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS", () => {
329
+ const ORIGINAL = process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
330
+
331
+ afterEach(() => {
332
+ if (ORIGINAL === undefined) {
333
+ delete process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
334
+ } else {
335
+ process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS = ORIGINAL;
336
+ }
337
+ });
338
+
339
+ async function ttlFor(configured: string | undefined, query = "") {
340
+ if (configured === undefined) {
341
+ delete process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
342
+ } else {
343
+ process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS = configured;
344
+ }
345
+ const { access } = makeAccess(ALLOWED);
346
+ const prepareDownloadTarget = vi.fn().mockResolvedValue({
347
+ kind: "presigned-get",
348
+ method: "GET",
349
+ url: "https://bucket.example.com/attachments/aa?sig=1",
350
+ headers: {},
351
+ expiresAtUtc: EXPIRES,
352
+ });
353
+ const attachments = makeAttachments({
354
+ backend: { kind: "s3", prepareDownloadTarget } as never,
355
+ });
356
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
357
+ await handler(
358
+ makeReq({
359
+ url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}${query}`,
360
+ }),
361
+ makeRes(),
362
+ undefined,
363
+ ACTOR,
364
+ );
365
+ return (prepareDownloadTarget.mock.calls[0] as unknown[])[1];
366
+ }
367
+
368
+ it("raises the ceiling when a deployment configures one", async () => {
369
+ await expect(ttlFor("1800")).resolves.toBe(1800);
370
+ });
371
+
372
+ it("never lets a configured ceiling exceed the signing maximum", async () => {
373
+ await expect(ttlFor("99999999")).resolves.toBe(604800);
374
+ });
375
+
376
+ it.each(["not-a-number", "0", "-5", "12.5"])(
377
+ "falls back to the default, not the maximum, on %s",
378
+ async (configured) => {
379
+ // A typo must narrow nothing and widen nothing: falling back to the
380
+ // signing ceiling would turn a misconfiguration into a week-long URL.
381
+ await expect(ttlFor(configured)).resolves.toBe(300);
382
+ },
383
+ );
384
+
385
+ it("still clamps a caller's request to the configured ceiling", async () => {
386
+ await expect(ttlFor("1800", "&expiresIn=99999")).resolves.toBe(1800);
387
+ });
226
388
  });
227
389
 
228
390
  it.each([
@@ -234,7 +396,7 @@ describe("makeDownloadTargetHandler", () => {
234
396
  ])("rejects a %s expiresIn with 400 before authorization", async (_, qs) => {
235
397
  const { access, spy } = makeAccess(ALLOWED);
236
398
  const attachments = makeAttachments({});
237
- const handler = makeDownloadTargetHandler(attachments, access);
399
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
238
400
  const res = makeRes();
239
401
 
240
402
  await handler(
@@ -253,7 +415,7 @@ describe("makeDownloadTargetHandler", () => {
253
415
  it("normalizes an uppercase path hash before authorization and lookup", async () => {
254
416
  const { access, spy } = makeAccess(ALLOWED);
255
417
  const attachments = makeAttachments({});
256
- const handler = makeDownloadTargetHandler(attachments, access);
418
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
257
419
  const res = makeRes();
258
420
 
259
421
  await handler(makeReq({ hash: HASH.toUpperCase() }), res, undefined, ACTOR);
@@ -268,7 +430,7 @@ describe("makeDownloadTargetHandler", () => {
268
430
  it("rejects an invalid hash before any access or storage call", async () => {
269
431
  const { access, spy } = makeAccess(ALLOWED);
270
432
  const attachments = makeAttachments({});
271
- const handler = makeDownloadTargetHandler(attachments, access);
433
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
272
434
  const res = makeRes();
273
435
 
274
436
  await handler(makeReq({ hash: "nothex" }), res, undefined, ACTOR);
@@ -292,7 +454,7 @@ describe("makeDownloadTargetHandler", () => {
292
454
  ])("rejects a %s documentId before authorization", async (_, url) => {
293
455
  const { access, spy } = makeAccess(ALLOWED);
294
456
  const attachments = makeAttachments({});
295
- const handler = makeDownloadTargetHandler(attachments, access);
457
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
296
458
  const res = makeRes();
297
459
 
298
460
  await handler(makeReq({ url }), res, undefined, ACTOR);
@@ -307,7 +469,7 @@ describe("makeDownloadTargetHandler", () => {
307
469
  const attachments = makeAttachments({
308
470
  backend: { kind: "s3", prepareDownloadTarget } as never,
309
471
  });
310
- const handler = makeDownloadTargetHandler(attachments, access);
472
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
311
473
  const res = makeRes();
312
474
 
313
475
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -321,7 +483,7 @@ describe("makeDownloadTargetHandler", () => {
321
483
  it("maps projection-unavailable to a generic non-cacheable 503 before storage", async () => {
322
484
  const { access } = makeAccess({ kind: "projection-unavailable" });
323
485
  const attachments = makeAttachments({});
324
- const handler = makeDownloadTargetHandler(attachments, access);
486
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
325
487
  const res = makeRes();
326
488
 
327
489
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -337,7 +499,7 @@ describe("makeDownloadTargetHandler", () => {
337
499
  const { AttachmentNotFound } =
338
500
  await import("@powerhousedao/reactor-attachments");
339
501
  attachments.statSpy.mockRejectedValue(new AttachmentNotFound(HASH));
340
- const handler = makeDownloadTargetHandler(attachments, access);
502
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
341
503
  const res = makeRes();
342
504
 
343
505
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -351,7 +513,7 @@ describe("makeDownloadTargetHandler", () => {
351
513
  const attachments = makeAttachments({
352
514
  stat: () => Promise.resolve({ ...AVAILABLE_HEADER, status: "pending" }),
353
515
  });
354
- const handler = makeDownloadTargetHandler(attachments, access);
516
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
355
517
  const res = makeRes();
356
518
 
357
519
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -371,7 +533,7 @@ describe("makeDownloadTargetHandler", () => {
371
533
  const attachments = makeAttachments({
372
534
  backend: { kind: "s3", prepareDownloadTarget } as never,
373
535
  });
374
- const handler = makeDownloadTargetHandler(attachments, access);
536
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
375
537
  const res = makeRes();
376
538
 
377
539
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -384,7 +546,7 @@ describe("makeDownloadTargetHandler", () => {
384
546
  it("passes an anonymous actor as an undefined address (OPEN mode)", async () => {
385
547
  const { access, spy } = makeAccess(ALLOWED);
386
548
  const attachments = makeAttachments({});
387
- const handler = makeDownloadTargetHandler(attachments, access);
549
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
388
550
  const res = makeRes();
389
551
 
390
552
  await handler(makeReq({}), res, undefined, {
@@ -400,7 +562,7 @@ describe("makeDownloadTargetHandler", () => {
400
562
  it("honours x-forwarded proto/host when building the switchboard target", async () => {
401
563
  const { access } = makeAccess(ALLOWED);
402
564
  const attachments = makeAttachments({});
403
- const handler = makeDownloadTargetHandler(attachments, access);
565
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
404
566
  const res = makeRes();
405
567
 
406
568
  await handler(
@@ -415,9 +577,10 @@ describe("makeDownloadTargetHandler", () => {
415
577
  ACTOR,
416
578
  );
417
579
 
418
- expect(JSON.parse(res._body)).toMatchObject({
419
- url: `https://public.example.com/attachments/${HASH}`,
420
- });
580
+ const { url } = signedUrlParts(res._body);
581
+ expect(`${url.origin}${url.pathname}`).toBe(
582
+ `https://public.example.com/attachments/${HASH}`,
583
+ );
421
584
  });
422
585
  });
423
586
 
@@ -435,7 +598,7 @@ describe("registerAttachmentRoutes inventory", () => {
435
598
  attachmentAccess: { canReadAttachment: vi.fn() },
436
599
  } as unknown as API;
437
600
 
438
- registerAttachmentRoutes(api);
601
+ registerAttachmentRoutes(api, { urlSigner: null });
439
602
 
440
603
  expect(captured).toEqual([
441
604
  { 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();
@@ -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,16 @@ 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
+ };
35
+
25
36
  describe("attachment routes through the real Express middleware stack", () => {
26
37
  let attachments: AttachmentBuildResult;
27
38
  let kysely: Kysely<unknown>;
@@ -42,10 +53,11 @@ describe("attachment routes through the real Express middleware stack", () => {
42
53
  registerAttachmentRoutes({
43
54
  httpAdapter: adapter,
44
55
  attachments,
56
+ attachmentAccess: ALLOW_ALL,
45
57
  authService: undefined,
46
58
  } as unknown as API);
47
59
 
48
- server = await adapter.listen(0);
60
+ server = await adapter.listen(0, undefined, "127.0.0.1");
49
61
  const addr = server.address();
50
62
  if (!addr || typeof addr === "string") throw new Error("no addr");
51
63
  baseUrl = `http://127.0.0.1:${addr.port}`;
@@ -82,7 +94,7 @@ describe("attachment routes through the real Express middleware stack", () => {
82
94
  expect(result.hash).not.toBe(EMPTY_STRING_SHA256);
83
95
  expect(result.header.sizeBytes).toBe(bytes.byteLength);
84
96
 
85
- const got = await service.get(result.ref);
97
+ const got = await service.get(result.ref, { documentId: DOC_ID });
86
98
  expect(got.header.sizeBytes).toBe(bytes.byteLength);
87
99
  expect(got.header.mimeType).toBe("application/json");
88
100
  const reader = got.body.getReader();
@@ -101,6 +113,48 @@ describe("attachment routes through the real Express middleware stack", () => {
101
113
  }
102
114
  expect(new TextDecoder().decode(merged)).toBe(payload);
103
115
  });
116
+
117
+ it("HEAD and GET /attachments/:hash serve a file name above U+00FF", async () => {
118
+ const fileName = "opłata – 報告 📎.pdf";
119
+ const service = createRemoteAttachmentService({ remoteUrl: baseUrl });
120
+ const upload = await service.reserve({
121
+ mimeType: "application/pdf",
122
+ fileName,
123
+ });
124
+ const bytes = new TextEncoder().encode("%PDF-unicode-name");
125
+ const { hash } = await upload.send(
126
+ new ReadableStream<Uint8Array>({
127
+ start(controller) {
128
+ controller.enqueue(bytes);
129
+ controller.close();
130
+ },
131
+ }),
132
+ );
133
+
134
+ for (const method of ["HEAD", "GET"]) {
135
+ const res = await fetch(
136
+ `${baseUrl}/attachments/${hash}?documentId=${DOC_ID}`,
137
+ { method },
138
+ );
139
+ expect(res.status).toBe(200);
140
+ const meta = JSON.parse(res.headers.get("attachment-metadata")!) as {
141
+ fileName: string;
142
+ };
143
+ expect(meta.fileName).toBe(fileName);
144
+ await res.arrayBuffer();
145
+ }
146
+
147
+ // A hash-first reserve of the same bytes is what the client sends on the
148
+ // next upload; it stats the hash first.
149
+ await expect(
150
+ service.reserve({
151
+ mimeType: "application/pdf",
152
+ fileName,
153
+ clientHash: hash,
154
+ sizeBytes: bytes.byteLength,
155
+ }),
156
+ ).rejects.toBeInstanceOf(AttachmentAlreadyExists);
157
+ });
104
158
  });
105
159
 
106
160
  describe("authenticated S3 reservation production path", () => {
@@ -138,9 +192,10 @@ describe("authenticated S3 reservation production path", () => {
138
192
  registerAttachmentRoutes({
139
193
  httpAdapter: adapter,
140
194
  attachments,
195
+ attachmentAccess: ALLOW_ALL,
141
196
  authService: { verifyBearer },
142
197
  } as unknown as API);
143
- const server = await adapter.listen(0);
198
+ const server = await adapter.listen(0, undefined, "127.0.0.1");
144
199
  const address = server.address();
145
200
  if (!address || typeof address === "string") throw new Error("no addr");
146
201
  const baseUrl = `http://127.0.0.1:${address.port}`;