@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.
Files changed (143) 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/reserve-metadata-parity.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
  7. package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
  8. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  19. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  20. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  21. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts +2 -0
  24. package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts.map +1 -0
  25. package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts +2 -0
  26. package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts.map +1 -0
  27. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  28. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  29. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  30. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  31. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
  32. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
  33. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  34. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  35. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  36. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  37. package/.tsbuild/test/observability-signals.test.d.ts +2 -0
  38. package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
  39. package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
  40. package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
  41. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  42. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  43. package/.tsbuild/test/snapshot-writer.d.ts +18 -0
  44. package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
  45. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  46. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  48. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  49. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  50. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  51. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  52. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  53. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  54. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  55. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  56. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  57. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  58. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  59. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  60. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  61. package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
  62. package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
  63. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
  64. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
  65. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  66. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  67. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  68. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  69. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  70. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  71. package/.tsbuild/vitest.config.d.ts.map +1 -1
  72. package/Auth.md +94 -0
  73. package/CHANGELOG.md +541 -0
  74. package/README.md +35 -0
  75. package/dist/index.d.mts +2 -1
  76. package/dist/index.mjs +48 -49
  77. package/dist/index.mjs.map +1 -1
  78. package/dist/install-packages.d.mts +2 -1
  79. package/dist/install-packages.mjs +2 -2
  80. package/dist/install-packages.mjs.map +1 -1
  81. package/dist/migrate.d.mts +2 -1
  82. package/dist/migrate.mjs +4 -3
  83. package/dist/migrate.mjs.map +1 -1
  84. package/dist/server-J9fpd42v.mjs +3549 -0
  85. package/dist/server-J9fpd42v.mjs.map +1 -0
  86. package/dist/server.d.mts +155 -30
  87. package/dist/server.d.mts.map +1 -1
  88. package/dist/server.mjs +4 -5
  89. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  90. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  91. package/dist/utils.d.mts +4 -5
  92. package/dist/utils.d.mts.map +1 -1
  93. package/dist/utils.mjs +3 -3
  94. package/package.json +22 -18
  95. package/test/attachment-reference-read-model.test.ts +6 -1
  96. package/test/attachments/auth.test.ts +85 -0
  97. package/test/attachments/byte-grants.test.ts +290 -0
  98. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  99. package/test/attachments/download-target.test.ts +201 -32
  100. package/test/attachments/index.test.ts +34 -3
  101. package/test/attachments/reserve-metadata-parity.test.ts +96 -0
  102. package/test/attachments/routes-integration.test.ts +60 -4
  103. package/test/attachments/routes.test.ts +166 -23
  104. package/test/attachments/service-config.test.ts +13 -3
  105. package/test/attachments/url-signer.test.ts +105 -0
  106. package/test/boot-create-signature-policy.test.ts +108 -0
  107. package/test/boot-mcp-enabled.test.ts +121 -0
  108. package/test/boot-privacy.test.ts +243 -0
  109. package/test/boot-unsupported-stored-documents.test.ts +213 -0
  110. package/test/boot-without-renown.test.ts +81 -0
  111. package/test/boot-without-require-signatures.test.ts +74 -0
  112. package/test/boot-worker-trust-policy.test.ts +90 -0
  113. package/test/boot-workflow-singleton-conflict.test.ts +107 -0
  114. package/test/boot-workflow-singleton-retry.test.ts +133 -0
  115. package/test/builder-defaults.test.ts +80 -0
  116. package/test/create-signature-policy.test.ts +69 -0
  117. package/test/durable-node-fs-pg16.test.ts +80 -0
  118. package/test/fatal-shutdown.test.ts +165 -0
  119. package/test/mcp-flag.test.ts +56 -0
  120. package/test/observability-signals.test.ts +131 -0
  121. package/test/pglite-dialect.test.ts +217 -2
  122. package/test/pglite-preflight.test.ts +313 -0
  123. package/test/pglite-version.test.ts +9 -1
  124. package/test/renown-trust-policy.test.ts +315 -0
  125. package/test/snapshot-writer.ts +94 -0
  126. package/test/unsupported-stored-documents.test.ts +72 -0
  127. package/test/worker-pool.test.ts +27 -1
  128. package/test/workflow/harness.ts +289 -0
  129. package/test/workflow/oauth.test.ts +214 -0
  130. package/test/workflow/resolvers.test.ts +357 -0
  131. package/test/workflow/webhook-dedupe.test.ts +295 -0
  132. package/test/workflow/webhook-signed.test.ts +509 -0
  133. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  134. package/test/workflow/webhook-unsigned.test.ts +345 -0
  135. package/test/workflow-reactor-access.test.ts +384 -0
  136. package/test/workflow-reactor-enforcement.test.ts +808 -0
  137. package/test/workflow-read-gate.test.ts +213 -0
  138. package/test/workflow-runtime.test.ts +1067 -0
  139. package/tsconfig.json +9 -3
  140. package/tsdown.config.ts +3 -1
  141. package/dist/server-CiKkVE6g.mjs +0 -1555
  142. package/dist/server-CiKkVE6g.mjs.map +0 -1
  143. 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,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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeStatHandler(attachments);
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 = makeStatHandler(attachments);
393
+ const statHandler = grantedStat(attachments);
368
394
  const server = createServer((req, res) => {
369
- const m = /^\/attachments\/([^/]+)$/.exec(req.url ?? "/");
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) => server.listen(0, 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 = makeStatHandler(attachments);
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 = makeStatHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeStatHandler(attachments);
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 = makeDownloadHandler(attachments);
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: "pending.txt",
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 = makeStatHandler(attachments);
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 = makeStatHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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 = makeDownloadHandler(attachments);
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
+ });