@powerhousedao/switchboard 6.0.0-dev.21 → 6.0.0-dev.211

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 (78) hide show
  1. package/Auth.md +45 -27
  2. package/CHANGELOG.md +1642 -5
  3. package/README.md +13 -12
  4. package/dist/index.d.mts +1 -0
  5. package/dist/index.mjs +129 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/dist/install-packages.d.mts +1 -0
  8. package/dist/install-packages.mjs +31 -0
  9. package/dist/install-packages.mjs.map +1 -0
  10. package/dist/migrate.d.mts +1 -0
  11. package/dist/migrate.mjs +55 -0
  12. package/dist/migrate.mjs.map +1 -0
  13. package/dist/server-8U7q7B7r.mjs +493 -0
  14. package/dist/server-8U7q7B7r.mjs.map +1 -0
  15. package/dist/server.d.mts +93 -0
  16. package/dist/server.d.mts.map +1 -0
  17. package/dist/server.mjs +4 -0
  18. package/dist/utils-DFl0ezBT.mjs +44 -0
  19. package/dist/utils-DFl0ezBT.mjs.map +1 -0
  20. package/dist/utils.d.mts +9 -0
  21. package/dist/utils.d.mts.map +1 -0
  22. package/dist/utils.mjs +2 -0
  23. package/package.json +54 -39
  24. package/test/attachments/auth.test.ts +219 -0
  25. package/test/attachments/index.test.ts +119 -0
  26. package/test/attachments/routes-integration.test.ts +103 -0
  27. package/test/attachments/routes.test.ts +501 -0
  28. package/test/metrics.test.ts +202 -0
  29. package/tsconfig.json +12 -3
  30. package/tsdown.config.ts +16 -0
  31. package/vitest.config.ts +11 -0
  32. package/Dockerfile +0 -86
  33. package/dist/src/clients/redis.d.ts +0 -5
  34. package/dist/src/clients/redis.d.ts.map +0 -1
  35. package/dist/src/clients/redis.js +0 -48
  36. package/dist/src/clients/redis.js.map +0 -1
  37. package/dist/src/config.d.ts +0 -12
  38. package/dist/src/config.d.ts.map +0 -1
  39. package/dist/src/config.js +0 -33
  40. package/dist/src/config.js.map +0 -1
  41. package/dist/src/connect-crypto.d.ts +0 -41
  42. package/dist/src/connect-crypto.d.ts.map +0 -1
  43. package/dist/src/connect-crypto.js +0 -127
  44. package/dist/src/connect-crypto.js.map +0 -1
  45. package/dist/src/feature-flags.d.ts +0 -2
  46. package/dist/src/feature-flags.d.ts.map +0 -1
  47. package/dist/src/feature-flags.js +0 -9
  48. package/dist/src/feature-flags.js.map +0 -1
  49. package/dist/src/index.d.ts +0 -3
  50. package/dist/src/index.d.ts.map +0 -1
  51. package/dist/src/index.js +0 -21
  52. package/dist/src/index.js.map +0 -1
  53. package/dist/src/install-packages.d.ts +0 -2
  54. package/dist/src/install-packages.d.ts.map +0 -1
  55. package/dist/src/install-packages.js +0 -36
  56. package/dist/src/install-packages.js.map +0 -1
  57. package/dist/src/migrate.d.ts +0 -3
  58. package/dist/src/migrate.d.ts.map +0 -1
  59. package/dist/src/migrate.js +0 -65
  60. package/dist/src/migrate.js.map +0 -1
  61. package/dist/src/profiler.d.ts +0 -4
  62. package/dist/src/profiler.d.ts.map +0 -1
  63. package/dist/src/profiler.js +0 -17
  64. package/dist/src/profiler.js.map +0 -1
  65. package/dist/src/server.d.ts +0 -6
  66. package/dist/src/server.d.ts.map +0 -1
  67. package/dist/src/server.js +0 -304
  68. package/dist/src/server.js.map +0 -1
  69. package/dist/src/types.d.ts +0 -64
  70. package/dist/src/types.d.ts.map +0 -1
  71. package/dist/src/types.js +0 -2
  72. package/dist/src/types.js.map +0 -1
  73. package/dist/src/utils.d.ts +0 -6
  74. package/dist/src/utils.d.ts.map +0 -1
  75. package/dist/src/utils.js +0 -92
  76. package/dist/src/utils.js.map +0 -1
  77. package/dist/tsconfig.tsbuildinfo +0 -1
  78. package/entrypoint.sh +0 -17
