@powerhousedao/switchboard 6.2.3-dev.22 → 6.2.3-dev.24

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 (46) hide show
  1. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  2. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  3. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  4. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  5. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  6. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  7. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  8. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  9. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  10. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  11. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  12. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  13. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  14. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  15. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  16. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  17. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  18. package/Auth.md +41 -0
  19. package/CHANGELOG.md +33 -0
  20. package/README.md +14 -0
  21. package/dist/index.mjs +6 -5
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/{server-C6VtW18k.mjs → server-BFbus-at.mjs} +225 -33
  24. package/dist/server-BFbus-at.mjs.map +1 -0
  25. package/dist/server.d.mts +19 -5
  26. package/dist/server.d.mts.map +1 -1
  27. package/dist/server.mjs +4 -4
  28. package/dist/{utils-Baw7rThP.mjs → utils-BBHt2kdo.mjs} +15 -9
  29. package/dist/utils-BBHt2kdo.mjs.map +1 -0
  30. package/dist/utils.d.mts.map +1 -1
  31. package/dist/utils.mjs +3 -3
  32. package/package.json +16 -16
  33. package/test/attachments/auth.test.ts +85 -0
  34. package/test/attachments/index.test.ts +34 -3
  35. package/test/attachments/routes-integration.test.ts +40 -0
  36. package/test/attachments/routes.test.ts +126 -2
  37. package/test/boot-create-signature-policy.test.ts +108 -0
  38. package/test/boot-without-renown.test.ts +81 -0
  39. package/test/boot-without-require-signatures.test.ts +74 -0
  40. package/test/boot-worker-trust-policy.test.ts +90 -0
  41. package/test/builder-defaults.test.ts +80 -0
  42. package/test/create-signature-policy.test.ts +69 -0
  43. package/test/fatal-shutdown.test.ts +102 -0
  44. package/test/renown-trust-policy.test.ts +315 -0
  45. package/dist/server-C6VtW18k.mjs.map +0 -1
  46. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils-BBHt2kdo.mjs","sources":["../src/utils.mts"],"sourcesContent":["import type { IReactorClient } from \"@powerhousedao/reactor\";\nimport {\n reactorDriveCreateDocument,\n reactorDriveCreateState,\n} from \"@powerhousedao/reactor-drive\";\nimport {\n driveCreateDocument,\n driveCreateState,\n} from \"@powerhousedao/shared/document-drive\";\nimport type { DriveInput } from \"@powerhousedao/shared/document-drive\";\nimport {\n withSignaturePolicy,\n type PHDocument,\n} from \"@powerhousedao/shared/document-model\";\n\n/**\n * A configured id pins the drive, and only a legacy drive can take a chosen\n * id; otherwise the client's creation default applies.\n */\nasync function underCreatePolicy<TDocument extends PHDocument>(\n client: IReactorClient,\n document: TDocument,\n id: string | undefined,\n): Promise<TDocument> {\n return id\n ? withSignaturePolicy(document, \"legacy\", { id })\n : withSignaturePolicy(document, await client.getCreateSignaturePolicy());\n}\n\nexport async function addDefaultDrive(\n client: IReactorClient,\n drive: DriveInput,\n serverPort: number,\n) {\n let driveId = drive.id;\n if (!driveId || driveId.length === 0) {\n driveId = drive.slug;\n }\n\n if (!driveId || driveId.length === 0) {\n throw new Error(\"Invalid Drive Id\");\n }\n\n // check if the drive already exists\n let existingDrive;\n try {\n existingDrive = await client.get(driveId);\n } catch {\n //\n }\n\n // already exists, return the existing drive url\n if (existingDrive) {\n return `http://localhost:${serverPort}/d/${driveId}`;\n }\n\n const { global } = driveCreateState();\n const created = driveCreateDocument({\n global: {\n ...global,\n name: drive.global.name,\n icon: drive.global.icon ?? global.icon,\n },\n local: {\n availableOffline: drive.local?.availableOffline ?? false,\n sharingType: drive.local?.sharingType ?? \"public\",\n listeners: drive.local?.listeners ?? [],\n triggers: drive.local?.triggers ?? [],\n },\n });\n\n const document = await underCreatePolicy(client, created, drive.id);\n if (drive.slug && drive.slug.length > 0) {\n document.header.slug = drive.slug;\n }\n if (drive.global.name) {\n document.header.name = drive.global.name;\n }\n if (drive.preferredEditor) {\n document.header.meta = { preferredEditor: drive.preferredEditor };\n }\n\n try {\n await client.create(document);\n } catch (e) {\n const errorMessage = e instanceof Error ? e.message : String(e);\n if (!errorMessage.includes(\"already exists\")) {\n throw e;\n }\n }\n\n return `http://localhost:${serverPort}/d/${driveId}`;\n}\n\nexport async function addDefaultReactorDrive(\n client: IReactorClient,\n drive: DriveInput,\n serverPort: number,\n) {\n let driveId = drive.id;\n if (!driveId || driveId.length === 0) {\n driveId = drive.slug;\n }\n\n if (!driveId || driveId.length === 0) {\n throw new Error(\"Invalid Drive Id\");\n }\n\n let existingDrive;\n try {\n existingDrive = await client.get(driveId);\n } catch {\n //\n }\n\n if (existingDrive) {\n return `http://localhost:${serverPort}/d/${driveId}`;\n }\n\n const { global, local } = reactorDriveCreateState();\n const created = reactorDriveCreateDocument({\n global: {\n ...global,\n name: drive.global.name,\n icon: drive.global.icon ?? global.icon,\n },\n local: {\n ...local,\n availableOffline: drive.local?.availableOffline ?? local.availableOffline,\n sharingType: drive.local?.sharingType ?? local.sharingType,\n },\n });\n\n const document = await underCreatePolicy(client, created, drive.id);\n if (drive.slug && drive.slug.length > 0) {\n document.header.slug = drive.slug;\n }\n if (drive.global.name) {\n document.header.name = drive.global.name;\n }\n if (drive.preferredEditor) {\n document.header.meta = { preferredEditor: drive.preferredEditor };\n }\n\n try {\n await client.create(document);\n } catch (e) {\n const errorMessage = e instanceof Error ? e.message : String(e);\n if (!errorMessage.includes(\"already exists\")) {\n throw e;\n }\n }\n\n return `http://localhost:${serverPort}/d/${driveId}`;\n}\n\nexport function isPostgresUrl(url: string) {\n return url.startsWith(\"postgresql\") || url.startsWith(\"postgres\");\n}\n"],"names":[],"mappings":";;;;;;;;;;AAmBA,eAAe,kBACb,QACA,UACA,IACoB;AACpB,QAAO,KACH,oBAAoB,UAAU,UAAU,EAAE,IAAI,CAAC,GAC/C,oBAAoB,UAAU,MAAM,OAAO,0BAA0B,CAAC;;AAG5E,eAAsB,gBACpB,QACA,OACA,YACA;CACA,IAAI,UAAU,MAAM;AACpB,KAAI,CAAC,WAAW,QAAQ,WAAW,EACjC,WAAU,MAAM;AAGlB,KAAI,CAAC,WAAW,QAAQ,WAAW,EACjC,OAAM,IAAI,MAAM,mBAAmB;CAIrC,IAAI;AACJ,KAAI;AACF,kBAAgB,MAAM,OAAO,IAAI,QAAQ;SACnC;AAKR,KAAI,cACF,QAAO,oBAAoB,WAAW,KAAK;CAG7C,MAAM,EAAE,WAAW,kBAAkB;CAerC,MAAM,WAAW,MAAM,kBAAkB,QAdzB,oBAAoB;EAClC,QAAQ;GACN,GAAG;GACH,MAAM,MAAM,OAAO;GACnB,MAAM,MAAM,OAAO,QAAQ,OAAO;GACnC;EACD,OAAO;GACL,kBAAkB,MAAM,OAAO,oBAAoB;GACnD,aAAa,MAAM,OAAO,eAAe;GACzC,WAAW,MAAM,OAAO,aAAa,EAAE;GACvC,UAAU,MAAM,OAAO,YAAY,EAAE;GACtC;EACF,CAAC,EAEwD,MAAM,GAAG;AACnE,KAAI,MAAM,QAAQ,MAAM,KAAK,SAAS,EACpC,UAAS,OAAO,OAAO,MAAM;AAE/B,KAAI,MAAM,OAAO,KACf,UAAS,OAAO,OAAO,MAAM,OAAO;AAEtC,KAAI,MAAM,gBACR,UAAS,OAAO,OAAO,EAAE,iBAAiB,MAAM,iBAAiB;AAGnE,KAAI;AACF,QAAM,OAAO,OAAO,SAAS;UACtB,GAAG;AAEV,MAAI,EADiB,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,EAC7C,SAAS,iBAAiB,CAC1C,OAAM;;AAIV,QAAO,oBAAoB,WAAW,KAAK;;AAG7C,eAAsB,uBACpB,QACA,OACA,YACA;CACA,IAAI,UAAU,MAAM;AACpB,KAAI,CAAC,WAAW,QAAQ,WAAW,EACjC,WAAU,MAAM;AAGlB,KAAI,CAAC,WAAW,QAAQ,WAAW,EACjC,OAAM,IAAI,MAAM,mBAAmB;CAGrC,IAAI;AACJ,KAAI;AACF,kBAAgB,MAAM,OAAO,IAAI,QAAQ;SACnC;AAIR,KAAI,cACF,QAAO,oBAAoB,WAAW,KAAK;CAG7C,MAAM,EAAE,QAAQ,UAAU,yBAAyB;CAcnD,MAAM,WAAW,MAAM,kBAAkB,QAbzB,2BAA2B;EACzC,QAAQ;GACN,GAAG;GACH,MAAM,MAAM,OAAO;GACnB,MAAM,MAAM,OAAO,QAAQ,OAAO;GACnC;EACD,OAAO;GACL,GAAG;GACH,kBAAkB,MAAM,OAAO,oBAAoB,MAAM;GACzD,aAAa,MAAM,OAAO,eAAe,MAAM;GAChD;EACF,CAAC,EAEwD,MAAM,GAAG;AACnE,KAAI,MAAM,QAAQ,MAAM,KAAK,SAAS,EACpC,UAAS,OAAO,OAAO,MAAM;AAE/B,KAAI,MAAM,OAAO,KACf,UAAS,OAAO,OAAO,MAAM,OAAO;AAEtC,KAAI,MAAM,gBACR,UAAS,OAAO,OAAO,EAAE,iBAAiB,MAAM,iBAAiB;AAGnE,KAAI;AACF,QAAM,OAAO,OAAO,SAAS;UACtB,GAAG;AAEV,MAAI,EADiB,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,EAC7C,SAAS,iBAAiB,CAC1C,OAAM;;AAIV,QAAO,oBAAoB,WAAW,KAAK;;AAG7C,SAAgB,cAAc,KAAa;AACzC,QAAO,IAAI,WAAW,aAAa,IAAI,IAAI,WAAW,WAAW","debug_id":"a69847ec-becf-5c25-988b-cd47759b2c19"}
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.mts","names":[],"sources":["../src/utils.mts"],"mappings":";;;;iBAWsB,eAAA,CACpB,MAAA,EAAQ,cAAA,EACR,KAAA,EAAO,UAAA,EACP,UAAA,WAAkB,OAAA;AAAA,iBAgEE,sBAAA,CACpB,MAAA,EAAQ,cAAA,EACR,KAAA,EAAO,UAAA,EACP,UAAA,WAAkB,OAAA;AAAA,iBA6DJ,aAAA,CAAc,GAAA"}
1
+ {"version":3,"file":"utils.d.mts","names":[],"sources":["../src/utils.mts"],"mappings":";;;;iBA6BsB,eAAA,CACpB,MAAA,EAAQ,cAAA,EACR,KAAA,EAAO,UAAA,EACP,UAAA,WAAkB,OAAA;AAAA,iBA8DE,sBAAA,CACpB,MAAA,EAAQ,cAAA,EACR,KAAA,EAAO,UAAA,EACP,UAAA,WAAkB,OAAA;AAAA,iBA2DJ,aAAA,CAAc,GAAA"}
package/dist/utils.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-Baw7rThP.mjs";
1
+ import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-BBHt2kdo.mjs";
2
2
  export { addDefaultDrive, addDefaultReactorDrive, isPostgresUrl };
