@powerhousedao/switchboard 6.2.3-dev.25 → 6.2.3-dev.26

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.
@@ -13,11 +13,20 @@ 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,22 +115,35 @@ 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,
@@ -130,6 +152,83 @@ describe("makeDownloadTargetHandler", () => {
130
152
  });
131
153
  });
132
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
+
133
232
  it("returns the backend presigned-get target in S3 mode", async () => {
134
233
  const { access } = makeAccess(ALLOWED);
135
234
  const prepareDownloadTarget = vi.fn().mockResolvedValue({
@@ -142,7 +241,7 @@ describe("makeDownloadTargetHandler", () => {
142
241
  const attachments = makeAttachments({
143
242
  backend: { kind: "s3", prepareDownloadTarget } as never,
144
243
  });
145
- const handler = makeDownloadTargetHandler(attachments, access);
244
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
146
245
  const res = makeRes();
147
246
 
148
247
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -164,7 +263,7 @@ describe("makeDownloadTargetHandler", () => {
164
263
  const attachments = makeAttachments({
165
264
  backend: { kind: "s3", prepareDownloadTarget } as never,
166
265
  });
167
- const handler = makeDownloadTargetHandler(attachments, access);
266
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
168
267
  const res = makeRes();
169
268
 
170
269
  await handler(
@@ -192,7 +291,7 @@ describe("makeDownloadTargetHandler", () => {
192
291
  const attachments = makeAttachments({
193
292
  backend: { kind: "s3", prepareDownloadTarget } as never,
194
293
  });
195
- const handler = makeDownloadTargetHandler(attachments, access);
294
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
196
295
  const res = makeRes();
197
296
 
198
297
  await handler(
@@ -219,7 +318,7 @@ describe("makeDownloadTargetHandler", () => {
219
318
  const attachments = makeAttachments({
220
319
  backend: { kind: "s3", prepareDownloadTarget } as never,
221
320
  });
222
- const handler = makeDownloadTargetHandler(attachments, access);
321
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
223
322
 
224
323
  await handler(makeReq({}), makeRes(), undefined, ACTOR);
225
324
 
@@ -254,7 +353,7 @@ describe("makeDownloadTargetHandler", () => {
254
353
  const attachments = makeAttachments({
255
354
  backend: { kind: "s3", prepareDownloadTarget } as never,
256
355
  });
257
- const handler = makeDownloadTargetHandler(attachments, access);
356
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
258
357
  await handler(
259
358
  makeReq({
260
359
  url: `/attachments/${HASH}/download-target?documentId=${DOC_ID}${query}`,
@@ -297,7 +396,7 @@ describe("makeDownloadTargetHandler", () => {
297
396
  ])("rejects a %s expiresIn with 400 before authorization", async (_, qs) => {
298
397
  const { access, spy } = makeAccess(ALLOWED);
299
398
  const attachments = makeAttachments({});
300
- const handler = makeDownloadTargetHandler(attachments, access);
399
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
301
400
  const res = makeRes();
302
401
 
303
402
  await handler(
@@ -316,7 +415,7 @@ describe("makeDownloadTargetHandler", () => {
316
415
  it("normalizes an uppercase path hash before authorization and lookup", async () => {
317
416
  const { access, spy } = makeAccess(ALLOWED);
318
417
  const attachments = makeAttachments({});
319
- const handler = makeDownloadTargetHandler(attachments, access);
418
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
320
419
  const res = makeRes();
321
420
 
322
421
  await handler(makeReq({ hash: HASH.toUpperCase() }), res, undefined, ACTOR);
@@ -331,7 +430,7 @@ describe("makeDownloadTargetHandler", () => {
331
430
  it("rejects an invalid hash before any access or storage call", async () => {
332
431
  const { access, spy } = makeAccess(ALLOWED);
333
432
  const attachments = makeAttachments({});
334
- const handler = makeDownloadTargetHandler(attachments, access);
433
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
335
434
  const res = makeRes();
336
435
 
337
436
  await handler(makeReq({ hash: "nothex" }), res, undefined, ACTOR);
@@ -355,7 +454,7 @@ describe("makeDownloadTargetHandler", () => {
355
454
  ])("rejects a %s documentId before authorization", async (_, url) => {
356
455
  const { access, spy } = makeAccess(ALLOWED);
357
456
  const attachments = makeAttachments({});
358
- const handler = makeDownloadTargetHandler(attachments, access);
457
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
359
458
  const res = makeRes();
360
459
 
361
460
  await handler(makeReq({ url }), res, undefined, ACTOR);
@@ -370,7 +469,7 @@ describe("makeDownloadTargetHandler", () => {
370
469
  const attachments = makeAttachments({
371
470
  backend: { kind: "s3", prepareDownloadTarget } as never,
372
471
  });
373
- const handler = makeDownloadTargetHandler(attachments, access);
472
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
374
473
  const res = makeRes();
375
474
 
376
475
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -384,7 +483,7 @@ describe("makeDownloadTargetHandler", () => {
384
483
  it("maps projection-unavailable to a generic non-cacheable 503 before storage", async () => {
385
484
  const { access } = makeAccess({ kind: "projection-unavailable" });
386
485
  const attachments = makeAttachments({});
387
- const handler = makeDownloadTargetHandler(attachments, access);
486
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
388
487
  const res = makeRes();
389
488
 
390
489
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -400,7 +499,7 @@ describe("makeDownloadTargetHandler", () => {
400
499
  const { AttachmentNotFound } =
401
500
  await import("@powerhousedao/reactor-attachments");
402
501
  attachments.statSpy.mockRejectedValue(new AttachmentNotFound(HASH));
403
- const handler = makeDownloadTargetHandler(attachments, access);
502
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
404
503
  const res = makeRes();
405
504
 
406
505
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -414,7 +513,7 @@ describe("makeDownloadTargetHandler", () => {
414
513
  const attachments = makeAttachments({
415
514
  stat: () => Promise.resolve({ ...AVAILABLE_HEADER, status: "pending" }),
416
515
  });
417
- const handler = makeDownloadTargetHandler(attachments, access);
516
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
418
517
  const res = makeRes();
419
518
 
420
519
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -434,7 +533,7 @@ describe("makeDownloadTargetHandler", () => {
434
533
  const attachments = makeAttachments({
435
534
  backend: { kind: "s3", prepareDownloadTarget } as never,
436
535
  });
437
- const handler = makeDownloadTargetHandler(attachments, access);
536
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
438
537
  const res = makeRes();
439
538
 
440
539
  await handler(makeReq({}), res, undefined, ACTOR);
@@ -447,7 +546,7 @@ describe("makeDownloadTargetHandler", () => {
447
546
  it("passes an anonymous actor as an undefined address (OPEN mode)", async () => {
448
547
  const { access, spy } = makeAccess(ALLOWED);
449
548
  const attachments = makeAttachments({});
450
- const handler = makeDownloadTargetHandler(attachments, access);
549
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
451
550
  const res = makeRes();
452
551
 
453
552
  await handler(makeReq({}), res, undefined, {
@@ -463,7 +562,7 @@ describe("makeDownloadTargetHandler", () => {
463
562
  it("honours x-forwarded proto/host when building the switchboard target", async () => {
464
563
  const { access } = makeAccess(ALLOWED);
465
564
  const attachments = makeAttachments({});
466
- const handler = makeDownloadTargetHandler(attachments, access);
565
+ const handler = makeDownloadTargetHandler(attachments, access, SIGNER);
467
566
  const res = makeRes();
468
567
 
469
568
  await handler(
@@ -478,9 +577,10 @@ describe("makeDownloadTargetHandler", () => {
478
577
  ACTOR,
479
578
  );
480
579
 
481
- expect(JSON.parse(res._body)).toMatchObject({
482
- url: `https://public.example.com/attachments/${HASH}`,
483
- });
580
+ const { url } = signedUrlParts(res._body);
581
+ expect(`${url.origin}${url.pathname}`).toBe(
582
+ `https://public.example.com/attachments/${HASH}`,
583
+ );
484
584
  });
485
585
  });
486
586
 
@@ -498,7 +598,7 @@ describe("registerAttachmentRoutes inventory", () => {
498
598
  attachmentAccess: { canReadAttachment: vi.fn() },
499
599
  } as unknown as API;
500
600
 
501
- registerAttachmentRoutes(api);
601
+ registerAttachmentRoutes(api, { urlSigner: null });
502
602
 
503
603
  expect(captured).toEqual([
504
604
  { method: "POST", path: "/attachments/reservations" },
@@ -6,7 +6,7 @@ import {
6
6
  type AttachmentBuildResult,
7
7
  createRemoteAttachmentService,
8
8
  } from "@powerhousedao/reactor-attachments";
9
- import type { API } from "@powerhousedao/reactor-api";
9
+ import type { API, IAttachmentAccessService } from "@powerhousedao/reactor-api";
10
10
  import { createHttpAdapter } from "@powerhousedao/reactor-api";
11
11
  import { Kysely } from "kysely";
12
12
  import { PGliteDialect } from "kysely-pglite-dialect";
@@ -23,6 +23,16 @@ import { registerAttachmentRoutes } from "../../src/attachments/index.js";
23
23
  const EMPTY_STRING_SHA256 =
24
24
  "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
25
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
+
26
36
  describe("attachment routes through the real Express middleware stack", () => {
27
37
  let attachments: AttachmentBuildResult;
28
38
  let kysely: Kysely<unknown>;
@@ -43,10 +53,11 @@ describe("attachment routes through the real Express middleware stack", () => {
43
53
  registerAttachmentRoutes({
44
54
  httpAdapter: adapter,
45
55
  attachments,
56
+ attachmentAccess: ALLOW_ALL,
46
57
  authService: undefined,
47
58
  } as unknown as API);
48
59
 
49
- server = await adapter.listen(0);
60
+ server = await adapter.listen(0, undefined, "127.0.0.1");
50
61
  const addr = server.address();
51
62
  if (!addr || typeof addr === "string") throw new Error("no addr");
52
63
  baseUrl = `http://127.0.0.1:${addr.port}`;
@@ -83,7 +94,7 @@ describe("attachment routes through the real Express middleware stack", () => {
83
94
  expect(result.hash).not.toBe(EMPTY_STRING_SHA256);
84
95
  expect(result.header.sizeBytes).toBe(bytes.byteLength);
85
96
 
86
- const got = await service.get(result.ref);
97
+ const got = await service.get(result.ref, { documentId: DOC_ID });
87
98
  expect(got.header.sizeBytes).toBe(bytes.byteLength);
88
99
  expect(got.header.mimeType).toBe("application/json");
89
100
  const reader = got.body.getReader();
@@ -121,7 +132,10 @@ describe("attachment routes through the real Express middleware stack", () => {
121
132
  );
122
133
 
123
134
  for (const method of ["HEAD", "GET"]) {
124
- const res = await fetch(`${baseUrl}/attachments/${hash}`, { method });
135
+ const res = await fetch(
136
+ `${baseUrl}/attachments/${hash}?documentId=${DOC_ID}`,
137
+ { method },
138
+ );
125
139
  expect(res.status).toBe(200);
126
140
  const meta = JSON.parse(res.headers.get("attachment-metadata")!) as {
127
141
  fileName: string;
@@ -178,9 +192,10 @@ describe("authenticated S3 reservation production path", () => {
178
192
  registerAttachmentRoutes({
179
193
  httpAdapter: adapter,
180
194
  attachments,
195
+ attachmentAccess: ALLOW_ALL,
181
196
  authService: { verifyBearer },
182
197
  } as unknown as API);
183
- const server = await adapter.listen(0);
198
+ const server = await adapter.listen(0, undefined, "127.0.0.1");
184
199
  const address = server.address();
185
200
  if (!address || typeof address === "string") throw new Error("no addr");
186
201
  const baseUrl = `http://127.0.0.1:${address.port}`;
@@ -14,6 +14,7 @@ import {
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { Readable, Writable } from "node:stream";
17
+ import type { IAttachmentAccessService } from "@powerhousedao/reactor-api";
17
18
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
19
  import {
19
20
  buildContentDisposition,
@@ -29,6 +30,21 @@ import {
29
30
  quoteFilename,
30
31
  } from "../../src/attachments/routes.js";
31
32
 
33
+ // Byte-serving mechanics under a granted read; grant decisions are tested in
34
+ // byte-grants.test.ts.
35
+ const ALLOW_ALL: IAttachmentAccessService = {
36
+ canReadAttachment: (request) =>
37
+ Promise.resolve({
38
+ kind: "allowed",
39
+ documentId: request.documentId as never,
40
+ ref: request.attachmentRef as never,
41
+ }),
42
+ };
43
+ const grantedDownload = (attachments: AttachmentBuildResult) =>
44
+ makeDownloadHandler(attachments, ALLOW_ALL, null);
45
+ const grantedStat = (attachments: AttachmentBuildResult) =>
46
+ makeStatHandler(attachments, ALLOW_ALL, null);
47
+
32
48
  type CapturedRes = ServerResponse & {
33
49
  _headers: Record<string, string>;
34
50
  _body: Buffer;
@@ -52,7 +68,7 @@ function makeReq(opts: {
52
68
  params?: Record<string, string>;
53
69
  };
54
70
  req.method = opts.method;
55
- req.url = opts.url ?? "/";
71
+ req.url = opts.url ?? "/?documentId=doc-1";
56
72
  req.headers = {};
57
73
  if (opts.params) req.params = opts.params;
58
74
  return req as unknown as IncomingMessage;
@@ -222,7 +238,7 @@ describe("attachment routes", () => {
222
238
  expect(row!.deleted_at_utc).not.toBeNull();
223
239
 
224
240
  // Download
225
- const downloadHandler = makeDownloadHandler(attachments);
241
+ const downloadHandler = grantedDownload(attachments);
226
242
  const downloadReq = makeReq({
227
243
  method: "GET",
228
244
  params: { hash: upload.hash },
@@ -271,7 +287,7 @@ describe("attachment routes", () => {
271
287
  hash: string;
272
288
  };
273
289
 
274
- const downloadHandler = makeDownloadHandler(attachments);
290
+ const downloadHandler = grantedDownload(attachments);
275
291
  const downloadReq = makeReq({
276
292
  method: "GET",
277
293
  params: { hash: upload.hash },
@@ -314,7 +330,7 @@ describe("attachment routes", () => {
314
330
  hash: string;
315
331
  };
316
332
 
317
- const statHandler = makeStatHandler(attachments);
333
+ const statHandler = grantedStat(attachments);
318
334
  const statReq = makeReq({
319
335
  method: "HEAD",
320
336
  params: { hash: upload.hash },
@@ -373,9 +389,9 @@ describe("attachment routes", () => {
373
389
  };
374
390
 
375
391
  const { createServer } = await import("node:http");
376
- const statHandler = makeStatHandler(attachments);
392
+ const statHandler = grantedStat(attachments);
377
393
  const server = createServer((req, res) => {
378
- const m = /^\/attachments\/([^/]+)$/.exec(req.url ?? "/");
394
+ const m = /^\/attachments\/([^/?]+)(?:\?|$)/.exec(req.url ?? "/");
379
395
  if (!m) {
380
396
  res.statusCode = 404;
381
397
  res.end();
@@ -386,12 +402,14 @@ describe("attachment routes", () => {
386
402
  };
387
403
  void statHandler(req, res);
388
404
  });
389
- await new Promise<void>((resolve) => server.listen(0, resolve));
405
+ await new Promise<void>((resolve) =>
406
+ server.listen(0, "127.0.0.1", resolve),
407
+ );
390
408
  const port = (server.address() as { port: number }).port;
391
409
 
392
410
  try {
393
411
  const response = await fetch(
394
- `http://127.0.0.1:${port}/attachments/${upload.hash}`,
412
+ `http://127.0.0.1:${port}/attachments/${upload.hash}?documentId=doc-1`,
395
413
  {
396
414
  method: "HEAD",
397
415
  },
@@ -412,7 +430,7 @@ describe("attachment routes", () => {
412
430
  });
413
431
 
414
432
  it("HEAD stat returns 404 for unknown hash", async () => {
415
- const handler = makeStatHandler(attachments);
433
+ const handler = grantedStat(attachments);
416
434
  const req = makeReq({ method: "HEAD", params: { hash: "a".repeat(64) } });
417
435
  const res = makeRes();
418
436
  await handler(req, res);
@@ -421,7 +439,7 @@ describe("attachment routes", () => {
421
439
  });
422
440
 
423
441
  it("HEAD stat returns 400 for malformed hash", async () => {
424
- const handler = makeStatHandler(attachments);
442
+ const handler = grantedStat(attachments);
425
443
  const req = makeReq({ method: "HEAD", params: { hash: "not-a-hash" } });
426
444
  const res = makeRes();
427
445
  await handler(req, res);
@@ -523,7 +541,7 @@ describe("attachment routes", () => {
523
541
  });
524
542
 
525
543
  it("GET download returns 404 for unknown hash", async () => {
526
- const handler = makeDownloadHandler(attachments);
544
+ const handler = grantedDownload(attachments);
527
545
  const req = makeReq({
528
546
  method: "GET",
529
547
  params: { hash: "a".repeat(64) },
@@ -535,7 +553,7 @@ describe("attachment routes", () => {
535
553
  });
536
554
 
537
555
  it("GET download returns 400 for malformed hash", async () => {
538
- const handler = makeDownloadHandler(attachments);
556
+ const handler = grantedDownload(attachments);
539
557
  const req = makeReq({
540
558
  method: "GET",
541
559
  params: { hash: "not-a-hash" },
@@ -574,7 +592,7 @@ describe("attachment routes", () => {
574
592
  hash: string;
575
593
  };
576
594
 
577
- const handler = makeDownloadHandler(attachments);
595
+ const handler = grantedDownload(attachments);
578
596
  const req = makeReq({
579
597
  method: "GET",
580
598
  params: { hash: upload.hash.toUpperCase() },
@@ -612,7 +630,7 @@ describe("attachment routes", () => {
612
630
  hash: string;
613
631
  };
614
632
 
615
- const statHandler = makeStatHandler(attachments);
633
+ const statHandler = grantedStat(attachments);
616
634
  const statReq = makeReq({
617
635
  method: "HEAD",
618
636
  params: { hash: upload.hash.toUpperCase() },
@@ -660,7 +678,7 @@ describe("attachment routes", () => {
660
678
  throw new Error(secret);
661
679
  };
662
680
  try {
663
- const handler = makeDownloadHandler(attachments);
681
+ const handler = grantedDownload(attachments);
664
682
  const req = makeReq({
665
683
  method: "GET",
666
684
  params: { hash: "a".repeat(64) },
@@ -933,7 +951,7 @@ describe("attachment routes", () => {
933
951
  it("HEAD stat returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
934
952
  const { hash } = await createPendingReservation("pending content data");
935
953
 
936
- const handler = makeStatHandler(attachments);
954
+ const handler = grantedStat(attachments);
937
955
  const req = makeReq({ method: "HEAD", params: { hash } });
938
956
  const res = makeRes();
939
957
  await handler(req, res);
@@ -958,7 +976,7 @@ describe("attachment routes", () => {
958
976
  it("HEAD stat 202 for pending hash does not set Content-Disposition or Attachment-Metadata", async () => {
959
977
  const { hash } = await createPendingReservation("no metadata content");
960
978
 
961
- const handler = makeStatHandler(attachments);
979
+ const handler = grantedStat(attachments);
962
980
  const req = makeReq({ method: "HEAD", params: { hash } });
963
981
  const res = makeRes();
964
982
  await handler(req, res);
@@ -972,7 +990,7 @@ describe("attachment routes", () => {
972
990
  it("GET download returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
973
991
  const { hash } = await createPendingReservation("download pending data");
974
992
 
975
- const handler = makeDownloadHandler(attachments);
993
+ const handler = grantedDownload(attachments);
976
994
  const req = makeReq({ method: "GET", params: { hash } });
977
995
  const res = makeRes();
978
996
  await handler(req, res);
@@ -994,7 +1012,7 @@ describe("attachment routes", () => {
994
1012
  // the response as a successful zero-byte attachment.
995
1013
  const { hash } = await createPendingReservation("check-headers content");
996
1014
 
997
- const handler = makeDownloadHandler(attachments);
1015
+ const handler = grantedDownload(attachments);
998
1016
  const req = makeReq({ method: "GET", params: { hash } });
999
1017
  const res = makeRes();
1000
1018
  await handler(req, res);
@@ -1014,8 +1032,8 @@ describe("attachment routes", () => {
1014
1032
  );
1015
1033
 
1016
1034
  for (const [method, handler] of [
1017
- ["HEAD", makeStatHandler(attachments)],
1018
- ["GET", makeDownloadHandler(attachments)],
1035
+ ["HEAD", grantedStat(attachments)],
1036
+ ["GET", grantedDownload(attachments)],
1019
1037
  ] as const) {
1020
1038
  const req = makeReq({ method, params: { hash } });
1021
1039
  const res = makeRes();
@@ -1034,7 +1052,7 @@ describe("attachment routes", () => {
1034
1052
  it("GET download 202 body is empty (not a zero-byte attachment)", async () => {
1035
1053
  const { hash } = await createPendingReservation("empty body assertion");
1036
1054
 
1037
- const handler = makeDownloadHandler(attachments);
1055
+ const handler = grantedDownload(attachments);
1038
1056
  const req = makeReq({ method: "GET", params: { hash } });
1039
1057
  const res = makeRes();
1040
1058
  await handler(req, res);
@@ -1322,7 +1340,7 @@ describe("attachment routes", () => {
1322
1340
  it("download with non-ASCII fileName produces RFC 6266 Content-Disposition", async () => {
1323
1341
  const reserveHandler = makeReserveHandler(attachments);
1324
1342
  const uploadHandler = makeUploadHandler(attachments);
1325
- const downloadHandler = makeDownloadHandler(attachments);
1343
+ const downloadHandler = grantedDownload(attachments);
1326
1344
 
1327
1345
  const reserveReq = makeReq({
1328
1346
  method: "POST",
@@ -1392,8 +1410,8 @@ describe("attachment routes", () => {
1392
1410
  };
1393
1411
 
1394
1412
  for (const [method, handler] of [
1395
- ["HEAD", makeStatHandler(attachments)],
1396
- ["GET", makeDownloadHandler(attachments)],
1413
+ ["HEAD", grantedStat(attachments)],
1414
+ ["GET", grantedDownload(attachments)],
1397
1415
  ] as const) {
1398
1416
  const res = makeRes();
1399
1417
  await handler(makeReq({ method, params: { hash } }), res);
@@ -1,5 +1,5 @@
1
1
  import { PGlite } from "@electric-sql/pglite";
2
- import type { API } from "@powerhousedao/reactor-api";
2
+ import type { API, IAttachmentAccessService } from "@powerhousedao/reactor-api";
3
3
  import { createHttpAdapter } from "@powerhousedao/reactor-api";
4
4
  import {
5
5
  AttachmentBuilder,
@@ -109,10 +109,20 @@ describe("attachment service built from deriveAttachmentServiceConfig round-trip
109
109
  registerAttachmentRoutes({
110
110
  httpAdapter: adapter,
111
111
  attachments,
112
+ attachmentAccess: {
113
+ canReadAttachment: (
114
+ request: Parameters<IAttachmentAccessService["canReadAttachment"]>[0],
115
+ ) =>
116
+ Promise.resolve({
117
+ kind: "allowed",
118
+ documentId: request.documentId as never,
119
+ ref: request.attachmentRef as never,
120
+ }),
121
+ },
112
122
  authService: undefined,
113
123
  } as unknown as API);
114
124
 
115
- server = await adapter.listen(0);
125
+ server = await adapter.listen(0, undefined, "127.0.0.1");
116
126
  const addr = server.address();
117
127
  if (!addr || typeof addr === "string") throw new Error("no addr");
118
128
  port = addr.port;
@@ -149,7 +159,7 @@ describe("attachment service built from deriveAttachmentServiceConfig round-trip
149
159
  const result = await upload.send(stream);
150
160
  expect(result.header.sizeBytes).toBe(bytes.byteLength);
151
161
 
152
- const got = await service.get(result.ref);
162
+ const got = await service.get(result.ref, { documentId: "doc-1" });
153
163
  expect(got.header.mimeType).toBe("text/plain");
154
164
  const reader = got.body.getReader();
155
165
  const chunks: Uint8Array[] = [];