@powerhousedao/switchboard 6.2.3-dev.25 → 6.2.3-dev.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/Auth.md +4 -2
- package/CHANGELOG.md +46 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-C3WaP3kE.mjs → server-DQCIIJzK.mjs} +184 -28
- package/dist/server-DQCIIJzK.mjs.map +1 -0
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +17 -17
- package/test/attachments/byte-grants.test.ts +287 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +124 -24
- package/test/attachments/routes-integration.test.ts +20 -5
- package/test/attachments/routes.test.ts +43 -25
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/workflow/harness.ts +1 -1
- package/test/workflow-read-gate.test.ts +145 -0
- package/dist/server-C3WaP3kE.mjs.map +0 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
392
|
+
const statHandler = grantedStat(attachments);
|
|
377
393
|
const server = createServer((req, res) => {
|
|
378
|
-
const m = /^\/attachments\/([
|
|
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) =>
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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",
|
|
1018
|
-
["GET",
|
|
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 =
|
|
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 =
|
|
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",
|
|
1396
|
-
["GET",
|
|
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[] = [];
|