3
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="8227e60f-e412-513a-b0cb-502740b7f818")}catch(e){}}();
4
- //# debugId=8227e60f-e412-513a-b0cb-502740b7f818
3
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="4dfe0eb9-58d9-5240-969e-d37feaef290c")}catch(e){}}();
4
+ //# debugId=4dfe0eb9-58d9-5240-969e-d37feaef290c
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@powerhousedao/switchboard",
3
3
  "type": "module",
4
- "version": "6.2.3-dev.22",
4
+ "version": "6.2.3-dev.24",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
7
7
  ".": {
@@ -41,7 +41,7 @@
41
41
  "@openfeature/core": "1.9.1",
42
42
  "@openfeature/env-var-provider": "0.3.1",
43
43
  "@openfeature/server-sdk": "1.19.0",
44
- "@powerhousedao/config": "6.2.3-dev.22",
44
+ "@powerhousedao/config": "6.2.3-dev.24",
45
45
  "@opentelemetry/api": "^1.9.0",
46
46
  "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
47
47
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
@@ -55,22 +55,22 @@
55
55
  "@opentelemetry/sdk-node": "^0.218.0",
56
56
  "@opentelemetry/sdk-trace-base": "^2.7.1",
57
57
  "@opentelemetry/semantic-conventions": "^1.41.1",
58
- "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.22",
59
- "@powerhousedao/reactor": "6.2.3-dev.22",
60
- "@powerhousedao/shared": "6.2.3-dev.22",
61
- "@powerhousedao/vetra": "6.2.3-dev.22",
62
- "@powerhousedao/reactor-api": "6.2.3-dev.22",
63
- "@powerhousedao/reactor-attachments": "6.2.3-dev.22",
64
- "@powerhousedao/reactor-drive": "6.2.3-dev.22",
65
- "@powerhousedao/reactor-group": "6.2.3-dev.22",
66
- "@powerhousedao/reactor-workflow": "6.2.3-dev.22",
67
- "@powerhousedao/workflow": "6.2.3-dev.22",
68
- "@powerhousedao/pglite-fs": "6.2.3-dev.22",
58
+ "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.24",
59
+ "@powerhousedao/reactor": "6.2.3-dev.24",
60
+ "@powerhousedao/shared": "6.2.3-dev.24",
61
+ "@powerhousedao/vetra": "6.2.3-dev.24",
62
+ "@powerhousedao/reactor-api": "6.2.3-dev.24",
63
+ "@powerhousedao/reactor-attachments": "6.2.3-dev.24",
64
+ "@powerhousedao/reactor-drive": "6.2.3-dev.24",
65
+ "@powerhousedao/reactor-group": "6.2.3-dev.24",
66
+ "@powerhousedao/reactor-workflow": "6.2.3-dev.24",
67
+ "@powerhousedao/workflow": "6.2.3-dev.24",
68
+ "@powerhousedao/pglite-fs": "6.2.3-dev.24",
69
69
  "@pyroscope/nodejs": "^0.4.5",
70
- "@renown/sdk": "6.2.3-dev.22",
70
+ "@renown/sdk": "6.2.3-dev.24",
71
71
  "@sentry/node": "^10.53.1",
72
72
  "@sentry/opentelemetry": "^10.53.1",
73
- "document-model": "6.2.3-dev.22",
73
+ "document-model": "6.2.3-dev.24",
74
74
  "dotenv": "^16.4.7",
75
75
  "express": "^4.21.2",
76
76
  "graphql": "^16",
@@ -96,7 +96,7 @@
96
96
  "scripts": {
97
97
  "tsc": "tsc",
98
98
  "test": "vitest run",
99
- "lint": "eslint",
99
+ "lint": "cd ../.. && oxlint apps/switchboard",
100
100
  "build": "tsdown && pnpm run install-packages",
101
101
  "start": "node dist/index.mjs",
102
102
  "start:profile": "mkdir -p .prof && node --cpu-prof --cpu-prof-dir=.prof dist/index.mjs",
@@ -342,6 +342,91 @@ describe("requireAuth", () => {
342
342
  });
343
343
  });
