@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
@@ -1,1555 +0,0 @@
1
-
2
- !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]="e2c62b7b-68fd-59c2-ab82-7d035e11698b")}catch(e){}}();
3
- import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-Baw7rThP.mjs";
4
- import { register } from "node:module";
5
- import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
6
- import * as Sentry from "@sentry/node";
7
- import { childLogger, documentModelDocumentModelModule, setLogLevel } from "document-model";
8
- import dotenv from "dotenv";
9
- import { getConfig } from "@powerhousedao/config/node";
10
- import { existsSync, promises, realpathSync } from "node:fs";
11
- import path from "node:path";
12
- import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
13
- import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
14
- import { HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
15
- import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
16
- import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget } from "@powerhousedao/reactor-attachments";
17
- import { DriveNodeView, NodeProcessor, ReactorDriveClient, createReactorDriveResolvers, reactorDriveDocumentModelModule, reactorDriveSubgraphTypeDefs } from "@powerhousedao/reactor-drive";
18
- import { DEFAULT_RENOWN_URL, NodeKeyStorage, RENOWN_READ_MODEL_SUBGRAPH, RenownBuilder, RenownCryptoBuilder, createLocalCredentialVerifier, createSignatureVerifier } from "@renown/sdk/node";
19
- import { Kysely, PostgresDialect } from "kysely";
20
- import net from "node:net";
21
- import path$1 from "path";
22
- import { Pool } from "pg";
23
- import { Readable } from "node:stream";
24
- import { ReactorGroupV1 } from "@powerhousedao/reactor-group";
25
- import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
26
- import { readFile } from "node:fs/promises";
27
- import os from "node:os";
28
- import { fileURLToPath } from "node:url";
29
- import { EnvVarProvider } from "@openfeature/env-var-provider";
30
- import { OpenFeature } from "@openfeature/server-sdk";
31
- import { PGliteDialect } from "kysely-pglite-dialect";
32
- //#region src/pglite-version.ts
33
- const SUPPORTED_PG_MAJORS = [16, 17];
34
- async function readPgVersionFile(dataDir) {
35
- try {
36
- const raw = await promises.readFile(path.join(dataDir, "PG_VERSION"), "utf8");
37
- const major = parseInt(raw.trim(), 10);
38
- return Number.isFinite(major) ? major : null;
39
- } catch {
40
- return null;
41
- }
42
- }
43
- function isSupportedMajor(major) {
44
- return SUPPORTED_PG_MAJORS.includes(major);
45
- }
46
- /**
47
- * Parses the `PH_FORCE_PG_VERSION` env var. Returns the validated major, or
48
- * `null` when the var is unset/empty. Throws on any value that is not a
49
- * supported major — invalid configuration must fail before the server starts
50
- * touching disk.
51
- */
52
- function parseForcePgVersion(raw) {
53
- if (raw === void 0 || raw.trim() === "") return null;
54
- const parsed = Number(raw);
55
- if (Number.isInteger(parsed) && isSupportedMajor(parsed)) return parsed;
56
- throw new Error(`PH_FORCE_PG_VERSION must be one of: ${SUPPORTED_PG_MAJORS.join(", ")} (got: ${raw})`);
57
- }
58
- async function loadPGliteModule(major) {
59
- if (major === 16) return await import("pglite-legacy-02");
60
- return import("@electric-sql/pglite");
61
- }
62
- async function loadPgDump(major) {
63
- if (major === 16) return (await import("pglite-tools-legacy-02/pg_dump")).pgDump;
64
- return (await import("@electric-sql/pglite-tools/pg_dump")).pgDump;
65
- }
66
- //#endregion
67
- //#region src/attachments/auth.ts
68
- const ANONYMOUS_ACTOR = {
69
- user: void 0,
70
- authEnabled: false
71
- };
72
- /**
73
- * Wrap a Node-style handler so that, when `authService` is provided and auth is
74
- * enabled, the request must carry a verifiable Bearer token. The handler always
75
- * receives an actor context: the verified bearer user when auth is enabled, or
76
- * the anonymous context when it is disabled. With `allowAnonymous`, a missing
77
- * bearer yields an anonymous actor with `authEnabled: true` instead of a 401.
78
- */
79
- function requireAuth(authService, handler, options) {
80
- if (!authService) return (req, res, body) => handler(req, res, body, ANONYMOUS_ACTOR);
81
- return async (req, res, body) => {
82
- let result;
83
- try {
84
- result = await authService.verifyBearer(req.headers.authorization);
85
- } catch {
86
- res.statusCode = 500;
87
- res.setHeader("Content-Type", "application/json");
88
- res.end(JSON.stringify({ error: "Internal authentication error" }));
89
- return;
90
- }
91
- if (result instanceof Response) {
92
- const body = await result.text();
93
- res.statusCode = result.status;
94
- const contentType = result.headers.get("content-type");
95
- if (contentType) res.setHeader("Content-Type", contentType);
96
- res.end(body);
97
- return;
98
- }
99
- if (result.auth_enabled && !result.user && !options?.allowAnonymous) {
100
- res.statusCode = 401;
101
- res.setHeader("Content-Type", "application/json");
102
- res.end(JSON.stringify({ error: "Authentication required" }));
103
- return;
104
- }
105
- await handler(req, res, body, {
106
- user: result.user,
107
- authEnabled: result.auth_enabled
108
- });
109
- };
110
- }
111
- //#endregion
112
- //#region src/attachments/mount-auth.ts
113
- /**
114
- * Mount a Node-style attachment route with `requireAuth` applied unconditionally.
115
- * When `api.authService` is undefined (auth disabled), the handler still runs
116
- * through `requireAuth` and receives the anonymous actor context — that is the
117
- * only way to opt out of bearer verification. To register a route without auth
118
- * wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
119
- *
120
- * `allowAnonymous` is reserved for routes whose handlers make a per-document
121
- * authorization decision themselves; identity-only routes must not use it.
122
- */
123
- function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
124
- api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, options));
125
- }
126
- //#endregion
127
- //#region src/attachments/routes.ts
128
- const logger$1 = childLogger(["switchboard", "attachments"]);
129
- const RETRY_AFTER_SECONDS = 5;
130
- const HASH_PATTERN = /^[a-f0-9]{64}$/i;
131
- const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
132
- const MIME_TYPE_PATTERN = /^[!#$%&'*+\-.^_`|~\w]+\/[!#$%&'*+\-.^_`|~\w]+(?:\s*;\s*[!#$%&'*+\-.^_`|~\w]+=(?:[!#$%&'*+\-.^_`|~\w]+|"(?:[^"\\\r\n]|\\[^\r\n])*"))*$/;
133
- const MAX_FILENAME_LEN = 255;
134
- const MAX_MIMETYPE_LEN = 255;
135
- function sendJson(res, status, body) {
136
- res.statusCode = status;
137
- res.setHeader("Content-Type", "application/json");
138
- res.end(JSON.stringify(body));
139
- }
140
- function sendError(res, status, message) {
141
- sendJson(res, status, { error: message });
142
- }
143
- function statusForError(err) {
144
- if (err instanceof AttachmentNotFound) return 404;
145
- if (err instanceof ReservationNotFound) return 404;
146
- if (err instanceof InvalidAttachmentRef) return 400;
147
- if (err instanceof UploadTooLarge) return 413;
148
- return 500;
149
- }
150
- function sendErrorFromException(res, err) {
151
- const status = statusForError(err);
152
- if (status >= 500) {
153
- logger$1.error("Attachment route error: @error", err);
154
- sendError(res, status, "Internal error");
155
- return;
156
- }
157
- sendError(res, status, err instanceof Error ? err.message : String(err));
158
- }
159
- async function readJsonBody(req, body) {
160
- if (body !== void 0 && body !== null && typeof body === "object") return body;
161
- const chunks = [];
162
- for await (const chunk of req) chunks.push(chunk);
163
- if (chunks.length === 0) return void 0;
164
- const text = Buffer.concat(chunks).toString("utf8");
165
- if (text.length === 0) return void 0;
166
- return JSON.parse(text);
167
- }
168
- function parseReserveOptions(input) {
169
- if (input === null || typeof input !== "object") return null;
170
- const obj = input;
171
- if (typeof obj.mimeType !== "string" || obj.mimeType.length === 0 || obj.mimeType.length > MAX_MIMETYPE_LEN || !MIME_TYPE_PATTERN.test(obj.mimeType)) return null;
172
- if (typeof obj.fileName !== "string" || obj.fileName.length === 0 || obj.fileName.length > MAX_FILENAME_LEN || CONTROL_CHARS.test(obj.fileName)) return null;
173
- let extension = null;
174
- if (typeof obj.extension === "string") {
175
- if (obj.extension.length === 0 || /[\\/]/.test(obj.extension)) return null;
176
- extension = obj.extension;
177
- } else if (obj.extension !== void 0 && obj.extension !== null) return null;
178
- if (obj.clientHash !== void 0) {
179
- if (typeof obj.clientHash !== "string" || !HASH_PATTERN.test(obj.clientHash)) return null;
180
- if (typeof obj.sizeBytes !== "number" || !Number.isInteger(obj.sizeBytes) || obj.sizeBytes <= 0 || !Number.isSafeInteger(obj.sizeBytes)) return null;
181
- return {
182
- mimeType: obj.mimeType,
183
- fileName: obj.fileName,
184
- extension,
185
- clientHash: obj.clientHash.toLowerCase(),
186
- sizeBytes: obj.sizeBytes
187
- };
188
- }
189
- return {
190
- mimeType: obj.mimeType,
191
- fileName: obj.fileName,
192
- extension
193
- };
194
- }
195
- function quoteFilename(name) {
196
- return `"${name.replace(/[\\"]/g, "\\$&")}"`;
197
- }
198
- function buildContentDisposition(fileName) {
199
- const ascii = fileName.replace(/[^\x20-\x21\x23-\x5b\x5d-\x7e]/g, "_");
200
- const encoded = encodeURIComponent(fileName).replace(/['()*!]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
201
- return `attachment; filename=${quoteFilename(ascii)}; filename*=UTF-8''${encoded}`;
202
- }
203
- function makeReserveHandler(attachments) {
204
- return async (req, res, body) => {
205
- let parsed;
206
- try {
207
- parsed = await readJsonBody(req, body);
208
- } catch {
209
- sendError(res, 400, "Invalid JSON body");
210
- return;
211
- }
212
- const opts = parseReserveOptions(parsed);
213
- if (!opts) {
214
- sendError(res, 400, "Body must be { mimeType: string (type/subtype), fileName: string (no control characters, max 255 chars), extension?: string|null, clientHash?: string (64 hex chars), sizeBytes?: number (required with clientHash) }");
215
- return;
216
- }
217
- let upload;
218
- try {
219
- upload = await attachments.service.reserve(opts);
220
- } catch (err) {
221
- if (err instanceof AttachmentAlreadyExists) {
222
- sendJson(res, 409, {
223
- error: "already_exists",
224
- ref: err.ref
225
- });
226
- return;
227
- }
228
- sendErrorFromException(res, err);
229
- return;
230
- }
231
- sendJson(res, 201, {
232
- reservationId: upload.reservationId,
233
- ref: upload.ref,
234
- expiresAtUtc: upload.expiresAtUtc,
235
- ...upload.uploadTarget ? { uploadTarget: upload.uploadTarget } : {}
236
- });
237
- };
238
- }
239
- function makeUploadHandler(attachments) {
240
- return async (req, res) => {
241
- const reservationId = extractParam(req, "reservationId");
242
- if (!reservationId) {
243
- sendError(res, 400, "Missing reservationId");
244
- return;
245
- }
246
- if (attachments.backend?.kind === "s3") {
247
- sendError(res, 405, "Use the reservation uploadTarget for S3 uploads");
248
- return;
249
- }
250
- let reservation;
251
- try {
252
- reservation = await attachments.reservations.get(reservationId);
253
- } catch (err) {
254
- sendErrorFromException(res, err);
255
- return;
256
- }
257
- const upload = attachments.uploadFactory.createUpload(reservation);
258
- const webStream = Readable.toWeb(req);
259
- try {
260
- sendJson(res, 200, await upload.send(webStream));
261
- } catch (err) {
262
- if (err instanceof HashMismatch) {
263
- sendJson(res, 422, {
264
- error: "hash_mismatch",
265
- claimed: err.claimed,
266
- actual: err.actual
267
- });
268
- return;
269
- }
270
- if (err instanceof SizeMismatch) {
271
- sendJson(res, 422, {
272
- error: "size_mismatch",
273
- declared: err.declared,
274
- actual: err.actual
275
- });
276
- return;
277
- }
278
- sendErrorFromException(res, err);
279
- }
280
- };
281
- }
282
- function makeDownloadHandler(attachments) {
283
- return async (req, res) => {
284
- const hash = extractParam(req, "hash");
285
- if (!hash || !HASH_PATTERN.test(hash)) {
286
- sendError(res, 400, "Invalid attachment hash");
287
- return;
288
- }
289
- const controller = new AbortController();
290
- req.once("close", () => controller.abort());
291
- const canonicalHash = hash.toLowerCase();
292
- let response;
293
- try {
294
- response = await attachments.store.get(canonicalHash, controller.signal);
295
- } catch (err) {
296
- if (err instanceof AttachmentPending) {
297
- res.statusCode = 202;
298
- res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
299
- res.setHeader("Attachment-Pending", JSON.stringify({
300
- expiresAtUtc: err.expiresAtUtc,
301
- ...err.metadata ?? {}
302
- }));
303
- res.end();
304
- return;
305
- }
306
- sendErrorFromException(res, err);
307
- return;
308
- }
309
- const { header, body } = response;
310
- res.statusCode = 200;
311
- res.setHeader("Content-Type", header.mimeType);
312
- res.setHeader("Content-Length", String(header.sizeBytes));
313
- res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
314
- res.setHeader("Attachment-Metadata", buildMetadataHeader(header));
315
- Readable.fromWeb(body).pipe(res);
316
- };
317
- }
318
- function buildMetadataHeader(header) {
319
- return JSON.stringify({
320
- mimeType: header.mimeType,
321
- fileName: header.fileName,
322
- sizeBytes: header.sizeBytes,
323
- extension: header.extension,
324
- createdAtUtc: header.createdAtUtc,
325
- lastAccessedAtUtc: header.lastAccessedAtUtc
326
- });
327
- }
328
- function makeStatHandler(attachments) {
329
- return async (req, res) => {
330
- const hash = extractParam(req, "hash");
331
- if (!hash || !HASH_PATTERN.test(hash)) {
332
- sendError(res, 400, "Invalid attachment hash");
333
- return;
334
- }
335
- const canonicalHash = hash.toLowerCase();
336
- let header;
337
- try {
338
- header = await attachments.store.stat(canonicalHash);
339
- } catch (err) {
340
- sendErrorFromException(res, err);
341
- return;
342
- }
343
- if (header.status === "pending") {
344
- res.statusCode = 202;
345
- res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
346
- res.setHeader("Attachment-Pending", JSON.stringify({
347
- expiresAtUtc: header.expiresAtUtc,
348
- mimeType: header.mimeType,
349
- fileName: header.fileName,
350
- sizeBytes: header.sizeBytes
351
- }));
352
- res.end();
353
- return;
354
- }
355
- res.statusCode = 200;
356
- res.setHeader("Content-Type", header.mimeType);
357
- res.setHeader("Content-Length", String(header.sizeBytes));
358
- res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
359
- res.setHeader("Attachment-Metadata", buildMetadataHeader(header));
360
- res.end();
361
- };
362
- }
363
- function makeGetReservationHandler(attachments) {
364
- return async (req, res) => {
365
- const reservationId = extractParam(req, "reservationId");
366
- if (!reservationId) {
367
- sendError(res, 400, "Missing reservationId");
368
- return;
369
- }
370
- try {
371
- sendJson(res, 200, await attachments.reservations.get(reservationId));
372
- } catch (err) {
373
- sendErrorFromException(res, err);
374
- }
375
- };
376
- }
377
- function makeDeleteReservationHandler(attachments) {
378
- return async (req, res) => {
379
- const reservationId = extractParam(req, "reservationId");
380
- if (!reservationId) {
381
- sendError(res, 400, "Missing reservationId");
382
- return;
383
- }
384
- try {
385
- await attachments.reservations.delete(reservationId);
386
- res.statusCode = 204;
387
- res.end();
388
- } catch (err) {
389
- sendErrorFromException(res, err);
390
- }
391
- };
392
- }
393
- function extractParam(req, name) {
394
- return req.params?.[name];
395
- }
396
- const MAX_DOCUMENT_ID_LEN = 512;
397
- const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
398
- const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 10080 * 60;
399
- /**
400
- * Returns the single `documentId` query value, or null when it is missing,
401
- * duplicated, blank, or oversized. Validation happens before authorization so
402
- * malformed requests never reach the access service.
403
- */
404
- function extractSingleDocumentId(req) {
405
- if (!req.url) return null;
406
- let url;
407
- try {
408
- url = new URL(req.url, "http://switchboard.invalid");
409
- } catch {
410
- return null;
411
- }
412
- const values = url.searchParams.getAll("documentId");
413
- if (values.length !== 1) return null;
414
- const value = values[0];
415
- if (value.trim().length === 0 || value.length > MAX_DOCUMENT_ID_LEN) return null;
416
- return value;
417
- }
418
- /**
419
- * Returns the requested target TTL in seconds: undefined when absent,
420
- * "invalid" when malformed (duplicated, non-integer, or non-positive), and
421
- * otherwise the value clamped to the presigning ceiling.
422
- */
423
- function extractExpiresIn(req) {
424
- if (!req.url) return void 0;
425
- let url;
426
- try {
427
- url = new URL(req.url, "http://switchboard.invalid");
428
- } catch {
429
- return;
430
- }
431
- const values = url.searchParams.getAll("expiresIn");
432
- if (values.length === 0) return void 0;
433
- if (values.length > 1) return "invalid";
434
- const parsed = Number(values[0]);
435
- if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
436
- return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
437
- }
438
- /**
439
- * Base URL of this Switchboard as seen by the caller, used to build
440
- * filesystem `switchboard` download targets that point back at the existing
441
- * authenticated byte route.
442
- */
443
- function requestBaseUrl(req) {
444
- const forwardedProto = req.headers["x-forwarded-proto"];
445
- const proto = (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0]?.trim() || (req.socket.encrypted ? "https" : "http");
446
- const forwardedHost = req.headers["x-forwarded-host"];
447
- const host = (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? req.headers.host;
448
- if (!host) return null;
449
- return `${proto}://${host}`;
450
- }
451
- function makeDownloadTargetHandler(attachments, attachmentAccess) {
452
- return async (req, res, _body, actor) => {
453
- res.setHeader("Cache-Control", "no-store");
454
- const hash = extractParam(req, "hash");
455
- if (!hash || !HASH_PATTERN.test(hash)) {
456
- sendError(res, 400, "Invalid attachment hash");
457
- return;
458
- }
459
- const documentId = extractSingleDocumentId(req);
460
- if (documentId === null) {
461
- sendError(res, 400, "documentId is required exactly once as a non-empty query parameter");
462
- return;
463
- }
464
- const expiresIn = extractExpiresIn(req);
465
- if (expiresIn === "invalid") {
466
- sendError(res, 400, "expiresIn must be a single positive integer number of seconds");
467
- return;
468
- }
469
- const canonicalHash = hash.toLowerCase();
470
- let decision;
471
- try {
472
- decision = await attachmentAccess.canReadAttachment({
473
- documentId,
474
- attachmentRef: createRef(canonicalHash),
475
- userAddress: actor?.user?.address
476
- });
477
- } catch (err) {
478
- logger$1.error("Attachment access decision failed: @error", err);
479
- sendError(res, 500, "Internal error");
480
- return;
481
- }
482
- if (decision.kind === "projection-unavailable") {
483
- sendError(res, 503, "Attachment downloads are temporarily unavailable");
484
- return;
485
- }
486
- if (decision.kind === "denied") {
487
- sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
488
- return;
489
- }
490
- let header;
491
- try {
492
- header = await attachments.store.stat(canonicalHash);
493
- } catch (err) {
494
- if (err instanceof AttachmentNotFound) {
495
- sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
496
- return;
497
- }
498
- logger$1.error("Attachment metadata lookup failed: @error", err);
499
- sendError(res, 500, "Internal error");
500
- return;
501
- }
502
- if (header.status !== "available") {
503
- sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
504
- return;
505
- }
506
- let target;
507
- if (attachments.backend && attachments.backend.kind !== "filesystem") try {
508
- target = await attachments.backend.prepareDownloadTarget(canonicalHash, expiresIn);
509
- } catch {
510
- sendError(res, 502, "Attachment download target unavailable");
511
- return;
512
- }
513
- else {
514
- const base = requestBaseUrl(req);
515
- if (!base) {
516
- sendError(res, 500, "Internal error");
517
- return;
518
- }
519
- try {
520
- target = parseAttachmentDownloadTarget({
521
- kind: "switchboard",
522
- method: "GET",
523
- url: `${base}/attachments/${canonicalHash}`,
524
- headers: {}
525
- });
526
- } catch {
527
- sendError(res, 500, "Internal error");
528
- return;
529
- }
530
- }
531
- sendJson(res, 200, target);
532
- };
533
- }
534
- //#endregion
535
- //#region src/attachments/index.ts
536
- function registerAttachmentRoutes(api) {
537
- const { attachments } = api;
538
- mountAuthenticatedNodeRoute(api, "POST", "/attachments/reservations", makeReserveHandler(attachments));
539
- mountAuthenticatedNodeRoute(api, "GET", "/attachments/reservations/:reservationId", makeGetReservationHandler(attachments));
540
- mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
541
- mountAuthenticatedNodeRoute(api, "PUT", "/attachments/reservations/:reservationId", makeUploadHandler(attachments));
542
- mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments));
543
- mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, api.attachmentAccess), { allowAnonymous: true });
544
- mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments));
545
- }
546
- //#endregion
547
- //#region src/attachment-reference-read-model.mts
548
- const attachmentSchemaCompiler = new AttachmentSchemaCompiler();
549
- function createAttachmentReferenceReadModel(baseKysely, dependencies, attachmentReferenceWriter) {
550
- return new AttachmentReferenceReadModel(baseKysely.withSchema(REACTOR_SCHEMA), dependencies.operationIndex, dependencies.writeCache, dependencies.processorManagerConsistencyTracker, dependencies.documentModelRegistry, attachmentSchemaCompiler, attachmentReferenceWriter);
551
- }
552
- function registerAttachmentReferenceReadModel(reactorBuilder, registration) {
553
- reactorBuilder.withReadModelFactory(async ({ documentModelRegistry, operationIndex, writeCache, processorManagerConsistencyTracker }) => {
554
- const readModel = createAttachmentReferenceReadModel(registration.baseKysely, {
555
- operationIndex,
556
- writeCache,
557
- processorManagerConsistencyTracker,
558
- documentModelRegistry
559
- }, registration.attachmentReferenceWriter);
560
- await readModel.init();
561
- return readModel;
562
- });
563
- }
564
- async function registerAttachmentReferenceReadModelOnModule(clientModule, attachmentReferenceWriter) {
565
- const reactorModule = clientModule.reactorModule;
566
- if (!reactorModule) return {
567
- status: "unavailable",
568
- reason: "in-process-reactor-module-unavailable"
569
- };
570
- const coordinator = reactorModule.readModelCoordinator;
571
- if (!supportsLiveReadModelRegistration(coordinator)) return {
572
- status: "unavailable",
573
- reason: "live-read-model-registration-unsupported"
574
- };
575
- const readModel = createAttachmentReferenceReadModel(reactorModule.database, {
576
- operationIndex: reactorModule.operationIndex,
577
- writeCache: reactorModule.writeCache,
578
- processorManagerConsistencyTracker: reactorModule.processorManagerConsistencyTracker,
579
- documentModelRegistry: reactorModule.documentModelRegistry
580
- }, attachmentReferenceWriter);
581
- await readModel.init();
582
- coordinator.addReadModel(readModel, "pre_ready");
583
- await readModel.init();
584
- return { status: "available" };
585
- }
586
- //#endregion
587
- //#region src/builder-defaults.mts
588
- /**
589
- * Apply switchboard's standard configuration to a reactor + client builder
590
- * pair. Each piece is opt-out via the options object; defaults mirror what
591
- * `startSwitchboard` does when building a reactor itself. Mutates both
592
- * builders in place.
593
- *
594
- * Does NOT touch kysely or read models — callers wire those themselves
595
- * (see `withKysely` / `withReadModelFactory` on the reactor builder).
596
- */
597
- /** The models switchboard always registers alongside package models. */
598
- function switchboardBaseDocumentModels() {
599
- return [
600
- documentModelDocumentModelModule,
601
- driveDocumentModelModule,
602
- reactorDriveDocumentModelModule,
603
- ReactorGroupV1
604
- ];
605
- }
606
- function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options = {}) {
607
- const baseModels = options.includeBaseModels !== false ? switchboardBaseDocumentModels() : [];
608
- const extra = options.documentModels ?? [];
609
- if (baseModels.length || extra.length) reactorBuilder.withDocumentModelSources(getUniqueDocumentModels(baseModels, extra));
610
- if (options.upgradeManifests?.length) reactorBuilder.withUpgradeManifests(options.upgradeManifests);
611
- const scheme = options.channelScheme === void 0 ? ChannelScheme.SWITCHBOARD : options.channelScheme;
612
- if (scheme !== false) reactorBuilder.withChannelScheme(scheme);
613
- if (options.signalHandlers !== false) reactorBuilder.withSignalHandlers();
614
- if (options.executorConfig?.maxSkipThreshold !== void 0 || options.executorConfig?.featureFlags !== void 0) reactorBuilder.withExecutorConfig({
615
- ...options.executorConfig.maxSkipThreshold !== void 0 ? { maxSkipThreshold: options.executorConfig.maxSkipThreshold } : {},
616
- ...options.executorConfig.featureFlags !== void 0 ? { featureFlags: options.executorConfig.featureFlags } : {}
617
- });
618
- if (options.documentModelLoader) reactorBuilder.withDocumentModelLoader(options.documentModelLoader);
619
- if (options.logger) reactorBuilder.withLogger(options.logger);
620
- if (options.signer) clientBuilder.withSigner(options.signer);
621
- }
622
- //#endregion
623
- //#region src/worker-pool.mts
624
- const DEFAULT_DB_POOL_SIZE_PER_WORKER = 2;
625
- const DEFAULT_ACQUIRE_TIMEOUT_MS = 5e3;
626
- const DEFAULT_DB_POOL_SIZE_HOST = 16;
627
- const AUTO_RESERVED_CORES = 2;
628
- const AUTO_WORKER_CAP = 8;
629
- /** Worker count for "auto": always at least 1 — auto sizes the pool, it does not turn worker mode off. */
630
- function autoWorkerCount(availableCores) {
631
- return Math.max(1, Math.min(AUTO_WORKER_CAP, availableCores - AUTO_RESERVED_CORES));
632
- }
633
- /**
634
- * Effective worker-pool config from programmatic options (which win) and the
635
- * REACTOR_* env vars; null when disabled (numWorkers 0, the default).
636
- * `"auto"` sizes the pool from the machine's available cores.
637
- */
638
- function resolveWorkerPoolOptions(input, env, availableCores = os.availableParallelism()) {
639
- const requested = input?.numWorkers ?? parseWorkerCount(env.REACTOR_WORKERS) ?? 0;
640
- const mode = requested === "auto" ? "auto" : "explicit";
641
- const numWorkers = requested === "auto" ? autoWorkerCount(availableCores) : requested;
642
- if (!Number.isInteger(numWorkers) || numWorkers < 0) throw new Error(`workerPool.numWorkers must be "auto" or a non-negative integer, got ${numWorkers}`);
643
- if (numWorkers === 0) return null;
644
- return {
645
- numWorkers,
646
- mode,
647
- dbPoolSizePerWorker: input?.dbPoolSizePerWorker ?? parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_WORKER, "REACTOR_DB_POOL_SIZE_WORKER") ?? DEFAULT_DB_POOL_SIZE_PER_WORKER,
648
- acquireTimeoutMs: input?.acquireTimeoutMs ?? parseNonNegativeInt(env.REACTOR_DB_ACQUIRE_TIMEOUT_MS, "REACTOR_DB_ACQUIRE_TIMEOUT_MS") ?? DEFAULT_ACQUIRE_TIMEOUT_MS
649
- };
650
- }
651
- /**
652
- * Size of the reactor's own (host) Postgres pool. Unlike the worker pool there
653
- * is no "disabled" state — the host pool is the only Postgres pool in the
654
- * server path, backing both reactor storage and the read models that reach it
655
- * via `withSchema` — so 0 is rejected rather than read as "off".
656
- *
657
- * The acquire timeout is deliberately not configurable here: the host pool
658
- * waits indefinitely today, and making it finite converts saturation from a
659
- * latency problem into a thrown error on the read-model path. That is a
660
- * behavior change worth making separately, once the retry semantics of a
661
- * rejected `pool.connect()` inside the read-model coordinator are settled.
662
- */
663
- function resolveHostPoolSize(env) {
664
- const poolSize = parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_HOST, "REACTOR_DB_POOL_SIZE_HOST") ?? DEFAULT_DB_POOL_SIZE_HOST;
665
- if (poolSize < 1) throw new Error("REACTOR_DB_POOL_SIZE_HOST must be at least 1; the host pool cannot be disabled");
666
- return poolSize;
667
- }
668
- /**
669
- * DbConfig for each worker's own pool, parsed from a postgres:// URL.
670
- * Explicit host, database, and user are required — no pg-side defaults.
671
- */
672
- function buildWorkerDbConfig(postgresUrl, options) {
673
- let parsed;
674
- try {
675
- parsed = new URL(postgresUrl);
676
- } catch {
677
- throw new Error(`Worker pool requires a valid postgres:// URL for the reactor database, got "${postgresUrl}"`);
678
- }
679
- const database = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
680
- if (!parsed.hostname || !database) throw new Error(`Worker pool requires a postgres URL with a host and database name, got "${postgresUrl}"`);
681
- if (!parsed.username) throw new Error(`Worker pool requires explicit credentials in the postgres URL (workers open their own connections), got "${postgresUrl}"`);
682
- const sslmode = parsed.searchParams.get("sslmode");
683
- const ssl = parsed.searchParams.get("ssl") === "true" || sslmode !== null && sslmode !== "disable";
684
- return {
685
- host: parsed.hostname,
686
- port: parsed.port ? Number(parsed.port) : 5432,
687
- database,
688
- user: decodeURIComponent(parsed.username),
689
- password: decodeURIComponent(parsed.password),
690
- ssl,
691
- applicationName: "switchboard-worker",
692
- poolSize: options.dbPoolSizePerWorker,
693
- connectionTimeoutMillis: options.acquireTimeoutMs
694
- };
695
- }
696
- /** Specifiers of the base models switchboard always registers. */
697
- const BASE_MODEL_SPECIFIERS = [
698
- "document-model",
699
- "@powerhousedao/shared/document-drive",
700
- "@powerhousedao/reactor-drive",
701
- "@powerhousedao/reactor-group/document-models"
702
- ];
703
- /**
704
- * Resolves base models and configured package identifiers to `{ filePath }`
705
- * sources for the reactor builder. Resolution to absolute paths happens here
706
- * because workers (and the builder) resolve bare specifiers from the reactor
707
- * package's own dependency context, which cannot see switchboard or project
708
- * packages. Unresolvable identifiers are skipped with a warning; the builder
709
- * fails the boot if a registered model ends up without an importable source.
710
- */
711
- async function resolveWorkerModelSources(packages, logger) {
712
- const sources = [];
713
- for (const identifier of [...BASE_MODEL_SPECIFIERS, ...packages]) {
714
- const filePath = await resolveModelModuleFile(identifier);
715
- if (!filePath) {
716
- logger.warn(`Worker model sources: no importable document-models entry for "${identifier}", skipping`);
717
- continue;
718
- }
719
- sources.push({ filePath });
720
- }
721
- return sources;
722
- }
723
- /**
724
- * Absolute file path of a source's models barrel. Mirrors reactor-api's
725
- * import-resolver: import.meta.resolve, then the project's node_modules via
726
- * the package.json exports map / dist layout.
727
- */
728
- async function resolveModelModuleFile(source) {
729
- if (isFsPath(source)) return resolveFromPackageDir(source, "./document-models");
730
- const specifier = BASE_MODEL_SPECIFIERS.includes(source) ? source : `${source}/document-models`;
731
- try {
732
- const resolved = import.meta.resolve(specifier);
733
- if (resolved.startsWith("file:")) return fileURLToPath(resolved);
734
- } catch {}
735
- const { packageName, subpath } = splitSpecifier(specifier);
736
- const packageDir = path.join(process.cwd(), "node_modules", packageName);
737
- if (!existsSync(packageDir)) return null;
738
- let realDir;
739
- try {
740
- realDir = realpathSync(packageDir);
741
- } catch {
742
- return null;
743
- }
744
- return resolveFromPackageDir(realDir, subpath);
745
- }
746
- function splitSpecifier(specifier) {
747
- const parts = specifier.split("/");
748
- const packageSegments = specifier.startsWith("@") ? 2 : 1;
749
- const packageName = parts.slice(0, packageSegments).join("/");
750
- const rest = parts.slice(packageSegments).join("/");
751
- return {
752
- packageName,
753
- subpath: rest ? `./${rest}` : "."
754
- };
755
- }
756
- /** Exports-map lookup (import condition), then conventional dist paths. */
757
- async function resolveFromPackageDir(packageDir, subpath) {
758
- const fromManifest = await resolveViaManifest(packageDir, subpath);
759
- if (fromManifest) return fromManifest;
760
- const candidates = subpath === "." ? [path.join(packageDir, "dist", "index.js")] : [
761
- path.join(packageDir, "dist", subpath.slice(2), "index.js"),
762
- path.join(packageDir, "dist", `${subpath.slice(2)}.js`),
763
- path.join(packageDir, subpath.slice(2), "index.js")
764
- ];
765
- for (const candidate of candidates) if (existsSync(candidate)) return candidate;
766
- return null;
767
- }
768
- async function resolveViaManifest(packageDir, subpath) {
769
- let raw;
770
- try {
771
- raw = await readFile(path.join(packageDir, "package.json"), "utf8");
772
- } catch {
773
- return null;
774
- }
775
- let manifest;
776
- try {
777
- manifest = JSON.parse(raw);
778
- } catch {
779
- return null;
780
- }
781
- let entry;
782
- const exportsField = manifest.exports;
783
- if (exportsField !== null && typeof exportsField === "object" && !Array.isArray(exportsField)) {
784
- const map = exportsField;
785
- entry = Object.keys(map).some((key) => key.startsWith(".")) ? map[subpath] : subpath === "." ? map : void 0;
786
- } else if (typeof exportsField === "string" && subpath === ".") entry = exportsField;
787
- let target = pickImportTarget(entry);
788
- if (!target && subpath === "." && typeof manifest.main === "string") target = manifest.main;
789
- if (!target) return null;
790
- const resolved = path.join(packageDir, target);
791
- return existsSync(resolved) ? resolved : null;
792
- }
793
- function pickImportTarget(entry) {
794
- if (typeof entry === "string") return entry;
795
- if (typeof entry !== "object" || entry === null) return null;
796
- const conditions = entry;
797
- for (const condition of [
798
- "import",
799
- "node",
800
- "default"
801
- ]) {
802
- const value = conditions[condition];
803
- if (typeof value === "string") return value;
804
- }
805
- return null;
806
- }
807
- function isFsPath(identifier) {
808
- return path.isAbsolute(identifier) || identifier.startsWith("./") || identifier.startsWith("../");
809
- }
810
- function parseWorkerCount(raw) {
811
- if (raw === void 0 || raw.trim() === "") return;
812
- if (raw.trim().toLowerCase() === "auto") return "auto";
813
- const value = Number.parseInt(raw, 10);
814
- if (!Number.isInteger(value) || value < 0 || String(value) !== raw.trim()) throw new Error(`REACTOR_WORKERS must be "auto" or a non-negative integer, got "${raw}"`);
815
- return value;
816
- }
817
- function parseNonNegativeInt(raw, name) {
818
- if (raw === void 0 || raw.trim() === "") return;
819
- const value = Number.parseInt(raw, 10);
820
- if (!Number.isInteger(value) || value < 0 || String(value) !== raw.trim()) throw new Error(`${name} must be a non-negative integer, got "${raw}"`);
821
- return value;
822
- }
823
- //#endregion
824
- //#region src/projection-worker.mts
825
- const DEFAULT_DB_POOL_SIZE_PROJECTION = 8;
826
- const ON_TOKENS = [
827
- "1",
828
- "true",
829
- "on",
830
- "yes"
831
- ];
832
- const OFF_TOKENS = [
833
- "0",
834
- "false",
835
- "off",
836
- "no"
837
- ];
838
- /**
839
- * Enabled by REACTOR_PROJECTION_WORKER (1/true/on/yes, case-insensitive) or
840
- * `input.enabled`, which wins when defined; null when off (the default).
841
- */
842
- function resolveProjectionWorkerOptions(input, env) {
843
- if (!(input?.enabled ?? parseOnOff(env.REACTOR_PROJECTION_WORKER) ?? false)) return null;
844
- const dbPoolSize = input?.dbPoolSize ?? parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_PROJECTION, "REACTOR_DB_POOL_SIZE_PROJECTION") ?? DEFAULT_DB_POOL_SIZE_PROJECTION;
845
- if (dbPoolSize < 1) throw new Error("REACTOR_DB_POOL_SIZE_PROJECTION must be at least 1; the projection worker cannot run without a pool");
846
- return { dbPoolSize };
847
- }
848
- /** Same preconditions as the executor worker pool; message shape mirrors server.mts. */
849
- function assertProjectionWorkerSupported(args) {
850
- if (args.dev) throw new Error("The projection worker (REACTOR_PROJECTION_WORKER) is not supported in dev mode: Vite-loaded document models cannot cross a worker-thread boundary");
851
- if (!args.reactorDbUrl || !isPostgresUrl(args.reactorDbUrl)) throw new Error("The projection worker (REACTOR_PROJECTION_WORKER) requires a Postgres reactor database — set PH_REACTOR_DATABASE_URL or PH_SWITCHBOARD_DATABASE_URL. PGlite cannot be shared across worker threads.");
852
- }
853
- function parseOnOff(raw) {
854
- if (raw === void 0 || raw.trim() === "") return;
855
- const token = raw.trim().toLowerCase();
856
- if (ON_TOKENS.includes(token)) return true;
857
- if (OFF_TOKENS.includes(token)) return false;
858
- throw new Error(`REACTOR_PROJECTION_WORKER must be one of 1, true, on, yes, 0, false, off, no, got "${raw}"`);
859
- }
860
- //#endregion
861
- //#region src/feature-flags.ts
862
- async function initFeatureFlags() {
863
- const provider = new EnvVarProvider();
864
- await OpenFeature.setProviderAndWait(provider);
865
- return OpenFeature.getClient();
866
- }
867
- //#endregion
868
- //#region src/pglite-dialect.ts
869
- var ClosablePGliteDialect = class extends PGliteDialect {
870
- #pglite;
871
- constructor(pglite) {
872
- super(pglite);
873
- this.#pglite = pglite;
874
- }
875
- createDriver() {
876
- const driver = super.createDriver();
877
- const pglite = this.#pglite;
878
- const innerDestroy = driver.destroy.bind(driver);
879
- driver.destroy = async () => {
880
- await innerDestroy();
881
- if (!pglite.closed) await pglite.close();
882
- };
883
- return driver;
884
- }
885
- };
886
- //#endregion
887
- //#region src/pglite-migration.ts
888
- function backupPath(dataDir, major) {
889
- return `${dataDir}.backup-pg${major}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
890
- }
891
- async function pathExists(p) {
892
- try {
893
- await promises.stat(p);
894
- return true;
895
- } catch {
896
- return false;
897
- }
898
- }
899
- function logRestoreFailure(dataDir, sql, err, logger) {
900
- const errObj = err;
901
- const position = typeof errObj.position === "string" ? parseInt(errObj.position, 10) : typeof errObj.position === "number" ? errObj.position : NaN;
902
- logger.error(`[pglite-migration] Restore failed for ${dataDir}: code=${errObj.code ?? ""} severity=${errObj.severity ?? ""} message=${errObj.message ?? ""} sqlLength=${sql.length}`);
903
- if (Number.isFinite(position) && position > 0) {
904
- const zeroBased = position - 1;
905
- const start = Math.max(0, zeroBased - 200);
906
- const end = Math.min(sql.length, zeroBased + 200);
907
- const before = sql.slice(start, zeroBased);
908
- const at = sql.slice(zeroBased, zeroBased + 1);
909
- const after = sql.slice(zeroBased + 1, end);
910
- logger.error(`[pglite-migration] SQL context around position ${position}:\n${before}»${at}«${after}`);
911
- } else logger.error(`[pglite-migration] No position info. First 2000 chars of dump:\n${sql.slice(0, 2e3)}`);
912
- }
913
- /**
914
- * Migrate a filesystem PGLite data directory from a legacy PG major to the
915
- * current one. Renames the existing dir to a timestamped backup, dumps via the
916
- * matching legacy `pg_dump`, restores into a fresh current-version PGLite at
917
- * the original path. On failure, the original dir is restored from the backup.
918
- *
919
- * No-op when the dir is missing or already at the current major.
920
- */
921
- async function migratePgliteDir(dataDir, logger) {
922
- const major = await readPgVersionFile(dataDir);
923
- if (major === null) {
924
- logger.info(`[pglite-migration] No PG_VERSION at ${dataDir}; skipping migration`);
925
- return;
926
- }
927
- if (major === 17) return;
928
- if (!isSupportedMajor(major)) throw new Error(`Unsupported legacy PGlite data dir: PG_VERSION=${major} for ${dataDir}`);
929
- const backupDir = backupPath(dataDir, major);
930
- logger.info(`[pglite-migration] Migrating ${dataDir} from PG${major} to PG17; backup: ${backupDir}`);
931
- await promises.rename(dataDir, backupDir);
932
- let sql;
933
- try {
934
- const [legacyMod, pgDump] = await Promise.all([loadPGliteModule(major), loadPgDump(major)]);
935
- const LegacyPGlite = legacyMod.PGlite;
936
- const pg = new LegacyPGlite(backupDir);
937
- try {
938
- await pg.waitReady;
939
- sql = await (await pgDump({ pg })).text();
940
- } finally {
941
- await pg.close();
942
- }
943
- } catch (err) {
944
- await rollback(dataDir, backupDir, err, logger);
945
- throw err;
946
- }
947
- try {
948
- const CurrentPGlite = (await loadPGliteModule(17)).PGlite;
949
- const pg = new CurrentPGlite(dataDir, { relaxedDurability: false });
950
- try {
951
- await pg.waitReady;
952
- try {
953
- await pg.exec("SET standard_conforming_strings = off;");
954
- } catch (gucErr) {
955
- logger.warn(`[pglite-migration] Could not force standard_conforming_strings=off: ${String(gucErr)}`);
956
- }
957
- try {
958
- await pg.exec(sql);
959
- } catch (execErr) {
960
- logRestoreFailure(dataDir, sql, execErr, logger);
961
- throw execErr;
962
- }
963
- } finally {
964
- await pg.close();
965
- }
966
- } catch (err) {
967
- await rollback(dataDir, backupDir, err, logger);
968
- throw err;
969
- }
970
- logger.info(`[pglite-migration] Migration of ${dataDir} complete. Backup retained at ${backupDir}; remove it manually once you have verified the upgrade.`);
971
- }
972
- async function rollback(dataDir, backupDir, originalError, logger) {
973
- try {
974
- if (await pathExists(dataDir)) await promises.rm(dataDir, {
975
- recursive: true,
976
- force: true
977
- });
978
- if (await pathExists(backupDir)) await promises.rename(backupDir, dataDir);
979
- } catch (rollbackErr) {
980
- logger.error(`[pglite-migration] Migration AND rollback failed for ${dataDir}. Original error: ${String(originalError)}; rollback error: ${String(rollbackErr)}; backup may still exist at ${backupDir}.`);
981
- return;
982
- }
983
- logger.error(`[pglite-migration] Migration failed for ${dataDir}; rolled back from ${backupDir}. Original error: ${String(originalError)}`);
984
- }
985
- //#endregion
986
- //#region src/reactor-feature-flags.mts
987
- /**
988
- * Enforcement flags from the REACTOR_* env vars. Each flag requires the ones
989
- * before it; the reactor rejects an inconsistent set rather than enforcing less
990
- * than the operator asked for, so this reports what was asked without
991
- * correcting it.
992
- */
993
- function resolveReactorFeatureFlags(env) {
994
- const flags = {
995
- documentDecisions: env.REACTOR_DOCUMENT_DECISIONS === "true",
996
- authEnforcement: env.REACTOR_AUTH_ENFORCEMENT === "true",
997
- authGroups: env.REACTOR_AUTH_GROUPS === "true",
998
- authConditions: env.REACTOR_AUTH_CONDITIONS === "true"
999
- };
1000
- return {
1001
- flags,
1002
- enabled: Object.entries(flags).filter(([, isEnabled]) => isEnabled).map(([name]) => name)
1003
- };
1004
- }
1005
- //#endregion
1006
- //#region src/renown.ts
1007
- const logger = childLogger(["switchboard", "renown"]);
1008
- /**
1009
- * Initialize Renown for the Switchboard instance.
1010
- * This allows Switchboard to authenticate with remote services
1011
- * using the same identity established during `ph login`.
1012
- */
1013
- async function initRenown(options = {}) {
1014
- const { keypairPath, requireExisting = false, baseUrl = DEFAULT_RENOWN_URL } = options;
1015
- const keyStorage = new NodeKeyStorage(keypairPath, { logger });
1016
- const existingKeyPair = await keyStorage.loadKeyPair();
1017
- if (!existingKeyPair && requireExisting) throw new Error("No existing keypair found and requireExisting is true. Run \"ph login\" to create one.");
1018
- if (!existingKeyPair) logger.info("No existing keypair found. A new one will be generated.");
1019
- const renownCrypto = await new RenownCryptoBuilder().withKeyPairStorage(keyStorage).build();
1020
- const renown = await new RenownBuilder("switchboard", {}).withCrypto(renownCrypto).withBaseUrl(baseUrl).build();
1021
- logger.info("Switchboard identity initialized: @did", renownCrypto.did);
1022
- return renown;
1023
- }
1024
- /**
1025
- * Get the signer config for the given renown instance.
1026
- *
1027
- * @param renown - The renown instance
1028
- * @param requireSignature - If true, unsigned actions are rejected
1029
- */
1030
- function getRenownSignerConfig(renown, requireSignature) {
1031
- return {
1032
- signer: renown.signer,
1033
- verifier: createSignatureVerifier(requireSignature)
1034
- };
1035
- }
1036
- //#endregion
1037
- //#region src/server.mts
1038
- const defaultLogger = childLogger(["switchboard"]);
1039
- const LogLevel = process.env.LOG_LEVEL || "info";
1040
- setLogLevel(LogLevel);
1041
- dotenv.config();
1042
- const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
1043
- const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
1044
- const REQUIRE_SIGNATURES = "REQUIRE_SIGNATURES";
1045
- const REQUIRE_SIGNATURES_DEFAULT = false;
1046
- const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
1047
- const PORT_FALLBACK_ATTEMPTS = 20;
1048
- const PGLITE_FLUSH_INTERVAL_MS = (() => {
1049
- const raw = process.env.PGLITE_FLUSH_INTERVAL_MS;
1050
- if (raw === void 0) return 100;
1051
- const parsed = Number(raw);
1052
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : 100;
1053
- })();
1054
- const PGLITE_IN_MEMORY = process.env.PH_PGLITE_IN_MEMORY === "1";
1055
- /**
1056
- * Attempt to bind a throwaway TCP server to the given port. Resolves true if
1057
- * the port is free, false if the OS reports it in use. Any other error is
1058
- * surfaced so we don't silently mask real issues (permissions, bad host, …).
1059
- */
1060
- function isPortAvailable(port) {
1061
- return new Promise((resolve, reject) => {
1062
- const tester = net.createServer();
1063
- tester.once("error", (err) => {
1064
- if (err.code === "EADDRINUSE" || err.code === "EACCES") resolve(false);
1065
- else reject(err);
1066
- });
1067
- tester.once("listening", () => {
1068
- tester.close(() => resolve(true));
1069
- });
1070
- tester.listen({
1071
- port,
1072
- host: "::"
1073
- });
1074
- });
1075
- }
1076
- /** The powerhouse.config.json this run reads, defaulting to the cwd copy. */
1077
- function resolveConfigPath(configFile) {
1078
- return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
1079
- }
1080
- async function resolveServerPort(requested, strictPort, logger) {
1081
- if (strictPort) return requested;
1082
- for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
1083
- const candidate = requested + i;
1084
- if (await isPortAvailable(candidate)) {
1085
- if (candidate !== requested) logger.info(`Port ${requested} is in use. Falling back to port ${candidate}.`);
1086
- return candidate;
1087
- }
1088
- }
1089
- return requested;
1090
- }
1091
- async function createReactorKysely(opts) {
1092
- const { reactorDbUrl, reactorPgliteDir, reactorPgliteMajor, inMemory, flushIntervalMs, hostPoolSize, logger } = opts;
1093
- if (reactorDbUrl && isPostgresUrl(reactorDbUrl)) {
1094
- const connectionString = reactorDbUrl.includes("?") ? reactorDbUrl : `${reactorDbUrl}?sslmode=disable`;
1095
- const poolSize = hostPoolSize();
1096
- const pool = new Pool({
1097
- connectionString,
1098
- max: poolSize
1099
- });
1100
- const poolInstrumentation = instrumentPgPool(pool, "reactor-host");
1101
- logger.info(`Using PostgreSQL for reactor storage (host pool max ${poolSize})`);
1102
- return {
1103
- kysely: new Kysely({ dialect: new PostgresDialect({ pool }) }),
1104
- poolInstrumentation
1105
- };
1106
- }
1107
- if (!reactorPgliteDir || reactorPgliteMajor === null) throw new Error("Reactor PGLite directory not resolved");
1108
- const { PGlite } = await loadPGliteModule(reactorPgliteMajor);
1109
- const pglite = inMemory ? new PGlite() : new PGlite({ fs: new AtomicNodeFs(reactorPgliteDir, {
1110
- logger,
1111
- flushIntervalMs
1112
- }) });
1113
- logger.info(inMemory ? `Using in-memory PGlite (PG${reactorPgliteMajor}) for reactor storage [PH_PGLITE_IN_MEMORY=1]` : `Using PGlite (PG${reactorPgliteMajor}) for reactor storage at ${reactorPgliteDir}`);
1114
- return {
1115
- kysely: new Kysely({ dialect: new ClosablePGliteDialect(pglite) }),
1116
- poolInstrumentation: void 0
1117
- };
1118
- }
1119
- /** Derive the remote attachment service config for switchboard's own `/attachments/*` API. */
1120
- function deriveAttachmentServiceConfig(options, serverPort, renown) {
1121
- const protocol = options.https ? "https" : "http";
1122
- return {
1123
- remoteUrl: options.attachmentServiceUrl ?? process.env.PH_SWITCHBOARD_PUBLIC_URL ?? `${protocol}://localhost:${serverPort}`,
1124
- jwtHandler: renown ? async (url) => renown.user ? renown.getBearerToken({
1125
- expiresIn: 10,
1126
- aud: url
1127
- }) : void 0 : void 0
1128
- };
1129
- }
1130
- async function initServer(serverPort, options, renown, renownConfig) {
1131
- const { dev, packages = [], remoteDrives = [], logger = defaultLogger } = options;
1132
- logger.level = LogLevel;
1133
- const dbPath = options.dbPath ?? process.env.DATABASE_URL ?? process.env.PH_SWITCHBOARD_DATABASE_URL;
1134
- const readModelPath = dbPath || ".ph/read-storage";
1135
- const reactorDbUrl = options.dbPath ?? process.env.PH_REACTOR_DATABASE_URL ?? process.env.PH_SWITCHBOARD_DATABASE_URL;
1136
- const reactorPath = reactorDbUrl || "./.ph/reactor-storage";
1137
- const reactorPgliteDir = options.reactor ? null : !reactorDbUrl || !isPostgresUrl(reactorDbUrl) ? reactorPath : null;
1138
- const readModelPgliteDir = !dbPath || !isPostgresUrl(dbPath) ? readModelPath : null;
1139
- const pgliteDirs = [reactorPgliteDir, readModelPgliteDir].filter((d) => d !== null);
1140
- const detectedMajors = /* @__PURE__ */ new Map();
1141
- for (const dir of pgliteDirs) {
1142
- const lockPath = path$1.join(dir, "postmaster.pid");
1143
- try {
1144
- await promises.unlink(lockPath);
1145
- logger.warn(`Removed stale PGLite lockfile ${lockPath}`);
1146
- } catch (err) {
1147
- if (err.code !== "ENOENT") throw err;
1148
- }
1149
- }
1150
- if (options.forcePgVersion !== void 0 && pgliteDirs.length > 0) {
1151
- if (options.migratePglite) logger.warn("PH_FORCE_PG_VERSION is set; ignoring --migrate-pglite/PH_MIGRATE_PGLITE because the data dirs will be wiped.");
1152
- logger.warn(`PH_FORCE_PG_VERSION=${options.forcePgVersion} set; wiping PGLite data dirs and re-initializing at PG${options.forcePgVersion}.`);
1153
- for (const dir of pgliteDirs) {
1154
- await promises.rm(dir, {
1155
- recursive: true,
1156
- force: true
1157
- });
1158
- logger.info(`Wiped PGLite data dir ${dir}`);
1159
- }
1160
- } else if (options.forcePgVersion === void 0) {
1161
- for (const dir of pgliteDirs) {
1162
- const major = await readPgVersionFile(dir);
1163
- if (major !== null) detectedMajors.set(dir, major);
1164
- }
1165
- if (options.migratePglite) for (const [dir, major] of detectedMajors) {
1166
- if (major === 17) continue;
1167
- await migratePgliteDir(dir, logger);
1168
- const after = await readPgVersionFile(dir);
1169
- if (after !== null) detectedMajors.set(dir, after);
1170
- }
1171
- else for (const [dir, major] of detectedMajors) {
1172
- if (major === 17) continue;
1173
- logger.warn(`PGLite data dir at ${dir} was created with PG${major} but Switchboard ships PG17. Running on legacy PGLite. Re-start with --migrate-pglite (or PH_MIGRATE_PGLITE=true) to upgrade.`);
1174
- }
1175
- }
1176
- function resolvePgliteMajorForDir(dir) {
1177
- if (options.forcePgVersion !== void 0) return options.forcePgVersion;
1178
- const detected = detectedMajors.get(dir);
1179
- if (detected === void 0) return 17;
1180
- if (!isSupportedMajor(detected)) throw new Error(`Unsupported PGLite data dir at ${dir}: PG_VERSION=${detected}`);
1181
- return detected;
1182
- }
1183
- const reactorPgliteMajor = reactorPgliteDir ? resolvePgliteMajorForDir(reactorPgliteDir) : null;
1184
- const readModelPgliteMajor = readModelPgliteDir ? resolvePgliteMajorForDir(readModelPgliteDir) : null;
1185
- let workerPool = resolveWorkerPoolOptions(options.workerPool, process.env);
1186
- if (workerPool && options.reactor) {
1187
- logger.warn("Worker pool configuration ignored: the caller-provided reactor owns its own executor");
1188
- workerPool = null;
1189
- }
1190
- if (workerPool) {
1191
- if (dev) throw new Error("The executor worker pool (REACTOR_WORKERS) is not supported in dev mode: Vite-loaded document models cannot cross a worker-thread boundary");
1192
- if (!reactorDbUrl || !isPostgresUrl(reactorDbUrl)) throw new Error("The executor worker pool (REACTOR_WORKERS) requires a Postgres reactor database — set PH_REACTOR_DATABASE_URL or PH_SWITCHBOARD_DATABASE_URL. PGlite cannot be shared across worker threads.");
1193
- }
1194
- let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
1195
- if (projectionWorker && options.reactor) {
1196
- logger.warn("Projection worker configuration ignored: the caller-provided reactor owns its own read-model coordinator");
1197
- projectionWorker = null;
1198
- }
1199
- if (projectionWorker) assertProjectionWorkerSupported({
1200
- dev: dev === true,
1201
- reactorDbUrl
1202
- });
1203
- const apiRef = { current: void 0 };
1204
- let driveNodeView;
1205
- const configPath = resolveConfigPath(options.configFile);
1206
- const config = getConfig(configPath);
1207
- const registryUrl = options.registryUrl ?? process.env.PH_REGISTRY_URL ?? config.packageRegistryUrl;
1208
- const registryPackages = process.env.PH_REGISTRY_PACKAGES;
1209
- const dynamicModelLoading = options.dynamicModelLoading ?? process.env.DYNAMIC_MODEL_LOADING === "true";
1210
- let httpLoader;
1211
- if (registryUrl) {
1212
- register(httpsHooksPath, import.meta.url);
1213
- httpLoader = new HttpPackageLoader({ registryUrl });
1214
- registryPackages?.split(",").forEach((p) => {
1215
- const name = p.trim();
1216
- if (!packages.includes(name)) packages.push(name);
1217
- });
1218
- }
1219
- const reactorLogger = logger.child(["reactor"]);
1220
- let projectionWorkerFatalFired = false;
1221
- const onProjectionWorkerFatal = (shardId, reason) => {
1222
- if (projectionWorkerFatalFired) return;
1223
- projectionWorkerFatalFired = true;
1224
- reactorLogger.error(`Projection worker ${shardId} died; shutting down so the supervisor restarts a healthy process`, reason);
1225
- process.kill(process.pid, "SIGTERM");
1226
- };
1227
- let ownedReactorModule;
1228
- const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
1229
- if (options.reactor) {
1230
- const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
1231
- if (options.reactor.reactorModule) {
1232
- new ReactorInstrumentation(options.reactor.reactorModule).start();
1233
- reactorLogger.info("Reactor metrics instrumentation started (using caller-provided reactor)");
1234
- }
1235
- return {
1236
- module: options.reactor,
1237
- attachmentReferenceProjection
1238
- };
1239
- }
1240
- const { kysely: baseKysely, poolInstrumentation } = await createReactorKysely({
1241
- reactorDbUrl,
1242
- reactorPgliteDir,
1243
- reactorPgliteMajor,
1244
- inMemory: PGLITE_IN_MEMORY,
1245
- flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS,
1246
- hostPoolSize: () => resolveHostPoolSize(process.env),
1247
- logger
1248
- });
1249
- const maxSkipThreshold = parseInt(process.env.MAX_SKIP_THRESHOLD ?? "", 10);
1250
- const hasSkipThreshold = !isNaN(maxSkipThreshold) && maxSkipThreshold > 0;
1251
- if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
1252
- const { flags: reactorFeatureFlags, enabled: enabledFeatureFlags } = resolveReactorFeatureFlags(process.env);
1253
- if (enabledFeatureFlags.length > 0) logger.info(`Reactor feature flags enabled: ${enabledFeatureFlags.join(", ")}`);
1254
- const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" });
1255
- if (poolInstrumentation) reactorBuilder.withInstrumentedPool(poolInstrumentation);
1256
- const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
1257
- const vetraDocumentModels = dev ? Object.values(await import("@powerhousedao/vetra/document-models")).filter((m) => typeof m === "object" && m !== null && "documentModel" in m && "reducer" in m) : [];
1258
- applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, {
1259
- documentModels: [...documentModels, ...vetraDocumentModels],
1260
- upgradeManifests,
1261
- executorConfig: hasSkipThreshold || enabledFeatureFlags.length > 0 ? {
1262
- ...hasSkipThreshold ? { maxSkipThreshold } : {},
1263
- ...enabledFeatureFlags.length > 0 ? { featureFlags: reactorFeatureFlags } : {}
1264
- } : void 0,
1265
- documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
1266
- logger: reactorLogger,
1267
- signer: renown ? getRenownSignerConfig(renown, options.identity?.requireSignatures) : void 0
1268
- });
1269
- if (workerPool) {
1270
- if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
1271
- const workerSources = await resolveWorkerModelSources(packages, reactorLogger);
1272
- reactorBuilder.withDocumentModelSources(workerSources).withWorkerPool({
1273
- numWorkers: workerPool.numWorkers,
1274
- db: buildWorkerDbConfig(reactorDbUrl, workerPool)
1275
- });
1276
- reactorLogger.info(`Executor worker pool enabled: ${workerPool.numWorkers} worker threads${workerPool.mode === "auto" ? " (auto-sized from cores)" : ""}`);
1277
- }
1278
- reactorBuilder.withReadModelFactory(async ({ operationIndex, writeCache, processorManagerConsistencyTracker }) => {
1279
- const nodeProcessor = new NodeProcessor(baseKysely, REACTOR_SCHEMA, operationIndex, writeCache, processorManagerConsistencyTracker);
1280
- await nodeProcessor.init();
1281
- return nodeProcessor;
1282
- });
1283
- registerAttachmentReferenceReadModel(reactorBuilder, {
1284
- baseKysely,
1285
- attachmentReferenceWriter
1286
- });
1287
- if (projectionWorker) {
1288
- if (!reactorDbUrl) throw new Error("unreachable: projection worker enabled without a reactor database URL");
1289
- if (!workerPool) {
1290
- const workerSources = await resolveWorkerModelSources(packages, reactorLogger);
1291
- reactorBuilder.withDocumentModelSources(workerSources);
1292
- }
1293
- const db = {
1294
- ...buildWorkerDbConfig(reactorDbUrl, {
1295
- dbPoolSizePerWorker: projectionWorker.dbPoolSize,
1296
- acquireTimeoutMs: workerPool?.acquireTimeoutMs ?? 5e3
1297
- }),
1298
- applicationName: "switchboard-projection"
1299
- };
1300
- reactorBuilder.withReadModelCoordinatorFactory(createHybridProjectionCoordinatorFactory({
1301
- shardCount: 1,
1302
- poolSize: projectionWorker.dbPoolSize,
1303
- db,
1304
- onFatal: onProjectionWorkerFatal
1305
- }));
1306
- reactorLogger.info(`Projection worker enabled: 1 worker thread, pool size ${projectionWorker.dbPoolSize}`);
1307
- }
1308
- reactorBuilder.withShutdownHook(async () => {
1309
- if (apiRef.current) await apiRef.current.dispose();
1310
- });
1311
- const module = await clientBuilder.buildModule();
1312
- if (module.reactorModule) {
1313
- new ReactorInstrumentation(module.reactorModule).start();
1314
- reactorLogger.info("Reactor metrics instrumentation started");
1315
- }
1316
- driveNodeView = new DriveNodeView(baseKysely.withSchema(REACTOR_SCHEMA));
1317
- const reactorDriveClient = new ReactorDriveClient({
1318
- reactor: module.client,
1319
- readModel: driveNodeView
1320
- });
1321
- ownedReactorModule = module;
1322
- return {
1323
- module,
1324
- reactorDriveClient,
1325
- attachmentReferenceProjection: { status: "available" }
1326
- };
1327
- };
1328
- const abortBoot = async (api) => {
1329
- const owned = ownedReactorModule;
1330
- if (owned) try {
1331
- await owned.reactor.kill().completed;
1332
- } catch (error) {
1333
- logger.error("Aborting boot: reactor shutdown failed: @error", error);
1334
- }
1335
- try {
1336
- await api.dispose();
1337
- } catch (error) {
1338
- logger.error("Aborting boot: api dispose failed: @error", error);
1339
- }
1340
- if (owned?.reactorModule) try {
1341
- await owned.reactorModule.database.destroy();
1342
- } catch (error) {
1343
- logger.error("Aborting boot: database destroy failed: @error", error);
1344
- }
1345
- };
1346
- let localCredentialCheck;
1347
- const verifyCredential = renownConfig.source === "self" ? (params) => localCredentialCheck ? localCredentialCheck(params) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet")) : void 0;
1348
- let defaultDriveUrl = void 0;
1349
- const basePath = process.cwd();
1350
- let vite;
1351
- let viteLoader;
1352
- if (dev) {
1353
- const { VitePackageLoader, createViteLogger, startViteServer } = await import("@powerhousedao/reactor-api/vite");
1354
- vite = await startViteServer(process.cwd(), createViteLogger(logger));
1355
- viteLoader = VitePackageLoader.build(vite);
1356
- }
1357
- const vetraProcessorFactory = dev ? (await import("@powerhousedao/vetra/processors")).processorFactory : void 0;
1358
- if (!options.disableLocalPackages) packages.push(basePath);
1359
- const packageLoaders = [];
1360
- if (viteLoader) packageLoaders.push(viteLoader);
1361
- else packageLoaders.push(new ImportPackageLoader());
1362
- if (httpLoader) {
1363
- packageLoaders.push(httpLoader);
1364
- registryPackages?.split(",").forEach((p) => {
1365
- const name = p.trim();
1366
- if (!packages.includes(name)) packages.push(name);
1367
- });
1368
- }
1369
- const apiLogger = logger.child(["reactor-api"]);
1370
- let pgliteFactory;
1371
- if (readModelPgliteDir && readModelPgliteMajor !== null) {
1372
- const { PGlite: ReadModelPGlite } = await loadPGliteModule(readModelPgliteMajor);
1373
- pgliteFactory = PGLITE_IN_MEMORY ? () => new ReadModelPGlite({ parsers: PGLITE_UTC_PARSERS }) : (connectionString) => new ReadModelPGlite({
1374
- fs: new AtomicNodeFs(connectionString ?? readModelPgliteDir, {
1375
- logger,
1376
- flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS
1377
- }),
1378
- parsers: PGLITE_UTC_PARSERS
1379
- });
1380
- }
1381
- const api = await initializeAndStartAPI(initializeClient, {
1382
- port: serverPort,
1383
- dbPath: readModelPath,
1384
- pgliteFactory,
1385
- https: options.https,
1386
- packageLoaders: packageLoaders.length > 0 ? packageLoaders : void 0,
1387
- packages,
1388
- processorConfig: options.processorConfig,
1389
- processors: vetraProcessorFactory ? { "@powerhousedao/vetra": [vetraProcessorFactory] } : {},
1390
- configFile: configPath,
1391
- mcp: options.mcp ?? true,
1392
- logger: apiLogger,
1393
- enableDocumentModelSubgraphs: options.enableDocumentModelSubgraphs,
1394
- renown: renownConfig,
1395
- verifyCredential
1396
- }, "switchboard");
1397
- apiRef.current = api;
1398
- if (renownConfig.source === "self") {
1399
- const { graphqlManager: manager } = api;
1400
- if (!manager.hasSubgraphHandler(RENOWN_READ_MODEL_SUBGRAPH)) {
1401
- await abortBoot(api);
1402
- throw new Error(`Renown credential verification is set to "self" (auth.renown.source or RENOWN_SOURCE) but no loaded package serves the "${RENOWN_READ_MODEL_SUBGRAPH}" subgraph. Install one (@powerhousedao/renown-package) or set RENOWN_SOURCE=remote.`);
1403
- }
1404
- localCredentialCheck = createLocalCredentialVerifier((query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables), { onError: (error) => logger.error("Renown read model query failed: @error", error) });
1405
- logger.info("Renown credentials will be verified against this switchboard's own renown read model");
1406
- }
1407
- registerAttachmentRoutes(api);
1408
- const attachmentService = createRemoteAttachmentService(deriveAttachmentServiceConfig(options, serverPort, renown));
1409
- if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
1410
- const { client, graphqlManager, documentModelRegistry } = api;
1411
- const lateSubgraphs = [];
1412
- if (httpLoader) {
1413
- const packageManagementService = new PackageManagementService({
1414
- defaultRegistryUrl: registryUrl,
1415
- httpLoader,
1416
- documentModelRegistry,
1417
- packageManager: api.packageManager
1418
- });
1419
- packageManagementService.setOnModelsChanged(() => {
1420
- graphqlManager.regenerateDocumentModelSubgraphs().catch((error) => {
1421
- logger.error("Failed to regenerate document model subgraphs: @error", error);
1422
- });
1423
- });
1424
- const packagesSubgraph = new PackagesSubgraph({
1425
- relationalDb: void 0,
1426
- analyticsStore: void 0,
1427
- reactorClient: client,
1428
- graphqlManager,
1429
- syncManager: api.syncManager,
1430
- path: graphqlManager.getBasePath(),
1431
- authorizationService: graphqlManager.getAuthorizationService(),
1432
- packageManagementService,
1433
- http: graphqlManager.scopeForPackage("@powerhousedao/switchboard")
1434
- });
1435
- lateSubgraphs.push(graphqlManager.registerSubgraphInstance(packagesSubgraph, "graphql", false).catch((error) => {
1436
- logger.error("Failed to register packages subgraph: @error", error);
1437
- }));
1438
- }
1439
- if (driveNodeView) {
1440
- graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
1441
- const reactorDriveSubgraph = {
1442
- name: "reactor-drive",
1443
- path: graphqlManager.getBasePath(),
1444
- resolvers: createReactorDriveResolvers(),
1445
- typeDefs: reactorDriveSubgraphTypeDefs,
1446
- reactorClient: client,
1447
- relationalDb: void 0
1448
- };
1449
- lateSubgraphs.push(graphqlManager.registerSubgraphInstance(reactorDriveSubgraph, "graphql", false).catch((error) => {
1450
- logger.error("Failed to register reactor-drive subgraph: @error", error);
1451
- }));
1452
- }
1453
- (async () => {
1454
- await Promise.all(lateSubgraphs);
1455
- try {
1456
- await graphqlManager.updateRouter(true);
1457
- } catch (error) {
1458
- logger.error("Final router update before readiness failed: @error", error);
1459
- }
1460
- api.readiness.markReady();
1461
- })();
1462
- if (options.drive) {
1463
- if (!renown) throw new Error("Cannot create default drive without Renown identity");
1464
- if ((options.drive.documentType ?? "powerhouse/document-drive") === "powerhouse/reactor-drive") defaultDriveUrl = await addDefaultReactorDrive(client, options.drive, serverPort);
1465
- else defaultDriveUrl = await addDefaultDrive(client, options.drive, serverPort);
1466
- }
1467
- if (vite) api.httpAdapter.mountRawMiddleware(vite.middlewares);
1468
- if (remoteDrives.length > 0) for (const remoteDriveUrl of remoteDrives) {
1469
- let driveId;
1470
- try {
1471
- const { syncManager } = api;
1472
- const parsed = parseDriveUrl(remoteDriveUrl);
1473
- driveId = parsed.driveId;
1474
- const remoteName = `remote-drive-${driveId}-${crypto.randomUUID()}`;
1475
- await syncManager.add(remoteName, DriveCollectionId.forDrive(driveId), {
1476
- type: "gql",
1477
- parameters: { url: parsed.graphqlEndpoint }
1478
- });
1479
- logger.debug("Remote drive @remoteDriveUrl synced", remoteDriveUrl);
1480
- } catch (error) {
1481
- if (error instanceof Error && error.message.includes("already exists")) {
1482
- logger.debug("Remote drive already added: @remoteDriveUrl", remoteDriveUrl);
1483
- driveId = remoteDriveUrl.split("/").pop();
1484
- } else logger.error("Failed to connect to remote drive @remoteDriveUrl: @error", remoteDriveUrl, error);
1485
- } finally {
1486
- if (!defaultDriveUrl && driveId) defaultDriveUrl = `${options.https ? "https" : "http"}://localhost:${serverPort}/d/${driveId}`;
1487
- }
1488
- }
1489
- return {
1490
- defaultDriveUrl,
1491
- api,
1492
- reactor: client,
1493
- attachmentService,
1494
- attachmentReferenceProjection: api.attachmentReferenceProjection,
1495
- renown,
1496
- port: serverPort,
1497
- shutdown: () => api.dispose()
1498
- };
1499
- }
1500
- /**
1501
- * Boot the switchboard HTTP/GraphQL/MCP stack on top of a reactor.
1502
- *
1503
- * If `options.reactor` is provided, the switchboard reuses it instead of
1504
- * building its own — the caller then owns the reactor's lifecycle and is
1505
- * responsible for invoking `SwitchboardReactor.shutdown()` from their own
1506
- * teardown / SIGINT path. The switchboard will not reach into the caller's
1507
- * reactor; killing the reactor alone leaves the api/GraphQL/MCP resources
1508
- * dangling until the process exits.
1509
- *
1510
- * When `options.reactor` is omitted, the switchboard builds and owns the
1511
- * reactor. `shutdown()` on the returned handle only drains the api (HTTP
1512
- * server, GraphQL, MCP, attachments); the reactor itself is torn down by
1513
- * its own signal handlers (`withSignalHandlers`), which call `kill()` and
1514
- * trigger the `withShutdownHook` chain that disposes the api. Programmatic
1515
- * full teardown isn't currently exposed — wire it via SIGINT/SIGTERM.
1516
- */
1517
- const startSwitchboard = async (options = {}) => {
1518
- const requestedPort = options.port ?? DEFAULT_PORT;
1519
- const logger = options.logger ?? defaultLogger;
1520
- const serverPort = await resolveServerPort(requestedPort, options.strictPort ?? false, logger);
1521
- const featureFlags = await initFeatureFlags();
1522
- const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
1523
- options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
1524
- const requireSignatures = options.identity?.requireSignatures ?? await featureFlags.getBooleanValue(REQUIRE_SIGNATURES, REQUIRE_SIGNATURES_DEFAULT);
1525
- const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
1526
- options.identity = {
1527
- ...options.identity,
1528
- requireSignatures,
1529
- baseUrl: options.identity?.baseUrl ?? renownConfig.url
1530
- };
1531
- logger.info("Feature flags: @flags", JSON.stringify({
1532
- DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
1533
- REQUIRE_SIGNATURES: requireSignatures
1534
- }, null, 2));
1535
- let renown = null;
1536
- try {
1537
- renown = await initRenown(options.identity);
1538
- } catch (e) {
1539
- logger.warn("Failed to initialize ConnectCrypto: @error", e);
1540
- if (options.identity.requireExisting) throw new Error("Identity required but failed to initialize. Run \"ph login\" first.", { cause: e });
1541
- }
1542
- try {
1543
- return await initServer(serverPort, options, renown, renownConfig);
1544
- } catch (e) {
1545
- Sentry.captureException(e);
1546
- logger.error("App crashed: @error", e);
1547
- throw e;
1548
- }
1549
- };
1550
- if (import.meta.main) await startSwitchboard();
1551
- //#endregion
1552
- export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
1553
-
1554
- //# sourceMappingURL=server-CiKkVE6g.mjs.map
1555
- //# debugId=e2c62b7b-68fd-59c2-ab82-7d035e11698b