@@ -0,0 +1,93 @@
1
+ import { ILogger } from "document-model";
2
+ import { MeterProvider } from "@opentelemetry/api";
3
+ import { IReactorClient } from "@powerhousedao/reactor";
4
+ import { DriveInput } from "@powerhousedao/shared/document-drive";
5
+ import { IRenown } from "@renown/sdk";
6
+
7
+ //#region src/types.d.ts
8
+ type StorageOptions = {
9
+ type: "filesystem" | "memory" | "postgres" | "browser";
10
+ filesystemPath?: string;
11
+ postgresUrl?: string;
12
+ };
13
+ type IdentityOptions = {
14
+ /** Path to the keypair file. Defaults to ~/.ph/keypair.json */keypairPath?: string;
15
+ /**
16
+ * If true, won't start without an existing keypair.
17
+ * Use this to ensure the switchboard only runs with an authenticated identity.
18
+ */
19
+ requireExisting?: boolean; /** Base url of the Renown instance to use */
20
+ baseUrl?: string; /** If true, unsigned actions will be rejected */
21
+ requireSignatures?: boolean;
22
+ };
23
+ type StartServerOptions = {
24
+ configFile?: string;
25
+ port?: number;
26
+ /**
27
+ * If true, fail immediately when the requested port is in use instead of
28
+ * falling back to the next free port. Matches the semantics of Vite's
29
+ * `--strictPort` flag that flows through the `ph vetra` command.
30
+ */
31
+ strictPort?: boolean;
32
+ dev?: boolean;
33
+ dbPath?: string;
34
+ drive?: DriveInput;
35
+ packages?: string[];
36
+ remoteDrives?: string[];
37
+ https?: {
38
+ keyPath: string;
39
+ certPath: string;
40
+ } | boolean | undefined;
41
+ auth?: {
42
+ enabled: boolean;
43
+ guests: string[];
44
+ users: string[];
45
+ admins: string[];
46
+ };
47
+ /**
48
+ * Identity options for Renown.
49
+ * When configured, the switchboard will load the keypair from `ph login`
50
+ * and can authenticate with remote services on behalf of the user.
51
+ */
52
+ identity?: IdentityOptions;
53
+ mcp?: boolean;
54
+ processorConfig?: Map<string, unknown>;
55
+ disableLocalPackages?: boolean;
56
+ enableDocumentModelSubgraphs?: boolean;
57
+ /**
58
+ * When true, enables dynamic loading of document models from the registry
59
+ * when an unknown document type is encountered during sync.
60
+ * Disabled by default — enable with DYNAMIC_MODEL_LOADING=true env var.
61
+ */
62
+ dynamicModelLoading?: boolean;
63
+ logger?: ILogger;
64
+ /**
65
+ * OpenTelemetry MeterProvider to register as the global provider before
66
+ * ReactorInstrumentation starts. Must be provided here rather than set
67
+ * externally to guarantee the registration happens before
68
+ * instrumentation.start() reads the global provider via metrics.getMeter().
69
+ */
70
+ meterProvider?: MeterProvider;
71
+ };
72
+ type SwitchboardReactor = {
73
+ defaultDriveUrl: string | undefined;
74
+ reactor: IReactorClient; /** The Renown instance if identity was initialized */
75
+ renown: IRenown | null;
76
+ /**
77
+ * Port the HTTP server actually bound to. May differ from the requested
78
+ * port when the requested port was in use and fallback kicked in.
79
+ */
80
+ port: number;
81
+ };
82
+ //#endregion
83
+ //#region src/server.d.mts
84
+ /**
85
+ * Attempt to bind a throwaway TCP server to the given port. Resolves true if
86
+ * the port is free, false if the OS reports it in use. Any other error is
87
+ * surfaced so we don't silently mask real issues (permissions, bad host, …).
88
+ */
89
+ declare function isPortAvailable(port: number): Promise<boolean>;
90
+ declare const startSwitchboard: (options?: StartServerOptions) => Promise<SwitchboardReactor>;
91
+ //#endregion
92
+ export { IdentityOptions, StartServerOptions, StorageOptions, SwitchboardReactor, isPortAvailable, startSwitchboard };
93
+ //# sourceMappingURL=server.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.mts","names":[],"sources":["../src/types.ts","../src/server.mts"],"mappings":";;;;;;;KAMY,cAAA;EACV,IAAA;EACA,cAAA;EACA,WAAA;AAAA;AAAA,KAGU,eAAA;EALV,+DAOA,WAAA;EALA;;;AAGF;EAOE,eAAA;EAGA,OAAA,WARA;EAWA,iBAAA;AAAA;AAAA,KAGU,kBAAA;EACV,UAAA;EACA,IAAA;EAFU;;;;;EAQV,UAAA;EACA,GAAA;EACA,MAAA;EACA,KAAA,GAAQ,UAAA;EACR,QAAA;EACA,YAAA;EACA,KAAA;IAEM,OAAA;IACA,QAAA;EAAA;EAIN,IAAA;IACE,OAAA;IACA,MAAA;IACA,KAAA;IACA,MAAA;EAAA;EARI;;;;;EAeN,QAAA,GAAW,eAAA;EACX,GAAA;EACA,eAAA,GAAkB,GAAA;EAClB,oBAAA;EACA,4BAAA;EAFkB;;;;;EAQlB,mBAAA;EACA,MAAA,GAAS,OAAA;EAOO;;;AAGlB;;;EAHE,aAAA,GAAgB,aAAA;AAAA;AAAA,KAGN,kBAAA;EACV,eAAA;EACA,OAAA,EAAS,cAAA,EAED;EAAR,MAAA,EAAQ,OAAA;EAKJ;;;;EAAJ,IAAA;AAAA;;;;;;;;iBCKc,eAAA,CAAgB,IAAA,WAAe,OAAA;AAAA,cA+TlC,gBAAA,GACX,OAAA,GAAS,kBAAA,KACR,OAAA,CAAQ,kBAAA"}
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { n as startSwitchboard, t as isPortAvailable } from "./server-8U7q7B7r.mjs";
3
+ import "./utils-DFl0ezBT.mjs";
4
+ export { isPortAvailable, startSwitchboard };
@@ -0,0 +1,44 @@
1
+ import { driveCreateDocument, driveCreateState } from "@powerhousedao/shared/document-drive";
2
+ import "@powerhousedao/shared/document-model";
3
+ //#region src/utils.mts
4
+ async function addDefaultDrive(client, drive, serverPort) {
5
+ let driveId = drive.id;
6
+ if (!driveId || driveId.length === 0) driveId = drive.slug;
7
+ if (!driveId || driveId.length === 0) throw new Error("Invalid Drive Id");
8
+ let existingDrive;
9
+ try {
10
+ existingDrive = await client.get(driveId);
11
+ } catch {}
12
+ if (existingDrive) return `http://localhost:${serverPort}/d/${driveId}`;
13
+ const { global } = driveCreateState();
14
+ const document = driveCreateDocument({
15
+ global: {
16
+ ...global,
17
+ name: drive.global.name,
18
+ icon: drive.global.icon ?? global.icon
19
+ },
20
+ local: {
21
+ availableOffline: drive.local?.availableOffline ?? false,
22
+ sharingType: drive.local?.sharingType ?? "public",
23
+ listeners: drive.local?.listeners ?? [],
24
+ triggers: drive.local?.triggers ?? []
25
+ }
26
+ });
27
+ if (drive.id && drive.id.length > 0) document.header.id = drive.id;
28
+ if (drive.slug && drive.slug.length > 0) document.header.slug = drive.slug;
29
+ if (drive.global.name) document.header.name = drive.global.name;
30
+ if (drive.preferredEditor) document.header.meta = { preferredEditor: drive.preferredEditor };
31
+ try {
32
+ await client.create(document);
33
+ } catch (e) {
34
+ if (!(e instanceof Error ? e.message : String(e)).includes("already exists")) throw e;
35
+ }
36
+ return `http://localhost:${serverPort}/d/${driveId}`;
37
+ }
38
+ function isPostgresUrl(url) {
39
+ return url.startsWith("postgresql") || url.startsWith("postgres");
40
+ }
41
+ //#endregion
42
+ export { isPostgresUrl as n, addDefaultDrive as t };
43
+
44
+ //# sourceMappingURL=utils-DFl0ezBT.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils-DFl0ezBT.mjs","names":[],"sources":["../src/utils.mts"],"sourcesContent":["import type { IReactorClient } from \"@powerhousedao/reactor\";\nimport type { DocumentDriveDocument } from \"@powerhousedao/shared/document-drive\";\nimport {\n driveCreateDocument,\n driveCreateState,\n} from \"@powerhousedao/shared/document-drive\";\nimport type { DriveInput } from \"@powerhousedao/shared/document-drive\";\nimport { generateId } from \"@powerhousedao/shared/document-model\";\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 document = 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 if (drive.id && drive.id.length > 0) {\n document.header.id = drive.id;\n }\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"],"mappings":";;;AASA,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;CACrC,MAAM,WAAW,oBAAoB;EACnC,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;AAEF,KAAI,MAAM,MAAM,MAAM,GAAG,SAAS,EAChC,UAAS,OAAO,KAAK,MAAM;AAE7B,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"}
@@ -0,0 +1,9 @@
1
+ import { IReactorClient } from "@powerhousedao/reactor";
2
+ import { DriveInput } from "@powerhousedao/shared/document-drive";
3
+
4
+ //#region src/utils.d.mts
5
+ declare function addDefaultDrive(client: IReactorClient, drive: DriveInput, serverPort: number): Promise<string>;
6
+ declare function isPostgresUrl(url: string): boolean;
7
+ //#endregion
8
+ export { addDefaultDrive, isPostgresUrl };
9
+ //# sourceMappingURL=utils.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.mts","names":[],"sources":["../src/utils.mts"],"mappings":";;;;iBASsB,eAAA,CACpB,MAAA,EAAQ,cAAA,EACR,KAAA,EAAO,UAAA,EACP,UAAA,WAAkB,OAAA;AAAA,iBAgEJ,aAAA,CAAc,GAAA"}
package/dist/utils.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import { n as isPostgresUrl, t as addDefaultDrive } from "./utils-DFl0ezBT.mjs";
2
+ export { addDefaultDrive, isPostgresUrl };
package/package.json CHANGED
@@ -1,20 +1,27 @@
1
1
  {
2
2
  "name": "@powerhousedao/switchboard",
3
3
  "type": "module",
4
- "version": "6.0.0-dev.21",
5
- "main": "dist/src/index.js",
4
+ "version": "6.0.0-dev.211",
5
+ "main": "dist/index.mjs",
6
6
  "exports": {
7
7
  ".": {
8
- "import": "./dist/src/index.js",
9
- "types": "./dist/src/index.d.ts"
8
+ "types": "./dist/index.d.mts",
9
+ "import": "./dist/index.mjs"
10
10
  },
11
11
  "./server": {
12
- "import": "./dist/src/server.js",
13
- "types": "./dist/src/server.d.ts"
12
+ "types": "./dist/server.d.mts",
13
+ "import": "./dist/server.mjs"
14
+ },
15
+ "./utils": {
16
+ "types": "./dist/utils.d.mts",
17
+ "import": "./dist/utils.mjs"
14
18
  }
15
19
  },
20
+ "engines": {
21
+ "node": ">=24.0.0"
22
+ },
16
23
  "bin": {
17
- "switchboard": "dist/src/index.js"
24
+ "switchboard": "dist/index.mjs"
18
25
  },
19
26
  "repository": {
20
27
  "type": "git",
@@ -24,48 +31,56 @@
24
31
  "license": "ISC",
25
32
  "description": "",
26
33
  "dependencies": {
27
- "@electric-sql/pglite": "0.2.17",
28
- "@openfeature/core": "^1.9.1",
29
- "@openfeature/env-var-provider": "^0.3.1",
30
- "@openfeature/server-sdk": "^1.19.0",
31
- "@powerhousedao/analytics-engine-core": "^0.5.0",
32
- "@powerhousedao/analytics-engine-knex": "^0.6.0",
34
+ "@electric-sql/pglite": "0.3.15",
35
+ "@openfeature/core": "1.9.1",
36
+ "@openfeature/env-var-provider": "0.3.1",
37
+ "@openfeature/server-sdk": "1.19.0",
38
+ "@opentelemetry/api": "^1.9.0",
39
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.57.2",
40
+ "@opentelemetry/resources": "^1.29.0",
41
+ "@opentelemetry/sdk-metrics": "^1.29.0",
33
42
  "@pyroscope/nodejs": "^0.4.5",
34
43
  "@sentry/node": "^9.6.1",
35
- "body-parser": "^1.20.3",
36
- "cors": "^2.8.5",
37
44
  "dotenv": "^16.4.7",
38
- "exponential-backoff": "^3.1.1",
39
45
  "express": "^4.21.2",
40
- "graphql": "^16.11.0",
41
- "kysely": "^0.28.2",
42
- "kysely-pglite-dialect": "^1.1.1",
43
- "pg": "^8.13.0",
44
- "redis": "^4.7.0",
45
- "@powerhousedao/config": "6.0.0-dev.21",
46
- "@powerhousedao/reactor": "6.0.0-dev.21",
47
- "@powerhousedao/reactor-api": "6.0.0-dev.21",
48
- "document-drive": "6.0.0-dev.21",
49
- "@renown/sdk": "6.0.0-dev.21",
50
- "document-model": "6.0.0-dev.21"
46
+ "kysely": "0.28.16",
47
+ "kysely-pglite-dialect": "1.2.0",
48
+ "pg": "8.18.0",
49
+ "vite": "8.0.8",
50
+ "@powerhousedao/config": "6.0.0-dev.211",
51
+ "@powerhousedao/opentelemetry-instrumentation-reactor": "6.0.0-dev.211",
52
+ "@powerhousedao/reactor": "6.0.0-dev.211",
53
+ "@powerhousedao/vetra": "6.0.0-dev.211",
54
+ "@powerhousedao/shared": "6.0.0-dev.211",
55
+ "@powerhousedao/reactor-api": "6.0.0-dev.211",
56
+ "@powerhousedao/reactor-attachments": "6.0.0-dev.211",
57
+ "@renown/sdk": "6.0.0-dev.211",
58
+ "document-model": "6.0.0-dev.211"
51
59
  },
52
60
  "devDependencies": {
53
61
  "@types/express": "^4.17.25",
54
- "@types/node": "^24.6.1",
55
- "@types/pg": "^8.11.10",
56
- "concurrently": "^9.1.2",
57
- "nodemon": "^3.1.9"
62
+ "@types/node": "25.2.3",
63
+ "@types/pg": "8.16.0",
64
+ "tsdown": "0.21.1",
65
+ "concurrently": "9.2.1",
66
+ "nodemon": "3.1.11",
67
+ "react": "19.2.4",
68
+ "vitest": "4.1.1"
69
+ },
70
+ "peerDependencies": {
71
+ "react": ">=19.0.0"
58
72
  },
59
73
  "scripts": {
60
74
  "tsc": "tsc",
75
+ "test": "vitest run",
61
76
  "lint": "eslint",
62
- "build": "pnpm run install-packages",
63
- "start": "node dist/src/index.js",
64
- "start:profile": "mkdir -p .prof && node --cpu-prof --cpu-prof-dir=.prof dist/src/index.js",
65
- "start:profile:bun": "mkdir -p .prof && bun --cpu-prof --cpu-prof-dir=.prof dist/src/index.js",
66
- "dev": "concurrently -P 'pnpm -w run tsc --watch' 'nodemon --trace-warnings --watch \"../..\" -e ts,tsx,js,json dist/src/index.js -- {@}' --",
67
- "install-packages": "node dist/src/install-packages.js",
68
- "migrate": "node dist/src/migrate.js",
69
- "migrate:status": "node dist/src/migrate.js status"
77
+ "build": "tsdown && pnpm run install-packages",
78
+ "start": "node dist/index.mjs",
79
+ "start:profile": "mkdir -p .prof && node --cpu-prof --cpu-prof-dir=.prof dist/index.mjs",
80
+ "start:profile:bun": "mkdir -p .prof && bun --cpu-prof --cpu-prof-dir=.prof dist/index.mjs",
81
+ "dev": "concurrently -P 'pnpm -w run tsc --watch' 'nodemon --trace-warnings --watch \"../..\" -e ts,tsx,js,json dist/index.mjs -- {@}' --",
82
+ "install-packages": "node dist/install-packages.mjs",
83
+ "migrate": "node dist/migrate.mjs",
84
+ "migrate:status": "node dist/migrate.mjs status"
70
85
  }
71
86
  }
@@ -0,0 +1,219 @@
1
+ import type { AuthService } from "@powerhousedao/reactor-api";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { requireAuth, type NodeHandler } from "../../src/attachments/auth.js";
5
+
6
+ type CapturedRes = ServerResponse & {
7
+ _headers: Record<string, string>;
8
+ _body: string;
9
+ _ended: boolean;
10
+ };
11
+
12
+ function makeReq(opts: {
13
+ method?: string;
14
+ url?: string;
15
+ headers?: Record<string, string>;
16
+ }): IncomingMessage {
17
+ return {
18
+ method: opts.method ?? "POST",
19
+ url: opts.url ?? "/attachments/reservations/abc",
20
+ headers: opts.headers ?? {},
21
+ } as unknown as IncomingMessage;
22
+ }
23
+
24
+ function makeRes(): CapturedRes {
25
+ const headers: Record<string, string> = {};
26
+ let body = "";
27
+ let ended = false;
28
+ const res = {
29
+ statusCode: 200,
30
+ setHeader(name: string, value: string | number | readonly string[]) {
31
+ headers[name.toLowerCase()] = String(value);
32
+ },
33
+ getHeader(name: string) {
34
+ return headers[name.toLowerCase()];
35
+ },
36
+ end(chunk?: string | Buffer) {
37
+ if (chunk !== undefined) {
38
+ body += typeof chunk === "string" ? chunk : chunk.toString("utf8");
39
+ }
40
+ ended = true;
41
+ },
42
+ } as unknown as CapturedRes;
43
+ Object.defineProperty(res, "_headers", { get: () => headers });
44
+ Object.defineProperty(res, "_body", { get: () => body });
45
+ Object.defineProperty(res, "_ended", { get: () => ended });
46
+ return res;
47
+ }
48
+
49
+ function makeAuthService(
50
+ impl: (authorization: string | undefined) => Promise<unknown>,
51
+ ): {
52
+ service: AuthService;
53
+ spy: ReturnType<typeof vi.fn>;
54
+ } {
55
+ const spy = vi.fn(
56
+ impl as (authorization: string | undefined) => Promise<unknown>,
57
+ );
58
+ const service = { verifyBearer: spy } as unknown as AuthService;
59
+ return { service, spy };
60
+ }
61
+
62
+ describe("requireAuth", () => {
63
+ it("returns the original handler unchanged when authService is undefined", async () => {
64
+ const handler: NodeHandler = vi.fn();
65
+ const wrapped = requireAuth(undefined, handler);
66
+ expect(wrapped).toBe(handler);
67
+ });
68
+
69
+ it("invokes the handler when authService is undefined (auth disabled path)", async () => {
70
+ const handler = vi.fn<NodeHandler>();
71
+ const wrapped = requireAuth(undefined, handler);
72
+ const req = makeReq({});
73
+ const res = makeRes();
74
+ await wrapped(req, res);
75
+ expect(handler).toHaveBeenCalledTimes(1);
76
+ expect(handler).toHaveBeenCalledWith(req, res);
77
+ });
78
+
79
+ it("returns 401 with { error: 'Authentication required' } when Authorization header is missing", async () => {
80
+ const { service, spy } = makeAuthService(async () => ({
81
+ user: undefined,
82
+ admins: [],
83
+ auth_enabled: true,
84
+ }));
85
+ const handler = vi.fn<NodeHandler>();
86
+ const wrapped = requireAuth(service, handler);
87
+
88
+ const res = makeRes();
89
+ await wrapped(makeReq({ headers: {} }), res);
90
+
91
+ expect(res.statusCode).toBe(401);
92
+ expect(res._headers["content-type"]).toBe("application/json");
93
+ expect(JSON.parse(res._body)).toEqual({ error: "Authentication required" });
94
+ expect(handler).not.toHaveBeenCalled();
95
+ expect(spy).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it("forwards a Response from AuthService (status, content-type, body) for an invalid bearer token", async () => {
99
+ const { service } = makeAuthService(
100
+ async () =>
101
+ new Response(JSON.stringify({ error: "Verification failed" }), {
102
+ status: 401,
103
+ headers: { "content-type": "application/json" },
104
+ }),
105
+ );
106
+ const handler = vi.fn<NodeHandler>();
107
+ const wrapped = requireAuth(service, handler);
108
+
109
+ const res = makeRes();
110
+ await wrapped(
111
+ makeReq({ headers: { authorization: "Bearer bad-token" } }),
112
+ res,
113
+ );
114
+
115
+ expect(res.statusCode).toBe(401);
116
+ expect(res._headers["content-type"]).toBe("application/json");
117
+ expect(JSON.parse(res._body)).toEqual({ error: "Verification failed" });
118
+ expect(handler).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("forwards a Response with a non-JSON content-type verbatim", async () => {
122
+ const { service } = makeAuthService(
123
+ async () =>
124
+ new Response("nope", {
125
+ status: 403,
126
+ headers: { "content-type": "text/plain" },
127
+ }),
128
+ );
129
+ const handler = vi.fn<NodeHandler>();
130
+ const wrapped = requireAuth(service, handler);
131
+
132
+ const res = makeRes();
133
+ await wrapped(
134
+ makeReq({ headers: { authorization: "Bearer bad-token" } }),
135
+ res,
136
+ );
137
+
138
+ expect(res.statusCode).toBe(403);
139
+ expect(res._headers["content-type"]).toBe("text/plain");
140
+ expect(res._body).toBe("nope");
141
+ expect(handler).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it("invokes the handler and leaves the response untouched on a valid bearer token", async () => {
145
+ const { service } = makeAuthService(async () => ({
146
+ user: { address: "0x123", chainId: 1, networkId: "mainnet" },
147
+ admins: [],
148
+ auth_enabled: true,
149
+ }));
150
+ const handler = vi.fn<NodeHandler>();
151
+ const wrapped = requireAuth(service, handler);
152
+
153
+ const req = makeReq({
154
+ headers: { authorization: "Bearer good-token" },
155
+ });
156
+ const res = makeRes();
157
+ await wrapped(req, res);
158
+
159
+ expect(handler).toHaveBeenCalledTimes(1);
160
+ expect(handler).toHaveBeenCalledWith(req, res);
161
+ expect(res.statusCode).toBe(200);
162
+ expect(res._body).toBe("");
163
+ expect(res._ended).toBe(false);
164
+ expect(res._headers["content-type"]).toBeUndefined();
165
+ });
166
+
167
+ it("returns 500 with a sanitized body when AuthService throws", async () => {
168
+ const { service } = makeAuthService(async () => {
169
+ throw new Error("transient Renown failure: secret-internal-detail");
170
+ });
171
+ const handler = vi.fn<NodeHandler>();
172
+ const wrapped = requireAuth(service, handler);
173
+
174
+ const res = makeRes();
175
+ await wrapped(
176
+ makeReq({ headers: { authorization: "Bearer good-token" } }),
177
+ res,
178
+ );
179
+
180
+ expect(res.statusCode).toBe(500);
181
+ expect(res._headers["content-type"]).toBe("application/json");
182
+ const parsed = JSON.parse(res._body) as { error: string };
183
+ expect(parsed.error).toBe("Internal authentication error");
184
+ expect(res._body).not.toContain("secret-internal-detail");
185
+ expect(res._body).not.toContain("Renown");
186
+ expect(handler).not.toHaveBeenCalled();
187
+ });
188
+
189
+ it("calls authService.verifyBearer with the incoming authorization header", async () => {
190
+ const { service, spy } = makeAuthService(async () => ({
191
+ user: { address: "0x1", chainId: 1, networkId: "mainnet" },
192
+ admins: [],
193
+ auth_enabled: true,
194
+ }));
195
+ const wrapped = requireAuth(service, vi.fn());
196
+
197
+ await wrapped(
198
+ makeReq({ headers: { authorization: "Bearer t" } }),
199
+ makeRes(),
200
+ );
201
+
202
+ expect(spy).toHaveBeenCalledTimes(1);
203
+ expect(spy).toHaveBeenCalledWith("Bearer t");
204
+ });
205
+
206
+ it("calls verifyBearer with undefined when no authorization header is present", async () => {
207
+ const { service, spy } = makeAuthService(async () => ({
208
+ user: undefined,
209
+ admins: [],
210
+ auth_enabled: true,
211
+ }));
212
+ const wrapped = requireAuth(service, vi.fn());
213
+
214
+ await wrapped(makeReq({ headers: {} }), makeRes());
215
+
216
+ expect(spy).toHaveBeenCalledTimes(1);
217
+ expect(spy).toHaveBeenCalledWith(undefined);
218
+ });
219
+ });
@@ -0,0 +1,119 @@
1
+ import type { API, AuthService } from "@powerhousedao/reactor-api";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { mountAuthenticatedNodeRoute } from "../../src/attachments/mount-auth.js";
5
+
6
+ type Captured = {
7
+ method: string;
8
+ path: string;
9
+ handler: (
10
+ req: IncomingMessage,
11
+ res: ServerResponse,
12
+ body?: unknown,
13
+ ) => void | Promise<void>;
14
+ };
15
+
16
+ function makeFakeApi(authService: AuthService | undefined): {
17
+ api: Pick<API, "httpAdapter" | "authService">;
18
+ captured: Captured[];
19
+ } {
20
+ const captured: Captured[] = [];
21
+ const api = {
22
+ httpAdapter: {
23
+ mountNodeRoute: (
24
+ method: string,
25
+ path: string,
26
+ handler: Captured["handler"],
27
+ ) => {
28
+ captured.push({ method, path, handler });
29
+ },
30
+ },
31
+ authService,
32
+ } as unknown as Pick<API, "httpAdapter" | "authService">;
33
+ return { api, captured };
34
+ }
35
+
36
+ function makeReq(headers: Record<string, string> = {}): IncomingMessage {
37
+ return { method: "POST", url: "/x", headers } as unknown as IncomingMessage;
38
+ }
39
+
40
+ function makeRes() {
41
+ const headers: Record<string, string> = {};
42
+ let body = "";
43
+ const res = {
44
+ statusCode: 200,
45
+ setHeader(name: string, value: string | number | readonly string[]) {
46
+ headers[name.toLowerCase()] = String(value);
47
+ },
48
+ end(chunk?: string | Buffer) {
49
+ if (chunk !== undefined) {
50
+ body += typeof chunk === "string" ? chunk : chunk.toString("utf8");
51
+ }
52
+ },
53
+ } as unknown as ServerResponse;
54
+ Object.defineProperty(res, "_headers", { get: () => headers });
55
+ Object.defineProperty(res, "_body", { get: () => body });
56
+ return res as ServerResponse & {
57
+ readonly _headers: Record<string, string>;
58
+ readonly _body: string;
59
+ };
60
+ }
61
+
62
+ describe("mountAuthenticatedNodeRoute", () => {
63
+ it("wraps the handler with auth enforcement when authService is defined", async () => {
64
+ const verifyBearer = vi.fn(async () => ({
65
+ user: undefined,
66
+ admins: [],
67
+ auth_enabled: true,
68
+ }));
69
+ const authService = { verifyBearer } as unknown as AuthService;
70
+ const { api, captured } = makeFakeApi(authService);
71
+ const inner = vi.fn();
72
+
73
+ mountAuthenticatedNodeRoute(api, "POST", "/x", inner);
74
+
75
+ expect(captured).toHaveLength(1);
76
+ expect(captured[0].method).toBe("POST");
77
+ expect(captured[0].path).toBe("/x");
78
+ // The mounted handler must NOT be the raw inner handler — it must be wrapped.
79
+ expect(captured[0].handler).not.toBe(inner);
80
+
81
+ const res = makeRes();
82
+ await captured[0].handler(makeReq(), res);
83
+
84
+ expect(verifyBearer).toHaveBeenCalledTimes(1);
85
+ expect(inner).not.toHaveBeenCalled();
86
+ expect(res.statusCode).toBe(401);
87
+ expect(JSON.parse(res._body)).toEqual({ error: "Authentication required" });
88
+ });
89
+
90
+ it("invokes the inner handler when verifyBearer returns a valid user", async () => {
91
+ const verifyBearer = vi.fn(async () => ({
92
+ user: { address: "0x1", chainId: 1, networkId: "mainnet" },
93
+ admins: [],
94
+ auth_enabled: true,
95
+ }));
96
+ const authService = { verifyBearer } as unknown as AuthService;
97
+ const { api, captured } = makeFakeApi(authService);
98
+ const inner = vi.fn();
99
+
100
+ mountAuthenticatedNodeRoute(api, "GET", "/x", inner);
101
+
102
+ await captured[0].handler(
103
+ makeReq({ authorization: "Bearer t" }),
104
+ makeRes(),
105
+ );
106
+
107
+ expect(inner).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it("mounts the inner handler unwrapped when authService is undefined", () => {
111
+ const { api, captured } = makeFakeApi(undefined);
112
+ const inner = vi.fn();
113
+
114
+ mountAuthenticatedNodeRoute(api, "PUT", "/x", inner);
115
+
116
+ expect(captured).toHaveLength(1);
117
+ expect(captured[0].handler).toBe(inner);
118
+ });
119
+ });