@powerhousedao/switchboard 6.2.3-dev.9 → 6.2.3
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/boot-create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
- package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +56 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.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/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +94 -0
- package/CHANGELOG.md +302 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +10 -8
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/{server-CiKkVE6g.mjs → server-sQoWp21G.mjs} +1454 -70
- package/dist/server-sQoWp21G.mjs.map +1 -0
- package/dist/server.d.mts +103 -29
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +4 -5
- package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
- package/dist/utils-uaBlzyjS.mjs.map +1 -0
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +21 -17
- package/test/attachments/auth.test.ts +85 -0
- 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 +194 -31
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/routes-integration.test.ts +59 -4
- package/test/attachments/routes.test.ts +165 -23
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/boot-create-signature-policy.test.ts +108 -0
- package/test/boot-mcp-enabled.test.ts +121 -0
- package/test/boot-unsupported-stored-documents.test.ts +210 -0
- package/test/boot-without-renown.test.ts +81 -0
- package/test/boot-without-require-signatures.test.ts +74 -0
- package/test/boot-worker-trust-policy.test.ts +90 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/fatal-shutdown.test.ts +102 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +296 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-trigger-test.test.ts +82 -0
- package/test/workflow/webhook-unsigned.test.ts +345 -0
- package/test/workflow-read-gate.test.ts +145 -0
- package/test/workflow-runtime.test.ts +486 -0
- package/tsconfig.json +6 -0
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -6,13 +6,20 @@ import {
|
|
|
6
6
|
import { Kysely } from "kysely";
|
|
7
7
|
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
8
8
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
validateHeaderValue,
|
|
11
|
+
type IncomingMessage,
|
|
12
|
+
type ServerResponse,
|
|
13
|
+
} from "node:http";
|
|
10
14
|
import { tmpdir } from "node:os";
|
|
11
15
|
import { join } from "node:path";
|
|
12
16
|
import { Readable, Writable } from "node:stream";
|
|
17
|
+
import type { IAttachmentAccessService } from "@powerhousedao/reactor-api";
|
|
13
18
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
14
19
|
import {
|
|
15
20
|
buildContentDisposition,
|
|
21
|
+
buildMetadataHeader,
|
|
22
|
+
buildPendingHeader,
|
|
16
23
|
makeDeleteReservationHandler,
|
|
17
24
|
makeDownloadHandler,
|
|
18
25
|
makeGetReservationHandler,
|
|
@@ -23,6 +30,21 @@ import {
|
|
|
23
30
|
quoteFilename,
|
|
24
31
|
} from "../../src/attachments/routes.js";
|
|
25
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
|
+
|
|
26
48
|
type CapturedRes = ServerResponse & {
|
|
27
49
|
_headers: Record<string, string>;
|
|
28
50
|
_body: Buffer;
|
|
@@ -46,7 +68,7 @@ function makeReq(opts: {
|
|
|
46
68
|
params?: Record<string, string>;
|
|
47
69
|
};
|
|
48
70
|
req.method = opts.method;
|
|
49
|
-
req.url = opts.url ?? "
|
|
71
|
+
req.url = opts.url ?? "/?documentId=doc-1";
|
|
50
72
|
req.headers = {};
|
|
51
73
|
if (opts.params) req.params = opts.params;
|
|
52
74
|
return req as unknown as IncomingMessage;
|
|
@@ -70,6 +92,9 @@ function makeRes(): CapturedRes {
|
|
|
70
92
|
statusCode: 200,
|
|
71
93
|
_headers: headers,
|
|
72
94
|
setHeader(name: string, value: string | number | readonly string[]) {
|
|
95
|
+
// Same check Node's ServerResponse applies, so a header value it would
|
|
96
|
+
// reject fails here instead of passing silently.
|
|
97
|
+
validateHeaderValue(name, String(value));
|
|
73
98
|
headers[name.toLowerCase()] = String(value);
|
|
74
99
|
},
|
|
75
100
|
getHeader(name: string) {
|
|
@@ -213,7 +238,7 @@ describe("attachment routes", () => {
|
|
|
213
238
|
expect(row!.deleted_at_utc).not.toBeNull();
|
|
214
239
|
|
|
215
240
|
// Download
|
|
216
|
-
const downloadHandler =
|
|
241
|
+
const downloadHandler = grantedDownload(attachments);
|
|
217
242
|
const downloadReq = makeReq({
|
|
218
243
|
method: "GET",
|
|
219
244
|
params: { hash: upload.hash },
|
|
@@ -262,7 +287,7 @@ describe("attachment routes", () => {
|
|
|
262
287
|
hash: string;
|
|
263
288
|
};
|
|
264
289
|
|
|
265
|
-
const downloadHandler =
|
|
290
|
+
const downloadHandler = grantedDownload(attachments);
|
|
266
291
|
const downloadReq = makeReq({
|
|
267
292
|
method: "GET",
|
|
268
293
|
params: { hash: upload.hash },
|
|
@@ -305,7 +330,7 @@ describe("attachment routes", () => {
|
|
|
305
330
|
hash: string;
|
|
306
331
|
};
|
|
307
332
|
|
|
308
|
-
const statHandler =
|
|
333
|
+
const statHandler = grantedStat(attachments);
|
|
309
334
|
const statReq = makeReq({
|
|
310
335
|
method: "HEAD",
|
|
311
336
|
params: { hash: upload.hash },
|
|
@@ -364,9 +389,9 @@ describe("attachment routes", () => {
|
|
|
364
389
|
};
|
|
365
390
|
|
|
366
391
|
const { createServer } = await import("node:http");
|
|
367
|
-
const statHandler =
|
|
392
|
+
const statHandler = grantedStat(attachments);
|
|
368
393
|
const server = createServer((req, res) => {
|
|
369
|
-
const m = /^\/attachments\/([
|
|
394
|
+
const m = /^\/attachments\/([^/?]+)(?:\?|$)/.exec(req.url ?? "/");
|
|
370
395
|
if (!m) {
|
|
371
396
|
res.statusCode = 404;
|
|
372
397
|
res.end();
|
|
@@ -377,12 +402,14 @@ describe("attachment routes", () => {
|
|
|
377
402
|
};
|
|
378
403
|
void statHandler(req, res);
|
|
379
404
|
});
|
|
380
|
-
await new Promise<void>((resolve) =>
|
|
405
|
+
await new Promise<void>((resolve) =>
|
|
406
|
+
server.listen(0, "127.0.0.1", resolve),
|
|
407
|
+
);
|
|
381
408
|
const port = (server.address() as { port: number }).port;
|
|
382
409
|
|
|
383
410
|
try {
|
|
384
411
|
const response = await fetch(
|
|
385
|
-
`http://127.0.0.1:${port}/attachments/${upload.hash}`,
|
|
412
|
+
`http://127.0.0.1:${port}/attachments/${upload.hash}?documentId=doc-1`,
|
|
386
413
|
{
|
|
387
414
|
method: "HEAD",
|
|
388
415
|
},
|
|
@@ -403,7 +430,7 @@ describe("attachment routes", () => {
|
|
|
403
430
|
});
|
|
404
431
|
|
|
405
432
|
it("HEAD stat returns 404 for unknown hash", async () => {
|
|
406
|
-
const handler =
|
|
433
|
+
const handler = grantedStat(attachments);
|
|
407
434
|
const req = makeReq({ method: "HEAD", params: { hash: "a".repeat(64) } });
|
|
408
435
|
const res = makeRes();
|
|
409
436
|
await handler(req, res);
|
|
@@ -412,7 +439,7 @@ describe("attachment routes", () => {
|
|
|
412
439
|
});
|
|
413
440
|
|
|
414
441
|
it("HEAD stat returns 400 for malformed hash", async () => {
|
|
415
|
-
const handler =
|
|
442
|
+
const handler = grantedStat(attachments);
|
|
416
443
|
const req = makeReq({ method: "HEAD", params: { hash: "not-a-hash" } });
|
|
417
444
|
const res = makeRes();
|
|
418
445
|
await handler(req, res);
|
|
@@ -514,7 +541,7 @@ describe("attachment routes", () => {
|
|
|
514
541
|
});
|
|
515
542
|
|
|
516
543
|
it("GET download returns 404 for unknown hash", async () => {
|
|
517
|
-
const handler =
|
|
544
|
+
const handler = grantedDownload(attachments);
|
|
518
545
|
const req = makeReq({
|
|
519
546
|
method: "GET",
|
|
520
547
|
params: { hash: "a".repeat(64) },
|
|
@@ -526,7 +553,7 @@ describe("attachment routes", () => {
|
|
|
526
553
|
});
|
|
527
554
|
|
|
528
555
|
it("GET download returns 400 for malformed hash", async () => {
|
|
529
|
-
const handler =
|
|
556
|
+
const handler = grantedDownload(attachments);
|
|
530
557
|
const req = makeReq({
|
|
531
558
|
method: "GET",
|
|
532
559
|
params: { hash: "not-a-hash" },
|
|
@@ -565,7 +592,7 @@ describe("attachment routes", () => {
|
|
|
565
592
|
hash: string;
|
|
566
593
|
};
|
|
567
594
|
|
|
568
|
-
const handler =
|
|
595
|
+
const handler = grantedDownload(attachments);
|
|
569
596
|
const req = makeReq({
|
|
570
597
|
method: "GET",
|
|
571
598
|
params: { hash: upload.hash.toUpperCase() },
|
|
@@ -603,7 +630,7 @@ describe("attachment routes", () => {
|
|
|
603
630
|
hash: string;
|
|
604
631
|
};
|
|
605
632
|
|
|
606
|
-
const statHandler =
|
|
633
|
+
const statHandler = grantedStat(attachments);
|
|
607
634
|
const statReq = makeReq({
|
|
608
635
|
method: "HEAD",
|
|
609
636
|
params: { hash: upload.hash.toUpperCase() },
|
|
@@ -651,7 +678,7 @@ describe("attachment routes", () => {
|
|
|
651
678
|
throw new Error(secret);
|
|
652
679
|
};
|
|
653
680
|
try {
|
|
654
|
-
const handler =
|
|
681
|
+
const handler = grantedDownload(attachments);
|
|
655
682
|
const req = makeReq({
|
|
656
683
|
method: "GET",
|
|
657
684
|
params: { hash: "a".repeat(64) },
|
|
@@ -899,6 +926,7 @@ describe("attachment routes", () => {
|
|
|
899
926
|
describe("pending state: stat and download 202 responses", () => {
|
|
900
927
|
async function createPendingReservation(
|
|
901
928
|
content: string,
|
|
929
|
+
fileName = "pending.txt",
|
|
902
930
|
): Promise<{ hash: string }> {
|
|
903
931
|
const { createHash } = await import("node:crypto");
|
|
904
932
|
const hash = createHash("sha256").update(content, "utf8").digest("hex");
|
|
@@ -908,7 +936,7 @@ describe("attachment routes", () => {
|
|
|
908
936
|
method: "POST",
|
|
909
937
|
body: JSON.stringify({
|
|
910
938
|
mimeType: "text/plain",
|
|
911
|
-
fileName
|
|
939
|
+
fileName,
|
|
912
940
|
clientHash: hash,
|
|
913
941
|
sizeBytes: Buffer.byteLength(content, "utf8"),
|
|
914
942
|
}),
|
|
@@ -923,7 +951,7 @@ describe("attachment routes", () => {
|
|
|
923
951
|
it("HEAD stat returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
|
|
924
952
|
const { hash } = await createPendingReservation("pending content data");
|
|
925
953
|
|
|
926
|
-
const handler =
|
|
954
|
+
const handler = grantedStat(attachments);
|
|
927
955
|
const req = makeReq({ method: "HEAD", params: { hash } });
|
|
928
956
|
const res = makeRes();
|
|
929
957
|
await handler(req, res);
|
|
@@ -948,7 +976,7 @@ describe("attachment routes", () => {
|
|
|
948
976
|
it("HEAD stat 202 for pending hash does not set Content-Disposition or Attachment-Metadata", async () => {
|
|
949
977
|
const { hash } = await createPendingReservation("no metadata content");
|
|
950
978
|
|
|
951
|
-
const handler =
|
|
979
|
+
const handler = grantedStat(attachments);
|
|
952
980
|
const req = makeReq({ method: "HEAD", params: { hash } });
|
|
953
981
|
const res = makeRes();
|
|
954
982
|
await handler(req, res);
|
|
@@ -962,7 +990,7 @@ describe("attachment routes", () => {
|
|
|
962
990
|
it("GET download returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
|
|
963
991
|
const { hash } = await createPendingReservation("download pending data");
|
|
964
992
|
|
|
965
|
-
const handler =
|
|
993
|
+
const handler = grantedDownload(attachments);
|
|
966
994
|
const req = makeReq({ method: "GET", params: { hash } });
|
|
967
995
|
const res = makeRes();
|
|
968
996
|
await handler(req, res);
|
|
@@ -984,7 +1012,7 @@ describe("attachment routes", () => {
|
|
|
984
1012
|
// the response as a successful zero-byte attachment.
|
|
985
1013
|
const { hash } = await createPendingReservation("check-headers content");
|
|
986
1014
|
|
|
987
|
-
const handler =
|
|
1015
|
+
const handler = grantedDownload(attachments);
|
|
988
1016
|
const req = makeReq({ method: "GET", params: { hash } });
|
|
989
1017
|
const res = makeRes();
|
|
990
1018
|
await handler(req, res);
|
|
@@ -995,10 +1023,36 @@ describe("attachment routes", () => {
|
|
|
995
1023
|
expect(res.getHeader("attachment-metadata")).toBeFalsy();
|
|
996
1024
|
});
|
|
997
1025
|
|
|
1026
|
+
it.each(["opłata.pdf", "a – b’s.txt", "📎.png", "報告書.pdf"])(
|
|
1027
|
+
"HEAD and GET 202 carry a parseable Attachment-Pending for %s",
|
|
1028
|
+
async (fileName) => {
|
|
1029
|
+
const { hash } = await createPendingReservation(
|
|
1030
|
+
`pending ${fileName}`,
|
|
1031
|
+
fileName,
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
for (const [method, handler] of [
|
|
1035
|
+
["HEAD", grantedStat(attachments)],
|
|
1036
|
+
["GET", grantedDownload(attachments)],
|
|
1037
|
+
] as const) {
|
|
1038
|
+
const req = makeReq({ method, params: { hash } });
|
|
1039
|
+
const res = makeRes();
|
|
1040
|
+
await handler(req, res);
|
|
1041
|
+
await waitFor(res);
|
|
1042
|
+
|
|
1043
|
+
expect(res.statusCode).toBe(202);
|
|
1044
|
+
const parsed = JSON.parse(
|
|
1045
|
+
res.getHeader("attachment-pending") as string,
|
|
1046
|
+
) as { fileName: string };
|
|
1047
|
+
expect(parsed.fileName).toBe(fileName);
|
|
1048
|
+
}
|
|
1049
|
+
},
|
|
1050
|
+
);
|
|
1051
|
+
|
|
998
1052
|
it("GET download 202 body is empty (not a zero-byte attachment)", async () => {
|
|
999
1053
|
const { hash } = await createPendingReservation("empty body assertion");
|
|
1000
1054
|
|
|
1001
|
-
const handler =
|
|
1055
|
+
const handler = grantedDownload(attachments);
|
|
1002
1056
|
const req = makeReq({ method: "GET", params: { hash } });
|
|
1003
1057
|
const res = makeRes();
|
|
1004
1058
|
await handler(req, res);
|
|
@@ -1215,6 +1269,54 @@ describe("attachment routes", () => {
|
|
|
1215
1269
|
}
|
|
1216
1270
|
});
|
|
1217
1271
|
|
|
1272
|
+
const NON_LATIN1_NAMES = [
|
|
1273
|
+
"opłata.pdf",
|
|
1274
|
+
"a – b’s.txt",
|
|
1275
|
+
"📎 notes.png",
|
|
1276
|
+
"報告書.pdf",
|
|
1277
|
+
"résumé.pdf",
|
|
1278
|
+
'quote"back\\slash.txt',
|
|
1279
|
+
];
|
|
1280
|
+
|
|
1281
|
+
it.each(NON_LATIN1_NAMES)(
|
|
1282
|
+
"buildMetadataHeader output is a valid header that parses back to %s",
|
|
1283
|
+
(fileName) => {
|
|
1284
|
+
const value = buildMetadataHeader({
|
|
1285
|
+
mimeType: "application/pdf",
|
|
1286
|
+
fileName,
|
|
1287
|
+
sizeBytes: 1,
|
|
1288
|
+
extension: null,
|
|
1289
|
+
createdAtUtc: "2026-01-01T00:00:00.000Z",
|
|
1290
|
+
lastAccessedAtUtc: "2026-01-01T00:00:00.000Z",
|
|
1291
|
+
});
|
|
1292
|
+
expect(() =>
|
|
1293
|
+
validateHeaderValue("Attachment-Metadata", value),
|
|
1294
|
+
).not.toThrow();
|
|
1295
|
+
expect(value).toMatch(/^[\x20-\x7e]*$/);
|
|
1296
|
+
expect((JSON.parse(value) as { fileName: string }).fileName).toBe(
|
|
1297
|
+
fileName,
|
|
1298
|
+
);
|
|
1299
|
+
},
|
|
1300
|
+
);
|
|
1301
|
+
|
|
1302
|
+
it.each(NON_LATIN1_NAMES)(
|
|
1303
|
+
"buildPendingHeader output is a valid header that parses back to %s",
|
|
1304
|
+
(fileName) => {
|
|
1305
|
+
const value = buildPendingHeader({
|
|
1306
|
+
expiresAtUtc: "2026-01-01T00:00:00.000Z",
|
|
1307
|
+
mimeType: "application/pdf",
|
|
1308
|
+
fileName,
|
|
1309
|
+
sizeBytes: 1,
|
|
1310
|
+
});
|
|
1311
|
+
expect(() =>
|
|
1312
|
+
validateHeaderValue("Attachment-Pending", value),
|
|
1313
|
+
).not.toThrow();
|
|
1314
|
+
expect((JSON.parse(value) as { fileName: string }).fileName).toBe(
|
|
1315
|
+
fileName,
|
|
1316
|
+
);
|
|
1317
|
+
},
|
|
1318
|
+
);
|
|
1319
|
+
|
|
1218
1320
|
it("buildContentDisposition encodes RFC-5987-reserved chars in the encoded form", () => {
|
|
1219
1321
|
const value = buildContentDisposition("a'b(c)*!.txt");
|
|
1220
1322
|
expect(value).toContain("filename*=UTF-8''a%27b%28c%29%2A%21.txt");
|
|
@@ -1238,7 +1340,7 @@ describe("attachment routes", () => {
|
|
|
1238
1340
|
it("download with non-ASCII fileName produces RFC 6266 Content-Disposition", async () => {
|
|
1239
1341
|
const reserveHandler = makeReserveHandler(attachments);
|
|
1240
1342
|
const uploadHandler = makeUploadHandler(attachments);
|
|
1241
|
-
const downloadHandler =
|
|
1343
|
+
const downloadHandler = grantedDownload(attachments);
|
|
1242
1344
|
|
|
1243
1345
|
const reserveReq = makeReq({
|
|
1244
1346
|
method: "POST",
|
|
@@ -1281,5 +1383,45 @@ describe("attachment routes", () => {
|
|
|
1281
1383
|
) as { fileName: string };
|
|
1282
1384
|
expect(meta.fileName).toBe("résumé.pdf");
|
|
1283
1385
|
});
|
|
1386
|
+
|
|
1387
|
+
it("HEAD and GET answer 200 with a parseable Attachment-Metadata for a name above U+00FF", async () => {
|
|
1388
|
+
const fileName = "opłata – 報告 📎.pdf";
|
|
1389
|
+
const reserveReq = makeReq({
|
|
1390
|
+
method: "POST",
|
|
1391
|
+
body: JSON.stringify({ mimeType: "application/pdf", fileName }),
|
|
1392
|
+
});
|
|
1393
|
+
const reserveRes = makeRes();
|
|
1394
|
+
await makeReserveHandler(attachments)(reserveReq, reserveRes);
|
|
1395
|
+
await waitFor(reserveRes);
|
|
1396
|
+
expect(reserveRes.statusCode).toBe(201);
|
|
1397
|
+
const { reservationId } = JSON.parse(
|
|
1398
|
+
reserveRes._body.toString("utf8"),
|
|
1399
|
+
) as { reservationId: string };
|
|
1400
|
+
|
|
1401
|
+
const uploadRes = makeRes();
|
|
1402
|
+
await makeUploadHandler(attachments)(
|
|
1403
|
+
makeReq({ method: "PUT", params: { reservationId }, body: "pdf" }),
|
|
1404
|
+
uploadRes,
|
|
1405
|
+
);
|
|
1406
|
+
await waitFor(uploadRes);
|
|
1407
|
+
expect(uploadRes.statusCode).toBe(200);
|
|
1408
|
+
const { hash } = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
1409
|
+
hash: string;
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
for (const [method, handler] of [
|
|
1413
|
+
["HEAD", grantedStat(attachments)],
|
|
1414
|
+
["GET", grantedDownload(attachments)],
|
|
1415
|
+
] as const) {
|
|
1416
|
+
const res = makeRes();
|
|
1417
|
+
await handler(makeReq({ method, params: { hash } }), res);
|
|
1418
|
+
await waitFor(res);
|
|
1419
|
+
expect(res.statusCode).toBe(200);
|
|
1420
|
+
const meta = JSON.parse(
|
|
1421
|
+
res.getHeader("attachment-metadata") as string,
|
|
1422
|
+
) as { fileName: string };
|
|
1423
|
+
expect(meta.fileName).toBe(fileName);
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1284
1426
|
});
|
|
1285
1427
|
});
|
|
@@ -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[] = [];
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { AttachmentHash } from "@powerhousedao/reactor";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
ATTACHMENT_URL_SIGNING_SECRET_ENV,
|
|
5
|
+
AttachmentUrlSigner,
|
|
6
|
+
resolveAttachmentUrlSigning,
|
|
7
|
+
} from "../../src/attachments/url-signer.js";
|
|
8
|
+
|
|
9
|
+
const HASH = "a".repeat(64) as AttachmentHash;
|
|
10
|
+
const OTHER_HASH = "b".repeat(64) as AttachmentHash;
|
|
11
|
+
const SECRET = "k".repeat(32);
|
|
12
|
+
|
|
13
|
+
function signed(now: number, ttl = 300) {
|
|
14
|
+
const signer = new AttachmentUrlSigner(SECRET, () => now);
|
|
15
|
+
const { query, expiresAtUtc } = signer.sign(HASH, "doc-1", ttl);
|
|
16
|
+
const params = new URLSearchParams(query);
|
|
17
|
+
return {
|
|
18
|
+
signer,
|
|
19
|
+
expiresAtUtc,
|
|
20
|
+
documentId: params.get("documentId")!,
|
|
21
|
+
expires: params.get("expires")!,
|
|
22
|
+
signature: params.get("signature")!,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("AttachmentUrlSigner", () => {
|
|
27
|
+
const NOW = Date.parse("2026-09-24T00:00:00.000Z");
|
|
28
|
+
|
|
29
|
+
it("verifies what it signed until the expiry", () => {
|
|
30
|
+
const { signer, expiresAtUtc, documentId, expires, signature } =
|
|
31
|
+
signed(NOW);
|
|
32
|
+
|
|
33
|
+
expect(documentId).toBe("doc-1");
|
|
34
|
+
expect(expiresAtUtc).toBe(new Date(NOW + 300_000).toISOString());
|
|
35
|
+
expect(signer.verify(HASH, documentId, expires, signature)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("refuses at and after the expiry", () => {
|
|
39
|
+
const { expires, signature } = signed(NOW);
|
|
40
|
+
const at = new AttachmentUrlSigner(SECRET, () => NOW + 300_000);
|
|
41
|
+
|
|
42
|
+
expect(at.verify(HASH, "doc-1", expires, signature)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("refuses a different hash, document, expiry or secret", () => {
|
|
46
|
+
const { signer, expires, signature } = signed(NOW);
|
|
47
|
+
|
|
48
|
+
expect(signer.verify(OTHER_HASH, "doc-1", expires, signature)).toBe(false);
|
|
49
|
+
expect(signer.verify(HASH, "doc-2", expires, signature)).toBe(false);
|
|
50
|
+
expect(
|
|
51
|
+
signer.verify(HASH, "doc-1", String(Number(expires) + 1), signature),
|
|
52
|
+
).toBe(false);
|
|
53
|
+
expect(
|
|
54
|
+
new AttachmentUrlSigner("x".repeat(32), () => NOW).verify(
|
|
55
|
+
HASH,
|
|
56
|
+
"doc-1",
|
|
57
|
+
expires,
|
|
58
|
+
signature,
|
|
59
|
+
),
|
|
60
|
+
).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("refuses malformed expiry and signature values", () => {
|
|
64
|
+
const { signer, expires, signature } = signed(NOW);
|
|
65
|
+
|
|
66
|
+
expect(signer.verify(HASH, "doc-1", `${expires}.0`, signature)).toBe(false);
|
|
67
|
+
expect(signer.verify(HASH, "doc-1", expires, `${signature}=`)).toBe(false);
|
|
68
|
+
expect(signer.verify(HASH, "doc-1", expires, "")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("resolveAttachmentUrlSigning", () => {
|
|
73
|
+
it("uses a configured secret", () => {
|
|
74
|
+
const signing = resolveAttachmentUrlSigning({
|
|
75
|
+
[ATTACHMENT_URL_SIGNING_SECRET_ENV]: SECRET,
|
|
76
|
+
NODE_ENV: "production",
|
|
77
|
+
});
|
|
78
|
+
expect(signing.status).toBe("configured");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("fails closed in production when unset", () => {
|
|
82
|
+
expect(resolveAttachmentUrlSigning({ NODE_ENV: "production" })).toEqual({
|
|
83
|
+
status: "unconfigured",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it.each([undefined, "development", "test"])(
|
|
88
|
+
"uses a per-process secret when unset and NODE_ENV is %s",
|
|
89
|
+
(nodeEnv) => {
|
|
90
|
+
const signing = resolveAttachmentUrlSigning({ NODE_ENV: nodeEnv });
|
|
91
|
+
expect(signing.status).toBe("ephemeral");
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it.each(["short", ` ${"k".repeat(32)}`, ""])(
|
|
96
|
+
"refuses to boot on an unusable secret %j",
|
|
97
|
+
(secret) => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
resolveAttachmentUrlSigning({
|
|
100
|
+
[ATTACHMENT_URL_SIGNING_SECRET_ENV]: secret,
|
|
101
|
+
}),
|
|
102
|
+
).toThrow(ATTACHMENT_URL_SIGNING_SECRET_ENV);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasDerivedDocumentId,
|
|
3
|
+
isDerivedDocumentId,
|
|
4
|
+
signaturePolicyOf,
|
|
5
|
+
} from "@powerhousedao/shared/document-model";
|
|
6
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { ILogger } from "document-model";
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { startSwitchboard } from "../src/server.mjs";
|
|
12
|
+
|
|
13
|
+
function stubLogger(): ILogger {
|
|
14
|
+
const logger = {
|
|
15
|
+
verbose: vi.fn(),
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
warn: vi.fn(),
|
|
19
|
+
error: vi.fn(),
|
|
20
|
+
child: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
logger.child.mockReturnValue(logger);
|
|
23
|
+
return logger as unknown as ILogger;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function withSwitchboard(
|
|
27
|
+
env: Record<string, string>,
|
|
28
|
+
run: (
|
|
29
|
+
switchboard: Awaited<ReturnType<typeof startSwitchboard>>,
|
|
30
|
+
) => Promise<void>,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-create-policy-"));
|
|
33
|
+
const names = ["PH_REACTOR_DATABASE_URL", ...Object.keys(env)];
|
|
34
|
+
const previous = names.map((name) => [name, process.env[name]] as const);
|
|
35
|
+
process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
|
|
36
|
+
Object.assign(process.env, env);
|
|
37
|
+
let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
switchboard = await startSwitchboard({
|
|
41
|
+
workflows: { enabled: false },
|
|
42
|
+
dbPath: join(tempRoot, "read-model"),
|
|
43
|
+
port: 0,
|
|
44
|
+
mcp: false,
|
|
45
|
+
disableLocalPackages: true,
|
|
46
|
+
identity: { keypairPath: join(tempRoot, "identity.json") },
|
|
47
|
+
drive: {
|
|
48
|
+
slug: "default-drive",
|
|
49
|
+
global: { name: "Default Drive" },
|
|
50
|
+
},
|
|
51
|
+
logger: stubLogger(),
|
|
52
|
+
});
|
|
53
|
+
await run(switchboard);
|
|
54
|
+
await switchboard.shutdown();
|
|
55
|
+
switchboard = undefined;
|
|
56
|
+
} finally {
|
|
57
|
+
await switchboard?.shutdown();
|
|
58
|
+
for (const [name, value] of previous) {
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
delete process.env[name];
|
|
61
|
+
} else {
|
|
62
|
+
process.env[name] = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("booting Switchboard", () => {
|
|
70
|
+
it("creates v2-required documents and drives by default", async () => {
|
|
71
|
+
await withSwitchboard({}, async ({ reactor }) => {
|
|
72
|
+
const document = await reactor.createEmpty("powerhouse/document-model");
|
|
73
|
+
expect(signaturePolicyOf(document.header)).toBe("v2-required");
|
|
74
|
+
expect(hasDerivedDocumentId(document.header)).toBe(true);
|
|
75
|
+
|
|
76
|
+
const drive = await reactor.drives.create({ global: { name: "Drive" } });
|
|
77
|
+
expect(signaturePolicyOf(drive.header)).toBe("v2-required");
|
|
78
|
+
|
|
79
|
+
const defaultDrive = await reactor.get("default-drive");
|
|
80
|
+
expect(signaturePolicyOf(defaultDrive.header)).toBe("v2-required");
|
|
81
|
+
});
|
|
82
|
+
}, 60_000);
|
|
83
|
+
|
|
84
|
+
it("creates legacy documents and drives under CREATE_SIGNATURE_POLICY=legacy", async () => {
|
|
85
|
+
await withSwitchboard(
|
|
86
|
+
{ CREATE_SIGNATURE_POLICY: "legacy" },
|
|
87
|
+
async ({ reactor }) => {
|
|
88
|
+
const document = await reactor.createEmpty("powerhouse/document-model");
|
|
89
|
+
expect(signaturePolicyOf(document.header)).toBe("legacy");
|
|
90
|
+
expect(isDerivedDocumentId(document.header.id)).toBe(false);
|
|
91
|
+
|
|
92
|
+
const drive = await reactor.drives.create({
|
|
93
|
+
global: { name: "Drive" },
|
|
94
|
+
});
|
|
95
|
+
expect(signaturePolicyOf(drive.header)).toBe("legacy");
|
|
96
|
+
expect(isDerivedDocumentId(drive.header.id)).toBe(false);
|
|
97
|
+
|
|
98
|
+
const defaultDrive = await reactor.get("default-drive");
|
|
99
|
+
expect(signaturePolicyOf(defaultDrive.header)).toBe("legacy");
|
|
100
|
+
|
|
101
|
+
const asked = await reactor.createEmpty("powerhouse/document-model", {
|
|
102
|
+
signaturePolicy: "v2-required",
|
|
103
|
+
});
|
|
104
|
+
expect(hasDerivedDocumentId(asked.header)).toBe(true);
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
}, 60_000);
|
|
108
|
+
});
|