344
344
 
345
+ /**
346
+ * The deployment-wide floor. These routes never pass the GraphQL fetch chain,
347
+ * so `REQUIRE_AUTHENTICATED_CALLER` reaches them through this option instead
348
+ * — including on the routes that opt into `allowAnonymous`, which says the
349
+ * handler can decide without identity, not that the server will serve callers
350
+ * it cannot name.
351
+ */
352
+ describe("requireAuthenticatedCaller", () => {
353
+ const anonymousUnderOpenPolicy = () =>
354
+ makeAuthService(() =>
355
+ Promise.resolve({ user: undefined, admins: [], auth_enabled: false }),
356
+ );
357
+
358
+ it("refuses a missing bearer even with auth disabled, where today it passes", async () => {
359
+ const { service } = anonymousUnderOpenPolicy();
360
+ const handler = vi.fn<NodeHandler>();
361
+ const wrapped = requireAuth(service, handler, {
362
+ requireAuthenticatedCaller: true,
363
+ });
364
+
365
+ const res = makeRes();
366
+ await wrapped(makeReq({ headers: {} }), res);
367
+
368
+ expect(res.statusCode).toBe(401);
369
+ expect(JSON.parse(res._body)).toEqual({
370
+ error: "Authentication required",
371
+ });
372
+ expect(handler).not.toHaveBeenCalled();
373
+ });
374
+
375
+ it("overrides allowAnonymous", async () => {
376
+ const { service } = anonymousUnderOpenPolicy();
377
+ const handler = vi.fn<NodeHandler>();
378
+ const wrapped = requireAuth(service, handler, {
379
+ allowAnonymous: true,
380
+ requireAuthenticatedCaller: true,
381
+ });
382
+
383
+ const res = makeRes();
384
+ await wrapped(makeReq({ headers: {} }), res);
385
+
386
+ expect(res.statusCode).toBe(401);
387
+ expect(handler).not.toHaveBeenCalled();
388
+ });
389
+
390
+ it("still serves a verified caller", async () => {
391
+ const { service } = makeAuthService(() =>
392
+ Promise.resolve({
393
+ user: { address: "0x123", chainId: 1, networkId: "mainnet" },
394
+ admins: [],
395
+ auth_enabled: false,
396
+ }),
397
+ );
398
+ const handler = vi.fn<NodeHandler>();
399
+ const wrapped = requireAuth(service, handler, {
400
+ allowAnonymous: true,
401
+ requireAuthenticatedCaller: true,
402
+ });
403
+
404
+ const req = makeReq({ headers: { authorization: "Bearer good-token" } });
405
+ const res = makeRes();
406
+ await wrapped(req, res);
407
+
408
+ expect(res.statusCode).toBe(200);
409
+ expect(handler).toHaveBeenCalledWith(req, res, undefined, {
410
+ user: { address: "0x123", chainId: 1, networkId: "mainnet" },
411
+ authEnabled: false,
412
+ });
413
+ });
414
+
415
+ it("leaves every route as it was when the deployment has not set it", async () => {
416
+ const { service } = anonymousUnderOpenPolicy();
417
+ const handler = vi.fn<NodeHandler>();
418
+ const wrapped = requireAuth(service, handler, {
419
+ requireAuthenticatedCaller: false,
420
+ });
421
+
422
+ const res = makeRes();
423
+ await wrapped(makeReq({ headers: {} }), res);
424
+
425
+ expect(res.statusCode).toBe(200);
426
+ expect(handler).toHaveBeenCalledTimes(1);
427
+ });
428
+ });
429
+
345
430
  it("calls verifyBearer with undefined when no authorization header is present", async () => {
346
431
  const { service, spy } = makeAuthService(() =>
347
432
  Promise.resolve({
@@ -13,8 +13,16 @@ type Captured = {
13
13
  ) => void | Promise<void>;
14
14
  };
15
15
 
16
- function makeFakeApi(authService: AuthService | undefined): {
17
- api: Pick<API, "httpAdapter" | "authService">;
16
+ type FakeApi = Pick<
17
+ API,
18
+ "httpAdapter" | "authService" | "requireAuthenticatedCaller"
19
+ >;
20
+
21
+ function makeFakeApi(
22
+ authService: AuthService | undefined,
23
+ requireAuthenticatedCaller = false,
24
+ ): {
25
+ api: FakeApi;
18
26
  captured: Captured[];
19
27
  } {
20
28
  const captured: Captured[] = [];
@@ -29,7 +37,8 @@ function makeFakeApi(authService: AuthService | undefined): {
29
37
  },
30
38
  },
31
39
  authService,
32
- } as unknown as Pick<API, "httpAdapter" | "authService">;
40
+ requireAuthenticatedCaller,
41
+ } as unknown as FakeApi;
33
42
  return { api, captured };
34
43
  }
35
44
 
@@ -138,6 +147,28 @@ describe("mountAuthenticatedNodeRoute", () => {
138
147
  });
139
148
  });
