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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
  2. package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
  3. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
  4. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
  5. package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  7. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  8. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  19. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  20. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  21. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  24. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  25. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  26. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  27. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  28. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  29. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  30. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  31. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  32. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  33. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  34. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  35. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  36. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  37. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  38. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  39. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  40. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  41. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  42. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  43. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  44. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  45. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  46. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  48. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  49. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  50. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  51. package/.tsbuild/vitest.config.d.ts.map +1 -1
  52. package/Auth.md +94 -0
  53. package/CHANGELOG.md +302 -0
  54. package/README.md +35 -0
  55. package/dist/index.d.mts +2 -1
  56. package/dist/index.mjs +10 -8
  57. package/dist/index.mjs.map +1 -1
  58. package/dist/install-packages.d.mts +2 -1
  59. package/dist/install-packages.mjs +2 -2
  60. package/dist/install-packages.mjs.map +1 -1
  61. package/dist/migrate.d.mts +2 -1
  62. package/dist/migrate.mjs +4 -3
  63. package/dist/migrate.mjs.map +1 -1
  64. package/dist/{server-CiKkVE6g.mjs → server-sQoWp21G.mjs} +1454 -70
  65. package/dist/server-sQoWp21G.mjs.map +1 -0
  66. package/dist/server.d.mts +103 -29
  67. package/dist/server.d.mts.map +1 -1
  68. package/dist/server.mjs +4 -5
  69. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  70. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  71. package/dist/utils.d.mts +4 -5
  72. package/dist/utils.d.mts.map +1 -1
  73. package/dist/utils.mjs +3 -3
  74. package/package.json +21 -17
  75. package/test/attachments/auth.test.ts +85 -0
  76. package/test/attachments/byte-grants.test.ts +287 -0
  77. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  78. package/test/attachments/download-target.test.ts +194 -31
  79. package/test/attachments/index.test.ts +34 -3
  80. package/test/attachments/routes-integration.test.ts +59 -4
  81. package/test/attachments/routes.test.ts +165 -23
  82. package/test/attachments/service-config.test.ts +13 -3
  83. package/test/attachments/url-signer.test.ts +105 -0
  84. package/test/boot-create-signature-policy.test.ts +108 -0
  85. package/test/boot-mcp-enabled.test.ts +121 -0
  86. package/test/boot-unsupported-stored-documents.test.ts +210 -0
  87. package/test/boot-without-renown.test.ts +81 -0
  88. package/test/boot-without-require-signatures.test.ts +74 -0
  89. package/test/boot-worker-trust-policy.test.ts +90 -0
  90. package/test/builder-defaults.test.ts +80 -0
  91. package/test/create-signature-policy.test.ts +69 -0
  92. package/test/fatal-shutdown.test.ts +102 -0
  93. package/test/mcp-flag.test.ts +56 -0
  94. package/test/renown-trust-policy.test.ts +315 -0
  95. package/test/unsupported-stored-documents.test.ts +72 -0
  96. package/test/workflow/harness.ts +289 -0
  97. package/test/workflow/oauth.test.ts +214 -0
  98. package/test/workflow/resolvers.test.ts +296 -0
  99. package/test/workflow/webhook-dedupe.test.ts +295 -0
  100. package/test/workflow/webhook-signed.test.ts +509 -0
  101. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  102. package/test/workflow/webhook-unsigned.test.ts +345 -0
  103. package/test/workflow-read-gate.test.ts +145 -0
  104. package/test/workflow-runtime.test.ts +486 -0
  105. package/tsconfig.json +6 -0
  106. package/tsdown.config.ts +3 -1
  107. package/dist/server-CiKkVE6g.mjs.map +0 -1
  108. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -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 type { IncomingMessage, ServerResponse } from "node:http";
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeStatHandler(attachments);
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 = makeStatHandler(attachments);
392
+ const statHandler = grantedStat(attachments);
368
393
  const server = createServer((req, res) => {
369
- const m = /^\/attachments\/([^/]+)$/.exec(req.url ?? "/");
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) => server.listen(0, 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 = makeStatHandler(attachments);
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 = makeStatHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeStatHandler(attachments);
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 = makeDownloadHandler(attachments);
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: "pending.txt",
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 = makeStatHandler(attachments);
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 = makeStatHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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
+ });