@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.51
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/reserve-metadata-parity.test.d.ts +2 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.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-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.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/boot-workflow-singleton-conflict.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.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/durable-node-fs-pg16.test.d.ts +2 -0
- package/.tsbuild/test/durable-node-fs-pg16.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/observability-signals.test.d.ts +2 -0
- package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
- package/.tsbuild/test/pglite-preflight.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/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.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-reactor-access.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-enforcement.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 +541 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +48 -49
- 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-J9fpd42v.mjs +3549 -0
- package/dist/server-J9fpd42v.mjs.map +1 -0
- package/dist/server.d.mts +155 -30
- 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 +22 -18
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +290 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +201 -32
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +60 -4
- package/test/attachments/routes.test.ts +166 -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-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +213 -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/boot-workflow-singleton-conflict.test.ts +107 -0
- package/test/boot-workflow-singleton-retry.test.ts +133 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +165 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +217 -2
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/snapshot-writer.ts +94 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +357 -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-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +808 -0
- package/test/workflow-read-gate.test.ts +213 -0
- package/test/workflow-runtime.test.ts +1067 -0
- package/tsconfig.json +9 -3
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs +0 -1555
- 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,22 @@ 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
|
+
admitCaller: () => Promise.resolve({ kind: "admitted" }),
|
|
43
|
+
};
|
|
44
|
+
const grantedDownload = (attachments: AttachmentBuildResult) =>
|
|
45
|
+
makeDownloadHandler(attachments, ALLOW_ALL, null);
|
|
46
|
+
const grantedStat = (attachments: AttachmentBuildResult) =>
|
|
47
|
+
makeStatHandler(attachments, ALLOW_ALL, null);
|
|
48
|
+
|
|
26
49
|
type CapturedRes = ServerResponse & {
|
|
27
50
|
_headers: Record<string, string>;
|
|
28
51
|
_body: Buffer;
|
|
@@ -46,7 +69,7 @@ function makeReq(opts: {
|
|
|
46
69
|
params?: Record<string, string>;
|
|
47
70
|
};
|
|
48
71
|
req.method = opts.method;
|
|
49
|
-
req.url = opts.url ?? "
|
|
72
|
+
req.url = opts.url ?? "/?documentId=doc-1";
|
|
50
73
|
req.headers = {};
|
|
51
74
|
if (opts.params) req.params = opts.params;
|
|
52
75
|
return req as unknown as IncomingMessage;
|
|
@@ -70,6 +93,9 @@ function makeRes(): CapturedRes {
|
|
|
70
93
|
statusCode: 200,
|
|
71
94
|
_headers: headers,
|
|
72
95
|
setHeader(name: string, value: string | number | readonly string[]) {
|
|
96
|
+
// Same check Node's ServerResponse applies, so a header value it would
|
|
97
|
+
// reject fails here instead of passing silently.
|
|
98
|
+
validateHeaderValue(name, String(value));
|
|
73
99
|
headers[name.toLowerCase()] = String(value);
|
|
74
100
|
},
|
|
75
101
|
getHeader(name: string) {
|
|
@@ -213,7 +239,7 @@ describe("attachment routes", () => {
|
|
|
213
239
|
expect(row!.deleted_at_utc).not.toBeNull();
|
|
214
240
|
|
|
215
241
|
// Download
|
|
216
|
-
const downloadHandler =
|
|
242
|
+
const downloadHandler = grantedDownload(attachments);
|
|
217
243
|
const downloadReq = makeReq({
|
|
218
244
|
method: "GET",
|
|
219
245
|
params: { hash: upload.hash },
|
|
@@ -262,7 +288,7 @@ describe("attachment routes", () => {
|
|
|
262
288
|
hash: string;
|
|
263
289
|
};
|
|
264
290
|
|
|
265
|
-
const downloadHandler =
|
|
291
|
+
const downloadHandler = grantedDownload(attachments);
|
|
266
292
|
const downloadReq = makeReq({
|
|
267
293
|
method: "GET",
|
|
268
294
|
params: { hash: upload.hash },
|
|
@@ -305,7 +331,7 @@ describe("attachment routes", () => {
|
|
|
305
331
|
hash: string;
|
|
306
332
|
};
|
|
307
333
|
|
|
308
|
-
const statHandler =
|
|
334
|
+
const statHandler = grantedStat(attachments);
|
|
309
335
|
const statReq = makeReq({
|
|
310
336
|
method: "HEAD",
|
|
311
337
|
params: { hash: upload.hash },
|
|
@@ -364,9 +390,9 @@ describe("attachment routes", () => {
|
|
|
364
390
|
};
|
|
365
391
|
|
|
366
392
|
const { createServer } = await import("node:http");
|
|
367
|
-
const statHandler =
|
|
393
|
+
const statHandler = grantedStat(attachments);
|
|
368
394
|
const server = createServer((req, res) => {
|
|
369
|
-
const m = /^\/attachments\/([
|
|
395
|
+
const m = /^\/attachments\/([^/?]+)(?:\?|$)/.exec(req.url ?? "/");
|
|
370
396
|
if (!m) {
|
|
371
397
|
res.statusCode = 404;
|
|
372
398
|
res.end();
|
|
@@ -377,12 +403,14 @@ describe("attachment routes", () => {
|
|
|
377
403
|
};
|
|
378
404
|
void statHandler(req, res);
|
|
379
405
|
});
|
|
380
|
-
await new Promise<void>((resolve) =>
|
|
406
|
+
await new Promise<void>((resolve) =>
|
|
407
|
+
server.listen(0, "127.0.0.1", resolve),
|
|
408
|
+
);
|
|
381
409
|
const port = (server.address() as { port: number }).port;
|
|
382
410
|
|
|
383
411
|
try {
|
|
384
412
|
const response = await fetch(
|
|
385
|
-
`http://127.0.0.1:${port}/attachments/${upload.hash}`,
|
|
413
|
+
`http://127.0.0.1:${port}/attachments/${upload.hash}?documentId=doc-1`,
|
|
386
414
|
{
|
|
387
415
|
method: "HEAD",
|
|
388
416
|
},
|
|
@@ -403,7 +431,7 @@ describe("attachment routes", () => {
|
|
|
403
431
|
});
|
|
404
432
|
|
|
405
433
|
it("HEAD stat returns 404 for unknown hash", async () => {
|
|
406
|
-
const handler =
|
|
434
|
+
const handler = grantedStat(attachments);
|
|
407
435
|
const req = makeReq({ method: "HEAD", params: { hash: "a".repeat(64) } });
|
|
408
436
|
const res = makeRes();
|
|
409
437
|
await handler(req, res);
|
|
@@ -412,7 +440,7 @@ describe("attachment routes", () => {
|
|
|
412
440
|
});
|
|
413
441
|
|
|
414
442
|
it("HEAD stat returns 400 for malformed hash", async () => {
|
|
415
|
-
const handler =
|
|
443
|
+
const handler = grantedStat(attachments);
|
|
416
444
|
const req = makeReq({ method: "HEAD", params: { hash: "not-a-hash" } });
|
|
417
445
|
const res = makeRes();
|
|
418
446
|
await handler(req, res);
|
|
@@ -514,7 +542,7 @@ describe("attachment routes", () => {
|
|
|
514
542
|
});
|
|
515
543
|
|
|
516
544
|
it("GET download returns 404 for unknown hash", async () => {
|
|
517
|
-
const handler =
|
|
545
|
+
const handler = grantedDownload(attachments);
|
|
518
546
|
const req = makeReq({
|
|
519
547
|
method: "GET",
|
|
520
548
|
params: { hash: "a".repeat(64) },
|
|
@@ -526,7 +554,7 @@ describe("attachment routes", () => {
|
|
|
526
554
|
});
|
|
527
555
|
|
|
528
556
|
it("GET download returns 400 for malformed hash", async () => {
|
|
529
|
-
const handler =
|
|
557
|
+
const handler = grantedDownload(attachments);
|
|
530
558
|
const req = makeReq({
|
|
531
559
|
method: "GET",
|
|
532
560
|
params: { hash: "not-a-hash" },
|
|
@@ -565,7 +593,7 @@ describe("attachment routes", () => {
|
|
|
565
593
|
hash: string;
|
|
566
594
|
};
|
|
567
595
|
|
|
568
|
-
const handler =
|
|
596
|
+
const handler = grantedDownload(attachments);
|
|
569
597
|
const req = makeReq({
|
|
570
598
|
method: "GET",
|
|
571
599
|
params: { hash: upload.hash.toUpperCase() },
|
|
@@ -603,7 +631,7 @@ describe("attachment routes", () => {
|
|
|
603
631
|
hash: string;
|
|
604
632
|
};
|
|
605
633
|
|
|
606
|
-
const statHandler =
|
|
634
|
+
const statHandler = grantedStat(attachments);
|
|
607
635
|
const statReq = makeReq({
|
|
608
636
|
method: "HEAD",
|
|
609
637
|
params: { hash: upload.hash.toUpperCase() },
|
|
@@ -651,7 +679,7 @@ describe("attachment routes", () => {
|
|
|
651
679
|
throw new Error(secret);
|
|
652
680
|
};
|
|
653
681
|
try {
|
|
654
|
-
const handler =
|
|
682
|
+
const handler = grantedDownload(attachments);
|
|
655
683
|
const req = makeReq({
|
|
656
684
|
method: "GET",
|
|
657
685
|
params: { hash: "a".repeat(64) },
|
|
@@ -899,6 +927,7 @@ describe("attachment routes", () => {
|
|
|
899
927
|
describe("pending state: stat and download 202 responses", () => {
|
|
900
928
|
async function createPendingReservation(
|
|
901
929
|
content: string,
|
|
930
|
+
fileName = "pending.txt",
|
|
902
931
|
): Promise<{ hash: string }> {
|
|
903
932
|
const { createHash } = await import("node:crypto");
|
|
904
933
|
const hash = createHash("sha256").update(content, "utf8").digest("hex");
|
|
@@ -908,7 +937,7 @@ describe("attachment routes", () => {
|
|
|
908
937
|
method: "POST",
|
|
909
938
|
body: JSON.stringify({
|
|
910
939
|
mimeType: "text/plain",
|
|
911
|
-
fileName
|
|
940
|
+
fileName,
|
|
912
941
|
clientHash: hash,
|
|
913
942
|
sizeBytes: Buffer.byteLength(content, "utf8"),
|
|
914
943
|
}),
|
|
@@ -923,7 +952,7 @@ describe("attachment routes", () => {
|
|
|
923
952
|
it("HEAD stat returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
|
|
924
953
|
const { hash } = await createPendingReservation("pending content data");
|
|
925
954
|
|
|
926
|
-
const handler =
|
|
955
|
+
const handler = grantedStat(attachments);
|
|
927
956
|
const req = makeReq({ method: "HEAD", params: { hash } });
|
|
928
957
|
const res = makeRes();
|
|
929
958
|
await handler(req, res);
|
|
@@ -948,7 +977,7 @@ describe("attachment routes", () => {
|
|
|
948
977
|
it("HEAD stat 202 for pending hash does not set Content-Disposition or Attachment-Metadata", async () => {
|
|
949
978
|
const { hash } = await createPendingReservation("no metadata content");
|
|
950
979
|
|
|
951
|
-
const handler =
|
|
980
|
+
const handler = grantedStat(attachments);
|
|
952
981
|
const req = makeReq({ method: "HEAD", params: { hash } });
|
|
953
982
|
const res = makeRes();
|
|
954
983
|
await handler(req, res);
|
|
@@ -962,7 +991,7 @@ describe("attachment routes", () => {
|
|
|
962
991
|
it("GET download returns 202 with Retry-After and Attachment-Pending for pending hash", async () => {
|
|
963
992
|
const { hash } = await createPendingReservation("download pending data");
|
|
964
993
|
|
|
965
|
-
const handler =
|
|
994
|
+
const handler = grantedDownload(attachments);
|
|
966
995
|
const req = makeReq({ method: "GET", params: { hash } });
|
|
967
996
|
const res = makeRes();
|
|
968
997
|
await handler(req, res);
|
|
@@ -984,7 +1013,7 @@ describe("attachment routes", () => {
|
|
|
984
1013
|
// the response as a successful zero-byte attachment.
|
|
985
1014
|
const { hash } = await createPendingReservation("check-headers content");
|
|
986
1015
|
|
|
987
|
-
const handler =
|
|
1016
|
+
const handler = grantedDownload(attachments);
|
|
988
1017
|
const req = makeReq({ method: "GET", params: { hash } });
|
|
989
1018
|
const res = makeRes();
|
|
990
1019
|
await handler(req, res);
|
|
@@ -995,10 +1024,36 @@ describe("attachment routes", () => {
|
|
|
995
1024
|
expect(res.getHeader("attachment-metadata")).toBeFalsy();
|
|
996
1025
|
});
|
|
997
1026
|
|
|
1027
|
+
it.each(["opłata.pdf", "a – b’s.txt", "📎.png", "報告書.pdf"])(
|
|
1028
|
+
"HEAD and GET 202 carry a parseable Attachment-Pending for %s",
|
|
1029
|
+
async (fileName) => {
|
|
1030
|
+
const { hash } = await createPendingReservation(
|
|
1031
|
+
`pending ${fileName}`,
|
|
1032
|
+
fileName,
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
for (const [method, handler] of [
|
|
1036
|
+
["HEAD", grantedStat(attachments)],
|
|
1037
|
+
["GET", grantedDownload(attachments)],
|
|
1038
|
+
] as const) {
|
|
1039
|
+
const req = makeReq({ method, params: { hash } });
|
|
1040
|
+
const res = makeRes();
|
|
1041
|
+
await handler(req, res);
|
|
1042
|
+
await waitFor(res);
|
|
1043
|
+
|
|
1044
|
+
expect(res.statusCode).toBe(202);
|
|
1045
|
+
const parsed = JSON.parse(
|
|
1046
|
+
res.getHeader("attachment-pending") as string,
|
|
1047
|
+
) as { fileName: string };
|
|
1048
|
+
expect(parsed.fileName).toBe(fileName);
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
);
|
|
1052
|
+
|
|
998
1053
|
it("GET download 202 body is empty (not a zero-byte attachment)", async () => {
|
|
999
1054
|
const { hash } = await createPendingReservation("empty body assertion");
|
|
1000
1055
|
|
|
1001
|
-
const handler =
|
|
1056
|
+
const handler = grantedDownload(attachments);
|
|
1002
1057
|
const req = makeReq({ method: "GET", params: { hash } });
|
|
1003
1058
|
const res = makeRes();
|
|
1004
1059
|
await handler(req, res);
|
|
@@ -1215,6 +1270,54 @@ describe("attachment routes", () => {
|
|
|
1215
1270
|
}
|
|
1216
1271
|
});
|
|
1217
1272
|
|
|
1273
|
+
const NON_LATIN1_NAMES = [
|
|
1274
|
+
"opłata.pdf",
|
|
1275
|
+
"a – b’s.txt",
|
|
1276
|
+
"📎 notes.png",
|
|
1277
|
+
"報告書.pdf",
|
|
1278
|
+
"résumé.pdf",
|
|
1279
|
+
'quote"back\\slash.txt',
|
|
1280
|
+
];
|
|
1281
|
+
|
|
1282
|
+
it.each(NON_LATIN1_NAMES)(
|
|
1283
|
+
"buildMetadataHeader output is a valid header that parses back to %s",
|
|
1284
|
+
(fileName) => {
|
|
1285
|
+
const value = buildMetadataHeader({
|
|
1286
|
+
mimeType: "application/pdf",
|
|
1287
|
+
fileName,
|
|
1288
|
+
sizeBytes: 1,
|
|
1289
|
+
extension: null,
|
|
1290
|
+
createdAtUtc: "2026-01-01T00:00:00.000Z",
|
|
1291
|
+
lastAccessedAtUtc: "2026-01-01T00:00:00.000Z",
|
|
1292
|
+
});
|
|
1293
|
+
expect(() =>
|
|
1294
|
+
validateHeaderValue("Attachment-Metadata", value),
|
|
1295
|
+
).not.toThrow();
|
|
1296
|
+
expect(value).toMatch(/^[\x20-\x7e]*$/);
|
|
1297
|
+
expect((JSON.parse(value) as { fileName: string }).fileName).toBe(
|
|
1298
|
+
fileName,
|
|
1299
|
+
);
|
|
1300
|
+
},
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
it.each(NON_LATIN1_NAMES)(
|
|
1304
|
+
"buildPendingHeader output is a valid header that parses back to %s",
|
|
1305
|
+
(fileName) => {
|
|
1306
|
+
const value = buildPendingHeader({
|
|
1307
|
+
expiresAtUtc: "2026-01-01T00:00:00.000Z",
|
|
1308
|
+
mimeType: "application/pdf",
|
|
1309
|
+
fileName,
|
|
1310
|
+
sizeBytes: 1,
|
|
1311
|
+
});
|
|
1312
|
+
expect(() =>
|
|
1313
|
+
validateHeaderValue("Attachment-Pending", value),
|
|
1314
|
+
).not.toThrow();
|
|
1315
|
+
expect((JSON.parse(value) as { fileName: string }).fileName).toBe(
|
|
1316
|
+
fileName,
|
|
1317
|
+
);
|
|
1318
|
+
},
|
|
1319
|
+
);
|
|
1320
|
+
|
|
1218
1321
|
it("buildContentDisposition encodes RFC-5987-reserved chars in the encoded form", () => {
|
|
1219
1322
|
const value = buildContentDisposition("a'b(c)*!.txt");
|
|
1220
1323
|
expect(value).toContain("filename*=UTF-8''a%27b%28c%29%2A%21.txt");
|
|
@@ -1238,7 +1341,7 @@ describe("attachment routes", () => {
|
|
|
1238
1341
|
it("download with non-ASCII fileName produces RFC 6266 Content-Disposition", async () => {
|
|
1239
1342
|
const reserveHandler = makeReserveHandler(attachments);
|
|
1240
1343
|
const uploadHandler = makeUploadHandler(attachments);
|
|
1241
|
-
const downloadHandler =
|
|
1344
|
+
const downloadHandler = grantedDownload(attachments);
|
|
1242
1345
|
|
|
1243
1346
|
const reserveReq = makeReq({
|
|
1244
1347
|
method: "POST",
|
|
@@ -1281,5 +1384,45 @@ describe("attachment routes", () => {
|
|
|
1281
1384
|
) as { fileName: string };
|
|
1282
1385
|
expect(meta.fileName).toBe("résumé.pdf");
|
|
1283
1386
|
});
|
|
1387
|
+
|
|
1388
|
+
it("HEAD and GET answer 200 with a parseable Attachment-Metadata for a name above U+00FF", async () => {
|
|
1389
|
+
const fileName = "opłata – 報告 📎.pdf";
|
|
1390
|
+
const reserveReq = makeReq({
|
|
1391
|
+
method: "POST",
|
|
1392
|
+
body: JSON.stringify({ mimeType: "application/pdf", fileName }),
|
|
1393
|
+
});
|
|
1394
|
+
const reserveRes = makeRes();
|
|
1395
|
+
await makeReserveHandler(attachments)(reserveReq, reserveRes);
|
|
1396
|
+
await waitFor(reserveRes);
|
|
1397
|
+
expect(reserveRes.statusCode).toBe(201);
|
|
1398
|
+
const { reservationId } = JSON.parse(
|
|
1399
|
+
reserveRes._body.toString("utf8"),
|
|
1400
|
+
) as { reservationId: string };
|
|
1401
|
+
|
|
1402
|
+
const uploadRes = makeRes();
|
|
1403
|
+
await makeUploadHandler(attachments)(
|
|
1404
|
+
makeReq({ method: "PUT", params: { reservationId }, body: "pdf" }),
|
|
1405
|
+
uploadRes,
|
|
1406
|
+
);
|
|
1407
|
+
await waitFor(uploadRes);
|
|
1408
|
+
expect(uploadRes.statusCode).toBe(200);
|
|
1409
|
+
const { hash } = JSON.parse(uploadRes._body.toString("utf8")) as {
|
|
1410
|
+
hash: string;
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
for (const [method, handler] of [
|
|
1414
|
+
["HEAD", grantedStat(attachments)],
|
|
1415
|
+
["GET", grantedDownload(attachments)],
|
|
1416
|
+
] as const) {
|
|
1417
|
+
const res = makeRes();
|
|
1418
|
+
await handler(makeReq({ method, params: { hash } }), res);
|
|
1419
|
+
await waitFor(res);
|
|
1420
|
+
expect(res.statusCode).toBe(200);
|
|
1421
|
+
const meta = JSON.parse(
|
|
1422
|
+
res.getHeader("attachment-metadata") as string,
|
|
1423
|
+
) as { fileName: string };
|
|
1424
|
+
expect(meta.fileName).toBe(fileName);
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1284
1427
|
});
|
|
1285
1428
|
});
|
|
@@ -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
|
+
});
|