140
149
 
150
+ it("carries the deployment's authenticated-caller floor onto the route, over allowAnonymous", async () => {
151
+ // auth_enabled false is the shape a deployment running the document
152
+ // policies has: the boundary policy is OPEN, and this floor is what keeps
153
+ // an anonymous caller off the attachment routes.
154
+ const verifyBearer = vi.fn(() =>
155
+ Promise.resolve({ user: undefined, admins: [], auth_enabled: false }),
156
+ );
157
+ const authService = { verifyBearer } as unknown as AuthService;
158
+ const { api, captured } = makeFakeApi(authService, true);
159
+ const inner = vi.fn();
160
+
161
+ mountAuthenticatedNodeRoute(api, "GET", "/x", inner, {
162
+ allowAnonymous: true,
163
+ });
164
+
165
+ const res = makeRes();
166
+ await captured[0].handler(makeReq(), res);
167
+
168
+ expect(res.statusCode).toBe(401);
169
+ expect(inner).not.toHaveBeenCalled();
170
+ });
171
+
141
172
  it("mounts a wrapper that forwards the anonymous actor when authService is undefined", async () => {
142
173
  const { api, captured } = makeFakeApi(undefined);
143
174
  const inner = vi.fn();
@@ -1,5 +1,6 @@
1
1
  import { PGlite } from "@electric-sql/pglite";
2
2
  import {
3
+ AttachmentAlreadyExists,
3
4
  AttachmentBuilder,
4
5
  S3AttachmentBackend,
5
6
  type AttachmentBuildResult,
@@ -101,6 +102,45 @@ describe("attachment routes through the real Express middleware stack", () => {
101
102
  }
102
103
  expect(new TextDecoder().decode(merged)).toBe(payload);
103
104
  });
105
+
106
+ it("HEAD and GET /attachments/:hash serve a file name above U+00FF", async () => {
107
+ const fileName = "opłata – 報告 📎.pdf";
108
+ const service = createRemoteAttachmentService({ remoteUrl: baseUrl });
109
+ const upload = await service.reserve({
110
+ mimeType: "application/pdf",
111
+ fileName,
112
+ });
113
+ const bytes = new TextEncoder().encode("%PDF-unicode-name");
114
+ const { hash } = await upload.send(
115
+ new ReadableStream<Uint8Array>({
116
+ start(controller) {
117
+ controller.enqueue(bytes);
118
+ controller.close();
119
+ },
120
+ }),
121
+ );
122
+
123
+ for (const method of ["HEAD", "GET"]) {
124
+ const res = await fetch(`${baseUrl}/attachments/${hash}`, { method });
125
+ expect(res.status).toBe(200);
126
+ const meta = JSON.parse(res.headers.get("attachment-metadata")!) as {
127
+ fileName: string;
128
+ };
129
+ expect(meta.fileName).toBe(fileName);
130
+ await res.arrayBuffer();
131
+ }
132
+
133
+ // A hash-first reserve of the same bytes is what the client sends on the
134
+ // next upload; it stats the hash first.
135
+ await expect(
136
+ service.reserve({
137
+ mimeType: "application/pdf",
138
+ fileName,
139
+ clientHash: hash,
140
+ sizeBytes: bytes.byteLength,
141
+ }),
142
+ ).rejects.toBeInstanceOf(AttachmentAlreadyExists);
143
+ });
104
144
  });
105
145
 
106
146
  describe("authenticated S3 reservation production path", () => {
@@ -6,13 +6,19 @@ 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";
13
17
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
14
18
  import {
15
19
  buildContentDisposition,
20
+ buildMetadataHeader,
21
+ buildPendingHeader,
16
22
  makeDeleteReservationHandler,
17
23
  makeDownloadHandler,
18
24
  makeGetReservationHandler,
@@ -70,6 +76,9 @@ function makeRes(): CapturedRes {
70
76
  statusCode: 200,
71
77
  _headers: headers,
72
78
  setHeader(name: string, value: string | number | readonly string[]) {
79
+ // Same check Node's ServerResponse applies, so a header value it would
80
+ // reject fails here instead of passing silently.
81
+ validateHeaderValue(name, String(value));
73
82
  headers[name.toLowerCase()] = String(value);
74
83
  },
75
84
  getHeader(name: string) {
@@ -899,6 +908,7 @@ describe("attachment routes", () => {
899
908
  describe("pending state: stat and download 202 responses", () => {
900
909
  async function createPendingReservation(
901
910
  content: string,
911
+ fileName = "pending.txt",
902
912
  ): Promise<{ hash: string }> {
903
913
  const { createHash } = await import("node:crypto");
904
914
  const hash = createHash("sha256").update(content, "utf8").digest("hex");
@@ -908,7 +918,7 @@ describe("attachment routes", () => {
908
918
  method: "POST",
909
919
  body: JSON.stringify({
910
920
  mimeType: "text/plain",
911
- fileName: "pending.txt",
921
+ fileName,
912
922
  clientHash: hash,
913
923
  sizeBytes: Buffer.byteLength(content, "utf8"),
914
924
  }),
@@ -995,6 +1005,32 @@ describe("attachment routes", () => {
995
1005
  expect(res.getHeader("attachment-metadata")).toBeFalsy();
996
1006
  });
997
1007
 
1008
+ it.each(["opłata.pdf", "a – b’s.txt", "📎.png", "報告書.pdf"])(
1009
+ "HEAD and GET 202 carry a parseable Attachment-Pending for %s",
1010
+ async (fileName) => {
1011
+ const { hash } = await createPendingReservation(
1012
+ `pending ${fileName}`,
1013
+ fileName,
1014
+ );
1015
+
1016
+ for (const [method, handler] of [
1017
+ ["HEAD", makeStatHandler(attachments)],
1018
+ ["GET", makeDownloadHandler(attachments)],
1019
+ ] as const) {
1020
+ const req = makeReq({ method, params: { hash } });
1021
+ const res = makeRes();
1022
+ await handler(req, res);
1023
+ await waitFor(res);
1024
+
1025
+ expect(res.statusCode).toBe(202);
1026
+ const parsed = JSON.parse(
1027
+ res.getHeader("attachment-pending") as string,
1028
+ ) as { fileName: string };
1029
+ expect(parsed.fileName).toBe(fileName);
1030
+ }
1031
+ },
1032
+ );
1033
+
998
1034
  it("GET download 202 body is empty (not a zero-byte attachment)", async () => {
999
1035
  const { hash } = await createPendingReservation("empty body assertion");
1000
1036
 
@@ -1215,6 +1251,54 @@ describe("attachment routes", () => {
1215
1251
  }
1216
1252
  });
1217
1253
 
1254
+ const NON_LATIN1_NAMES = [
1255
+ "opłata.pdf",
1256
+ "a – b’s.txt",
1257
+ "📎 notes.png",
1258
+ "報告書.pdf",
1259
+ "résumé.pdf",
1260
+ 'quote"back\\slash.txt',
1261
+ ];
1262
+
1263
+ it.each(NON_LATIN1_NAMES)(
1264
+ "buildMetadataHeader output is a valid header that parses back to %s",
1265
+ (fileName) => {
1266
+ const value = buildMetadataHeader({
1267
+ mimeType: "application/pdf",
1268
+ fileName,
1269
+ sizeBytes: 1,
1270
+ extension: null,
1271
+ createdAtUtc: "2026-01-01T00:00:00.000Z",
1272
+ lastAccessedAtUtc: "2026-01-01T00:00:00.000Z",
1273
+ });
1274
+ expect(() =>
1275
+ validateHeaderValue("Attachment-Metadata", value),
1276
+ ).not.toThrow();
1277
+ expect(value).toMatch(/^[\x20-\x7e]*$/);
1278
+ expect((JSON.parse(value) as { fileName: string }).fileName).toBe(
1279
+ fileName,
1280
+ );
1281
+ },
1282
+ );
1283
+
1284
+ it.each(NON_LATIN1_NAMES)(
1285
+ "buildPendingHeader output is a valid header that parses back to %s",
1286
+ (fileName) => {
1287
+ const value = buildPendingHeader({
1288
+ expiresAtUtc: "2026-01-01T00:00:00.000Z",
1289
+ mimeType: "application/pdf",
1290
+ fileName,
1291
+ sizeBytes: 1,
1292
+ });
1293
+ expect(() =>
1294
+ validateHeaderValue("Attachment-Pending", value),
1295
+ ).not.toThrow();
1296
+ expect((JSON.parse(value) as { fileName: string }).fileName).toBe(
1297
+ fileName,
1298
+ );
1299
+ },
1300
+ );
1301
+
1218
1302
  it("buildContentDisposition encodes RFC-5987-reserved chars in the encoded form", () => {
1219
1303
  const value = buildContentDisposition("a'b(c)*!.txt");
1220
1304
  expect(value).toContain("filename*=UTF-8''a%27b%28c%29%2A%21.txt");
@@ -1281,5 +1365,45 @@ describe("attachment routes", () => {
1281
1365
  ) as { fileName: string };
1282
1366
  expect(meta.fileName).toBe("résumé.pdf");
1283
1367
  });
1368
+
1369
+ it("HEAD and GET answer 200 with a parseable Attachment-Metadata for a name above U+00FF", async () => {
1370
+ const fileName = "opłata – 報告 📎.pdf";
1371
+ const reserveReq = makeReq({
1372
+ method: "POST",
1373
+ body: JSON.stringify({ mimeType: "application/pdf", fileName }),
1374
+ });
1375
+ const reserveRes = makeRes();
1376
+ await makeReserveHandler(attachments)(reserveReq, reserveRes);
1377
+ await waitFor(reserveRes);
1378
+ expect(reserveRes.statusCode).toBe(201);
1379
+ const { reservationId } = JSON.parse(
1380
+ reserveRes._body.toString("utf8"),
1381
+ ) as { reservationId: string };
1382
+
1383
+ const uploadRes = makeRes();
1384
+ await makeUploadHandler(attachments)(
1385
+ makeReq({ method: "PUT", params: { reservationId }, body: "pdf" }),
1386
+ uploadRes,
1387
+ );
1388
+ await waitFor(uploadRes);
1389
+ expect(uploadRes.statusCode).toBe(200);
1390
+ const { hash } = JSON.parse(uploadRes._body.toString("utf8")) as {
1391
+ hash: string;
1392
+ };
1393
+
1394
+ for (const [method, handler] of [
1395
+ ["HEAD", makeStatHandler(attachments)],
1396
+ ["GET", makeDownloadHandler(attachments)],
1397
+ ] as const) {
1398
+ const res = makeRes();
1399
+ await handler(makeReq({ method, params: { hash } }), res);
1400
+ await waitFor(res);
1401
+ expect(res.statusCode).toBe(200);
1402
+ const meta = JSON.parse(
1403
+ res.getHeader("attachment-metadata") as string,
1404
+ ) as { fileName: string };
1405
+ expect(meta.fileName).toBe(fileName);
1406
+ }
1407
+ });
1284
1408
  });
1285
1409
  });
@@ -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
+ });
@@ -0,0 +1,81 @@
1
+ import {
2
+ isDerivedDocumentId,
3
+ signaturePolicyOf,
4
+ } from "@powerhousedao/shared/document-model";
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import type { ILogger } from "document-model";
9
+ import { describe, expect, it, vi } from "vitest";
10
+ import type * as RenownModule from "../src/renown.js";
11
+ import { startSwitchboard } from "../src/server.mjs";
12
+
13
+ vi.mock("../src/renown.js", async (importOriginal) => ({
14
+ ...(await importOriginal<typeof RenownModule>()),
15
+ initRenown: vi.fn().mockRejectedValue(new Error("Renown is unreachable")),
16
+ }));
17
+
18
+ function stubLogger(): ILogger & { warn: ReturnType<typeof vi.fn> } {
19
+ const logger = {
20
+ verbose: vi.fn(),
21
+ debug: vi.fn(),
22
+ info: vi.fn(),
23
+ warn: vi.fn(),
24
+ error: vi.fn(),
25
+ child: vi.fn(),
26
+ };
27
+ logger.child.mockReturnValue(logger);
28
+ return logger as unknown as ILogger & { warn: ReturnType<typeof vi.fn> };
29
+ }
30
+
31
+ describe("booting Switchboard without Renown", () => {
32
+ it("warns and creates legacy documents, which it can write unsigned", async () => {
33
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-no-renown-"));
34
+ const previous = process.env.PH_REACTOR_DATABASE_URL;
35
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
36
+ const logger = stubLogger();
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
+ logger,
47
+ });
48
+
49
+ expect(
50
+ logger.warn.mock.calls.some(
51
+ ([message]) =>
52
+ typeof message === "string" &&
53
+ message.includes("new documents are created legacy"),
54
+ ),
55
+ ).toBe(true);
56
+
57
+ const document = await switchboard.reactor.createEmpty(
58
+ "powerhouse/document-model",
59
+ );
60
+ expect(signaturePolicyOf(document.header)).toBe("legacy");
61
+ expect(isDerivedDocumentId(document.header.id)).toBe(false);
62
+
63
+ const renamed = await switchboard.reactor.rename(
64
+ document.header.id,
65
+ "Renamed",
66
+ );
67
+ expect(renamed.header.name).toBe("Renamed");
68
+
69
+ await switchboard.shutdown();
70
+ switchboard = undefined;
71
+ } finally {
72
+ await switchboard?.shutdown();
73
+ if (previous === undefined) {
74
+ delete process.env.PH_REACTOR_DATABASE_URL;
75
+ } else {
76
+ process.env.PH_REACTOR_DATABASE_URL = previous;
77
+ }
78
+ await rm(tempRoot, { recursive: true, force: true });
79
+ }
80
+ }, 60_000);
81
+ });