@powerhousedao/switchboard 6.2.3-dev.4 → 6.2.3-dev.40

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 (115) 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/builder-defaults.test.d.ts +2 -0
  24. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  25. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  26. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  27. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  28. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  29. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  30. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  31. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  32. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  33. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  34. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  35. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  36. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  37. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  38. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  39. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  40. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  41. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  42. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  43. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  44. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  45. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  46. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  48. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  49. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  50. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  51. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  52. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  53. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  54. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  55. package/.tsbuild/vitest.config.d.ts.map +1 -1
  56. package/Auth.md +94 -0
  57. package/CHANGELOG.md +400 -0
  58. package/README.md +35 -0
  59. package/dist/index.d.mts +2 -1
  60. package/dist/index.mjs +10 -8
  61. package/dist/index.mjs.map +1 -1
  62. package/dist/install-packages.d.mts +2 -1
  63. package/dist/install-packages.mjs +2 -2
  64. package/dist/install-packages.mjs.map +1 -1
  65. package/dist/migrate.d.mts +2 -1
  66. package/dist/migrate.mjs +4 -3
  67. package/dist/migrate.mjs.map +1 -1
  68. package/dist/{server-CiKkVE6g.mjs → server-D3CYuEka.mjs} +1574 -88
  69. package/dist/server-D3CYuEka.mjs.map +1 -0
  70. package/dist/server.d.mts +120 -29
  71. package/dist/server.d.mts.map +1 -1
  72. package/dist/server.mjs +4 -5
  73. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  74. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  75. package/dist/utils.d.mts +4 -5
  76. package/dist/utils.d.mts.map +1 -1
  77. package/dist/utils.mjs +3 -3
  78. package/package.json +22 -17
  79. package/test/attachment-reference-read-model.test.ts +6 -1
  80. package/test/attachments/auth.test.ts +85 -0
  81. package/test/attachments/byte-grants.test.ts +290 -0
  82. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  83. package/test/attachments/download-target.test.ts +201 -32
  84. package/test/attachments/index.test.ts +34 -3
  85. package/test/attachments/reserve-metadata-parity.test.ts +96 -0
  86. package/test/attachments/routes-integration.test.ts +60 -4
  87. package/test/attachments/routes.test.ts +166 -23
  88. package/test/attachments/service-config.test.ts +13 -3
  89. package/test/attachments/url-signer.test.ts +105 -0
  90. package/test/boot-create-signature-policy.test.ts +108 -0
  91. package/test/boot-mcp-enabled.test.ts +121 -0
  92. package/test/boot-privacy.test.ts +243 -0
  93. package/test/boot-unsupported-stored-documents.test.ts +213 -0
  94. package/test/boot-without-renown.test.ts +81 -0
  95. package/test/boot-without-require-signatures.test.ts +74 -0
  96. package/test/boot-worker-trust-policy.test.ts +90 -0
  97. package/test/builder-defaults.test.ts +80 -0
  98. package/test/create-signature-policy.test.ts +69 -0
  99. package/test/fatal-shutdown.test.ts +132 -0
  100. package/test/mcp-flag.test.ts +56 -0
  101. package/test/renown-trust-policy.test.ts +315 -0
  102. package/test/unsupported-stored-documents.test.ts +72 -0
  103. package/test/workflow/harness.ts +289 -0
  104. package/test/workflow/oauth.test.ts +214 -0
  105. package/test/workflow/resolvers.test.ts +296 -0
  106. package/test/workflow/webhook-dedupe.test.ts +295 -0
  107. package/test/workflow/webhook-signed.test.ts +509 -0
  108. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  109. package/test/workflow/webhook-unsigned.test.ts +345 -0
  110. package/test/workflow-read-gate.test.ts +145 -0
  111. package/test/workflow-runtime.test.ts +499 -0
  112. package/tsconfig.json +9 -0
  113. package/tsdown.config.ts +3 -1
  114. package/dist/server-CiKkVE6g.mjs.map +0 -1
  115. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -1,33 +1,39 @@
1
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";
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]="98e9dab8-b145-5eb1-8457-7a9d38dda98e")}catch(e){}}();
3
+ import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-uaBlzyjS.mjs";
4
+ import { createRequire, register } from "node:module";
5
5
  import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
6
6
  import * as Sentry from "@sentry/node";
7
7
  import { childLogger, documentModelDocumentModelModule, setLogLevel } from "document-model";
8
8
  import dotenv from "dotenv";
9
9
  import { getConfig } from "@powerhousedao/config/node";
10
10
  import { existsSync, promises, realpathSync } from "node:fs";
11
- import path from "node:path";
11
+ import path, { resolve } from "node:path";
12
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";
13
+ import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, UnsupportedStoredProtocolError, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
14
+ import { AuthorizationPolicy, BaseSubgraph, ForbiddenError, HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, PrivacyPermissionAdapter, callerSubject, createCanonicalDocumentIdResolver, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
15
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";
16
+ import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentMetadata, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget, parseRef, validateReserveMetadata } from "@powerhousedao/reactor-attachments";
17
+ import { createAttachmentClient } from "@powerhousedao/reactor-attachments/client";
17
18
  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 { DEFAULT_KEYPAIR_PATH, DEFAULT_RENOWN_URL, NodeKeyStorage, RENOWN_READ_MODEL_SUBGRAPH, RenownBuilder, RenownCryptoBuilder, createLocalCredentialVerifier, createRenownTrustPolicy, resolveSwitchboardEndpoint } from "@renown/sdk/node";
19
20
  import { Kysely, PostgresDialect } from "kysely";
20
21
  import net from "node:net";
21
22
  import path$1 from "path";
22
23
  import { Pool } from "pg";
23
24
  import { Readable } from "node:stream";
25
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
26
+ import { DisclosureService, MIN_DEPLOYMENT_SECRET_BYTES, createModuleErasure, createPrivacySubgraph, registerSubjectDocumentsReadModel } from "@powerhousedao/reactor-privacy";
24
27
  import { ReactorGroupV1 } from "@powerhousedao/reactor-group";
25
28
  import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
29
+ import { DEFAULT_SIGNATURE_POLICY, isSignaturePolicy } from "@powerhousedao/shared/document-model";
26
30
  import { readFile } from "node:fs/promises";
27
31
  import os from "node:os";
28
32
  import { fileURLToPath } from "node:url";
29
33
  import { EnvVarProvider } from "@openfeature/env-var-provider";
30
34
  import { OpenFeature } from "@openfeature/server-sdk";
35
+ import { GraphQLError, Kind } from "graphql";
36
+ import { gql } from "graphql-tag";
31
37
  import { PGliteDialect } from "kysely-pglite-dialect";
32
38
  //#region src/pglite-version.ts
33
39
  const SUPPORTED_PG_MAJORS = [16, 17];
@@ -74,7 +80,15 @@ const ANONYMOUS_ACTOR = {
74
80
  * enabled, the request must carry a verifiable Bearer token. The handler always
75
81
  * receives an actor context: the verified bearer user when auth is enabled, or
76
82
  * the anonymous context when it is disabled. With `allowAnonymous`, a missing
77
- * bearer yields an anonymous actor with `authEnabled: true` instead of a 401.
83
+ * bearer yields an anonymous actor with `authEnabled: true` instead of a 401 —
84
+ * unless `requireAuthenticatedCaller` says the deployment refuses anonymous
85
+ * callers outright, which overrides it.
86
+ *
87
+ * With no `authService` there is no bearer to read and every caller is
88
+ * anonymous, so nothing is refused here. That combination cannot carry the
89
+ * floor: `assertRequireAuthenticatedCallerAllowed` refuses to boot with
90
+ * `REQUIRE_AUTHENTICATED_CALLER` set and no identity resolution, and identity
91
+ * resolution is what builds the service.
78
92
  */
79
93
  function requireAuth(authService, handler, options) {
80
94
  if (!authService) return (req, res, body) => handler(req, res, body, ANONYMOUS_ACTOR);
@@ -96,11 +110,13 @@ function requireAuth(authService, handler, options) {
96
110
  res.end(body);
97
111
  return;
98
112
  }
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;
113
+ if (!result.user) {
114
+ if (options?.requireAuthenticatedCaller === true || result.auth_enabled && !options?.allowAnonymous) {
115
+ res.statusCode = 401;
116
+ res.setHeader("Content-Type", "application/json");
117
+ res.end(JSON.stringify({ error: "Authentication required" }));
118
+ return;
119
+ }
104
120
  }
105
121
  await handler(req, res, body, {
106
122
  user: result.user,
@@ -118,20 +134,24 @@ function requireAuth(authService, handler, options) {
118
134
  * wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
119
135
  *
120
136
  * `allowAnonymous` is reserved for routes whose handlers make a per-document
121
- * authorization decision themselves; identity-only routes must not use it.
137
+ * authorization decision themselves; identity-only routes must not use it. It
138
+ * is also not the last word: a deployment that refuses anonymous callers
139
+ * (`REQUIRE_AUTHENTICATED_CALLER`) refuses them here too, because these routes
140
+ * are mounted on the HTTP adapter and never reach the fetch chain that carries
141
+ * that floor for GraphQL. The flag is read from the API, so every route
142
+ * mounted through here inherits it without each one remembering to.
122
143
  */
123
144
  function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
124
- api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, options));
145
+ api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, {
146
+ ...options,
147
+ requireAuthenticatedCaller: api.requireAuthenticatedCaller
148
+ }));
125
149
  }
126
150
  //#endregion
127
151
  //#region src/attachments/routes.ts
128
- const logger$1 = childLogger(["switchboard", "attachments"]);
152
+ const logger$2 = childLogger(["switchboard", "attachments"]);
129
153
  const RETRY_AFTER_SECONDS = 5;
130
154
  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
155
  function sendJson(res, status, body) {
136
156
  res.statusCode = status;
137
157
  res.setHeader("Content-Type", "application/json");
@@ -150,7 +170,7 @@ function statusForError(err) {
150
170
  function sendErrorFromException(res, err) {
151
171
  const status = statusForError(err);
152
172
  if (status >= 500) {
153
- logger$1.error("Attachment route error: @error", err);
173
+ logger$2.error("Attachment route error: @error", err);
154
174
  sendError(res, status, "Internal error");
155
175
  return;
156
176
  }
@@ -168,27 +188,34 @@ async function readJsonBody(req, body) {
168
188
  function parseReserveOptions(input) {
169
189
  if (input === null || typeof input !== "object") return null;
170
190
  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;
191
+ const { mimeType, fileName } = obj;
192
+ const extension = obj.extension ?? null;
193
+ try {
194
+ validateReserveMetadata({
195
+ mimeType,
196
+ fileName,
197
+ extension
198
+ });
199
+ } catch (err) {
200
+ if (err instanceof InvalidAttachmentMetadata) return null;
201
+ throw err;
202
+ }
203
+ if (typeof mimeType !== "string" || typeof fileName !== "string") return null;
204
+ if (extension !== null && typeof extension !== "string") return null;
178
205
  if (obj.clientHash !== void 0) {
179
206
  if (typeof obj.clientHash !== "string" || !HASH_PATTERN.test(obj.clientHash)) return null;
180
207
  if (typeof obj.sizeBytes !== "number" || !Number.isInteger(obj.sizeBytes) || obj.sizeBytes <= 0 || !Number.isSafeInteger(obj.sizeBytes)) return null;
181
208
  return {
182
- mimeType: obj.mimeType,
183
- fileName: obj.fileName,
209
+ mimeType,
210
+ fileName,
184
211
  extension,
185
212
  clientHash: obj.clientHash.toLowerCase(),
186
213
  sizeBytes: obj.sizeBytes
187
214
  };
188
215
  }
189
216
  return {
190
- mimeType: obj.mimeType,
191
- fileName: obj.fileName,
217
+ mimeType,
218
+ fileName,
192
219
  extension
193
220
  };
194
221
  }
@@ -279,24 +306,27 @@ function makeUploadHandler(attachments) {
279
306
  }
280
307
  };
281
308
  }
282
- function makeDownloadHandler(attachments) {
283
- return async (req, res) => {
309
+ function makeDownloadHandler(attachments, attachmentAccess, urlSigner) {
310
+ return async (req, res, _body, actor) => {
284
311
  const hash = extractParam(req, "hash");
285
312
  if (!hash || !HASH_PATTERN.test(hash)) {
286
313
  sendError(res, 400, "Invalid attachment hash");
287
314
  return;
288
315
  }
316
+ const canonicalHash = hash.toLowerCase();
317
+ const grant = await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner);
318
+ if (!admitGrant(res, grant)) return;
289
319
  const controller = new AbortController();
290
320
  req.once("close", () => controller.abort());
291
- const canonicalHash = hash.toLowerCase();
292
321
  let response;
293
322
  try {
294
- response = await attachments.store.get(canonicalHash, controller.signal);
323
+ response = await attachments.store.get(canonicalHash, controller.signal, grant.documentId);
295
324
  } catch (err) {
296
325
  if (err instanceof AttachmentPending) {
297
326
  res.statusCode = 202;
327
+ res.setHeader("Cache-Control", "private");
298
328
  res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
299
- res.setHeader("Attachment-Pending", JSON.stringify({
329
+ res.setHeader("Attachment-Pending", buildPendingHeader({
300
330
  expiresAtUtc: err.expiresAtUtc,
301
331
  ...err.metadata ?? {}
302
332
  }));
@@ -308,6 +338,7 @@ function makeDownloadHandler(attachments) {
308
338
  }
309
339
  const { header, body } = response;
310
340
  res.statusCode = 200;
341
+ res.setHeader("Cache-Control", "private");
311
342
  res.setHeader("Content-Type", header.mimeType);
312
343
  res.setHeader("Content-Length", String(header.sizeBytes));
313
344
  res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
@@ -315,8 +346,14 @@ function makeDownloadHandler(attachments) {
315
346
  Readable.fromWeb(body).pipe(res);
316
347
  };
317
348
  }
349
+ function toAsciiJson(value) {
350
+ return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
351
+ }
352
+ function buildPendingHeader(pending) {
353
+ return toAsciiJson(pending);
354
+ }
318
355
  function buildMetadataHeader(header) {
319
- return JSON.stringify({
356
+ return toAsciiJson({
320
357
  mimeType: header.mimeType,
321
358
  fileName: header.fileName,
322
359
  sizeBytes: header.sizeBytes,
@@ -325,14 +362,15 @@ function buildMetadataHeader(header) {
325
362
  lastAccessedAtUtc: header.lastAccessedAtUtc
326
363
  });
327
364
  }
328
- function makeStatHandler(attachments) {
329
- return async (req, res) => {
365
+ function makeStatHandler(attachments, attachmentAccess, urlSigner) {
366
+ return async (req, res, _body, actor) => {
330
367
  const hash = extractParam(req, "hash");
331
368
  if (!hash || !HASH_PATTERN.test(hash)) {
332
369
  sendError(res, 400, "Invalid attachment hash");
333
370
  return;
334
371
  }
335
372
  const canonicalHash = hash.toLowerCase();
373
+ if (!admitGrant(res, await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner))) return;
336
374
  let header;
337
375
  try {
338
376
  header = await attachments.store.stat(canonicalHash);
@@ -340,10 +378,11 @@ function makeStatHandler(attachments) {
340
378
  sendErrorFromException(res, err);
341
379
  return;
342
380
  }
381
+ res.setHeader("Cache-Control", "private");
343
382
  if (header.status === "pending") {
344
383
  res.statusCode = 202;
345
384
  res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
346
- res.setHeader("Attachment-Pending", JSON.stringify({
385
+ res.setHeader("Attachment-Pending", buildPendingHeader({
347
386
  expiresAtUtc: header.expiresAtUtc,
348
387
  mimeType: header.mimeType,
349
388
  fileName: header.fileName,
@@ -395,7 +434,37 @@ function extractParam(req, name) {
395
434
  }
396
435
  const MAX_DOCUMENT_ID_LEN = 512;
397
436
  const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
398
- const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 10080 * 60;
437
+ const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 604800;
438
+ /**
439
+ * How long a download target may live, by default.
440
+ *
441
+ * The authorization behind a presigned URL is decided once, when it is issued,
442
+ * and the URL keeps working until it expires however the document's policy
443
+ * changes in between. A week of that is a revocation that does not revoke: an
444
+ * account deactivated, a company reassigned or a group left, and the bytes stay
445
+ * fetchable by anyone holding the link. Minutes are enough to open a file;
446
+ * days are only enough to lose control of it.
447
+ *
448
+ * A deployment that needs longer sets ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS,
449
+ * still bounded by the signing ceiling above.
450
+ */
451
+ const DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS = 300;
452
+ /**
453
+ * The ceiling in force, read per call so a deployment can set it without the
454
+ * value being frozen at import — which is also what lets a test cover it.
455
+ * Anything unparseable falls back to the default rather than to the maximum:
456
+ * a typo must not quietly widen the window it was meant to narrow.
457
+ */
458
+ function downloadTargetTtlCapSeconds() {
459
+ const configured = process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
460
+ if (configured === void 0) return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
461
+ const parsed = Number(configured);
462
+ if (!Number.isInteger(parsed) || parsed <= 0) {
463
+ logger$2.warn(`Ignoring invalid ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS="${configured}" (expected a positive integer number of seconds) — using ${DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS}`);
464
+ return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
465
+ }
466
+ return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
467
+ }
399
468
  /**
400
469
  * Returns the single `documentId` query value, or null when it is missing,
401
470
  * duplicated, blank, or oversized. Validation happens before authorization so
@@ -416,29 +485,95 @@ function extractSingleDocumentId(req) {
416
485
  return value;
417
486
  }
418
487
  /**
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.
488
+ * Returns the target TTL in seconds, or "invalid" when the caller's own value
489
+ * is malformed (duplicated, non-integer, or non-positive).
490
+ *
491
+ * The cap applies whether or not the caller asked for a lifetime: omitting
492
+ * `expiresIn` used to fall through to whatever the storage backend was
493
+ * configured with, which would make the ceiling something a client opts out of
494
+ * by saying nothing.
422
495
  */
423
496
  function extractExpiresIn(req) {
424
- if (!req.url) return void 0;
497
+ const cap = downloadTargetTtlCapSeconds();
498
+ if (!req.url) return cap;
425
499
  let url;
426
500
  try {
427
501
  url = new URL(req.url, "http://switchboard.invalid");
428
502
  } catch {
429
- return;
503
+ return cap;
430
504
  }
431
505
  const values = url.searchParams.getAll("expiresIn");
432
- if (values.length === 0) return void 0;
506
+ if (values.length === 0) return cap;
433
507
  if (values.length > 1) return "invalid";
434
508
  const parsed = Number(values[0]);
435
509
  if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
436
- return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
510
+ return Math.min(parsed, cap);
511
+ }
512
+ function singleQueryValue(params, name) {
513
+ const values = params.getAll(name);
514
+ return values.length === 1 ? values[0] : null;
515
+ }
516
+ /**
517
+ * Byte routes serve only under a grant: a signed URL minted by the
518
+ * download-target route, or a documentId the caller may read the attachment
519
+ * through. A signed request is judged by its signature alone.
520
+ */
521
+ async function resolveByteGrant(req, hash, actor, attachmentAccess, urlSigner) {
522
+ let params;
523
+ try {
524
+ params = new URL(req.url ?? "", "http://switchboard.invalid").searchParams;
525
+ } catch {
526
+ return { kind: "denied" };
527
+ }
528
+ const documentId = extractSingleDocumentId(req);
529
+ if (documentId === null) return { kind: "denied" };
530
+ if (params.has("signature") || params.has("expires")) {
531
+ const signature = singleQueryValue(params, "signature");
532
+ const expires = singleQueryValue(params, "expires");
533
+ if (!urlSigner || signature === null || expires === null) return { kind: "denied" };
534
+ return urlSigner.verify(hash, documentId, expires, signature) ? {
535
+ kind: "granted",
536
+ documentId
537
+ } : { kind: "denied" };
538
+ }
539
+ let decision;
540
+ try {
541
+ decision = await attachmentAccess.canReadAttachment({
542
+ documentId,
543
+ attachmentRef: createRef(hash),
544
+ userAddress: actor?.user?.address,
545
+ appKey: actor?.user?.appKey
546
+ });
547
+ } catch (err) {
548
+ logger$2.error("Attachment access decision failed: @error", err);
549
+ return { kind: "error" };
550
+ }
551
+ if (decision.kind === "projection-unavailable") return { kind: "unavailable" };
552
+ if (decision.kind === "denied") return { kind: "denied" };
553
+ return {
554
+ kind: "granted",
555
+ documentId: decision.documentId
556
+ };
557
+ }
558
+ /** Answers a refused grant; true when the request may proceed. */
559
+ function admitGrant(res, grant) {
560
+ switch (grant.kind) {
561
+ case "granted": return true;
562
+ case "denied":
563
+ sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
564
+ return false;
565
+ case "unavailable":
566
+ sendError(res, 503, "Attachment downloads are temporarily unavailable");
567
+ return false;
568
+ case "error":
569
+ sendError(res, 500, "Internal error");
570
+ return false;
571
+ }
437
572
  }
438
573
  /**
439
574
  * 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.
575
+ * filesystem `switchboard` download targets that point back at the signed
576
+ * byte route.
442
577
  */
443
578
  function requestBaseUrl(req) {
444
579
  const forwardedProto = req.headers["x-forwarded-proto"];
@@ -448,7 +583,7 @@ function requestBaseUrl(req) {
448
583
  if (!host) return null;
449
584
  return `${proto}://${host}`;
450
585
  }
451
- function makeDownloadTargetHandler(attachments, attachmentAccess) {
586
+ function makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner) {
452
587
  return async (req, res, _body, actor) => {
453
588
  res.setHeader("Cache-Control", "no-store");
454
589
  const hash = extractParam(req, "hash");
@@ -472,10 +607,11 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
472
607
  decision = await attachmentAccess.canReadAttachment({
473
608
  documentId,
474
609
  attachmentRef: createRef(canonicalHash),
475
- userAddress: actor?.user?.address
610
+ userAddress: actor?.user?.address,
611
+ appKey: actor?.user?.appKey
476
612
  });
477
613
  } catch (err) {
478
- logger$1.error("Attachment access decision failed: @error", err);
614
+ logger$2.error("Attachment access decision failed: @error", err);
479
615
  sendError(res, 500, "Internal error");
480
616
  return;
481
617
  }
@@ -495,7 +631,7 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
495
631
  sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
496
632
  return;
497
633
  }
498
- logger$1.error("Attachment metadata lookup failed: @error", err);
634
+ logger$2.error("Attachment metadata lookup failed: @error", err);
499
635
  sendError(res, 500, "Internal error");
500
636
  return;
501
637
  }
@@ -511,17 +647,23 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
511
647
  return;
512
648
  }
513
649
  else {
650
+ if (!urlSigner) {
651
+ sendError(res, 503, "Attachment download signing is not configured");
652
+ return;
653
+ }
514
654
  const base = requestBaseUrl(req);
515
655
  if (!base) {
516
656
  sendError(res, 500, "Internal error");
517
657
  return;
518
658
  }
659
+ const signed = urlSigner.sign(canonicalHash, decision.documentId, expiresIn);
519
660
  try {
520
661
  target = parseAttachmentDownloadTarget({
521
662
  kind: "switchboard",
522
663
  method: "GET",
523
- url: `${base}/attachments/${canonicalHash}`,
524
- headers: {}
664
+ url: `${base}/attachments/${canonicalHash}?${signed.query}`,
665
+ headers: {},
666
+ expiresAtUtc: signed.expiresAtUtc
525
667
  });
526
668
  } catch {
527
669
  sendError(res, 500, "Internal error");
@@ -532,16 +674,82 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
532
674
  };
533
675
  }
534
676
  //#endregion
677
+ //#region src/attachments/url-signer.ts
678
+ const ATTACHMENT_URL_SIGNING_SECRET_ENV = "PH_ATTACHMENT_URL_SIGNING_SECRET";
679
+ const MIN_SECRET_LENGTH = 32;
680
+ const EXPIRES_PATTERN = /^[1-9]\d{0,15}$/;
681
+ const SIGNATURE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
682
+ var AttachmentUrlSigner = class {
683
+ secret;
684
+ now;
685
+ constructor(secret, now = Date.now) {
686
+ this.secret = secret;
687
+ this.now = now;
688
+ }
689
+ sign(hash, documentId, ttlSeconds) {
690
+ const expires = Math.floor(this.now() / 1e3) + ttlSeconds;
691
+ return {
692
+ query: new URLSearchParams({
693
+ documentId,
694
+ expires: String(expires),
695
+ signature: this.mac(hash, documentId, String(expires))
696
+ }).toString(),
697
+ expiresAtUtc: (/* @__PURE__ */ new Date(expires * 1e3)).toISOString()
698
+ };
699
+ }
700
+ verify(hash, documentId, expires, signature) {
701
+ if (!EXPIRES_PATTERN.test(expires) || !SIGNATURE_PATTERN.test(signature)) return false;
702
+ if (Number(expires) * 1e3 <= this.now()) return false;
703
+ const expected = Buffer.from(this.mac(hash, documentId, expires));
704
+ const presented = Buffer.from(signature);
705
+ return expected.length === presented.length && timingSafeEqual(expected, presented);
706
+ }
707
+ mac(hash, documentId, expires) {
708
+ return createHmac("sha256", this.secret).update(JSON.stringify([
709
+ "attachment-download/v1",
710
+ hash,
711
+ documentId,
712
+ expires
713
+ ])).digest("base64url");
714
+ }
715
+ };
716
+ function resolveAttachmentUrlSigning(env = process.env) {
717
+ const secret = env[ATTACHMENT_URL_SIGNING_SECRET_ENV];
718
+ if (secret !== void 0) {
719
+ if (secret.trim() !== secret || secret.length < MIN_SECRET_LENGTH) throw new Error(`${ATTACHMENT_URL_SIGNING_SECRET_ENV} must be at least ${MIN_SECRET_LENGTH} characters with no leading or trailing whitespace`);
720
+ return {
721
+ status: "configured",
722
+ signer: new AttachmentUrlSigner(secret)
723
+ };
724
+ }
725
+ if (env.NODE_ENV === "production") return { status: "unconfigured" };
726
+ return {
727
+ status: "ephemeral",
728
+ signer: new AttachmentUrlSigner(randomBytes(32))
729
+ };
730
+ }
731
+ //#endregion
535
732
  //#region src/attachments/index.ts
536
- function registerAttachmentRoutes(api) {
537
- const { attachments } = api;
733
+ const logger$1 = childLogger(["switchboard", "attachments"]);
734
+ function registerAttachmentRoutes(api, options = {}) {
735
+ const { attachments, attachmentAccess } = api;
736
+ const urlSigner = options.urlSigner === void 0 ? signerFromEnvironment(attachments.backend?.kind ?? "filesystem") : options.urlSigner;
538
737
  mountAuthenticatedNodeRoute(api, "POST", "/attachments/reservations", makeReserveHandler(attachments));
539
738
  mountAuthenticatedNodeRoute(api, "GET", "/attachments/reservations/:reservationId", makeGetReservationHandler(attachments));
540
739
  mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
541
740
  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));
741
+ mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
742
+ mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
743
+ mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
744
+ }
745
+ function signerFromEnvironment(backend) {
746
+ const signing = resolveAttachmentUrlSigning();
747
+ if (signing.status === "unconfigured") {
748
+ if (backend === "filesystem") logger$1.error(`${ATTACHMENT_URL_SIGNING_SECRET_ENV} is not set: filesystem attachment download targets are refused until it is`);
749
+ return null;
750
+ }
751
+ if (signing.status === "ephemeral" && backend === "filesystem") logger$1.warn(`${ATTACHMENT_URL_SIGNING_SECRET_ENV} is not set: signing attachment download URLs with a per-process secret`);
752
+ return signing.signer;
545
753
  }
546
754
  //#endregion
547
755
  //#region src/attachment-reference-read-model.mts
@@ -584,6 +792,127 @@ async function registerAttachmentReferenceReadModelOnModule(clientModule, attach
584
792
  return { status: "available" };
585
793
  }
586
794
  //#endregion
795
+ //#region src/fatal-shutdown.mts
796
+ const FORCED_EXIT_MS = 15e3;
797
+ const fatalHandlers = /* @__PURE__ */ new WeakMap();
798
+ /**
799
+ * Sends an uncaught exception or unhandled rejection through the SIGTERM
800
+ * shutdown that `withSignalHandlers()` installs, so the reactor and read-model
801
+ * PGlite stores write their snapshots before the process exits with code 1.
802
+ * Node's default is to exit at once, which drops every write AtomicNodeFs has
803
+ * not flushed yet. A shutdown that hangs is cut off after FORCED_EXIT_MS.
804
+ *
805
+ * A rejection is fatal only when this is its sole listener: Node exits on an
806
+ * unhandled rejection only when nothing listens for it, and Sentry's
807
+ * listener deliberately keeps the process running.
808
+ */
809
+ function installFatalErrorShutdown(logger, proc = process) {
810
+ if (fatalHandlers.has(proc)) return;
811
+ const realExit = proc.exit.bind(proc);
812
+ let shuttingDown = false;
813
+ const onFatal = (kind, err) => {
814
+ logger.error(`${kind}: @error`, err);
815
+ if (shuttingDown) return;
816
+ shuttingDown = true;
817
+ proc.exitCode = 1;
818
+ setTimeout(() => {
819
+ logger.error("Shutdown after fatal error timed out; exiting");
820
+ realExit(1);
821
+ }, FORCED_EXIT_MS).unref();
822
+ proc.kill(proc.pid, "SIGTERM");
823
+ };
824
+ fatalHandlers.set(proc, onFatal);
825
+ proc.on("uncaughtException", (err) => onFatal("Uncaught exception", err));
826
+ proc.on("unhandledRejection", (reason) => {
827
+ if (proc.listenerCount("unhandledRejection") > 1) return;
828
+ onFatal("Unhandled rejection", reason);
829
+ });
830
+ }
831
+ /**
832
+ * Routes a fatal condition the caller detected through the same shutdown as an
833
+ * uncaught error. Returns false, and does nothing, when
834
+ * `installFatalErrorShutdown` has not run for `proc`.
835
+ */
836
+ function triggerFatalShutdown(kind, err, proc = process) {
837
+ const onFatal = fatalHandlers.get(proc);
838
+ if (!onFatal) return false;
839
+ onFatal(kind, err);
840
+ return true;
841
+ }
842
+ //#endregion
843
+ //#region src/privacy.mts
844
+ const DAY_MS = 864e5;
845
+ /** Enabling privacy on a switchboard that cannot run it safely. */
846
+ var PrivacyBootError = class extends Error {
847
+ constructor(message) {
848
+ super(message);
849
+ this.name = "PrivacyBootError";
850
+ }
851
+ };
852
+ function positive(env, name, unitMs) {
853
+ const raw = env[name];
854
+ if (raw === void 0 || raw.trim() === "") return void 0;
855
+ const value = Number(raw);
856
+ if (!Number.isFinite(value) || value <= 0) throw new PrivacyBootError(`${name} must be a positive number, got "${raw}"`);
857
+ return value * unitMs;
858
+ }
859
+ function resolvePrivacy(options, env) {
860
+ if (!(options?.enabled ?? env.PH_PRIVACY_ENABLED === "true")) return { enabled: false };
861
+ const deploymentSecret = options?.deploymentSecret ?? env.PH_PRIVACY_DEPLOYMENT_SECRET;
862
+ if (!deploymentSecret || Buffer.byteLength(deploymentSecret, "utf8") < MIN_DEPLOYMENT_SECRET_BYTES) throw new PrivacyBootError(`PH_PRIVACY_ENABLED needs PH_PRIVACY_DEPLOYMENT_SECRET of at least ${MIN_DEPLOYMENT_SECRET_BYTES} bytes`);
863
+ return {
864
+ enabled: true,
865
+ deploymentSecret,
866
+ intervalMs: positive(env, "PH_PRIVACY_INTERVAL_MS", 1),
867
+ deadlineMs: positive(env, "PH_PRIVACY_DEADLINE_DAYS", DAY_MS),
868
+ markerGraceMs: positive(env, "PH_PRIVACY_MARKER_GRACE_DAYS", DAY_MS),
869
+ purgeTimeoutMs: positive(env, "PH_PRIVACY_PURGE_TIMEOUT_MINUTES", 6e4)
870
+ };
871
+ }
872
+ /** Subject index, erasure scheduler and admin subgraph on a built reactor. */
873
+ async function startPrivacy(deps) {
874
+ const { config, reactorModule, authorizationService } = deps;
875
+ if (authorizationService.config.policy === AuthorizationPolicy.OPEN) throw new PrivacyBootError("PH_PRIVACY_ENABLED is refused under the OPEN authorization policy: every caller, anonymous included, would administer erasure. Enable authentication (AUTH_ENABLED=true with ADMINS) first.");
876
+ if (!reactorModule) throw new PrivacyBootError("PH_PRIVACY_ENABLED needs an in-process reactor module");
877
+ if (!deps.signer?.app?.key) throw new PrivacyBootError("PH_PRIVACY_ENABLED needs a reactor signer (a Renown identity): every receiver refuses an unsigned purge marker");
878
+ const permissions = deps.documentPermissionService ? new PrivacyPermissionAdapter(deps.documentPermissionService) : void 0;
879
+ const { service, scheduler } = createModuleErasure(reactorModule, {
880
+ deploymentSecret: config.deploymentSecret,
881
+ signer: deps.signer,
882
+ permissions,
883
+ intervalMs: config.intervalMs,
884
+ deadlineMs: config.deadlineMs,
885
+ markerGraceMs: config.markerGraceMs,
886
+ purgeTimeoutMs: config.purgeTimeoutMs,
887
+ logger: deps.logger
888
+ });
889
+ const disclosure = new DisclosureService(reactorModule.database, config.deploymentSecret, permissions);
890
+ let subgraph;
891
+ try {
892
+ subgraph = createPrivacySubgraph({
893
+ authorizationService,
894
+ erasure: service,
895
+ disclosure
896
+ });
897
+ } catch (error) {
898
+ throw new PrivacyBootError(error instanceof Error ? error.message : String(error));
899
+ }
900
+ await registerSubjectDocumentsReadModel(reactorModule, { deploymentSecret: config.deploymentSecret });
901
+ await deps.graphqlManager.registerSubgraphInstance({
902
+ ...subgraph,
903
+ path: deps.graphqlManager.getBasePath(),
904
+ reactorClient: deps.reactorClient,
905
+ relationalDb: void 0
906
+ }, "graphql", false);
907
+ scheduler.start();
908
+ deps.logger.info("Privacy add-on started: subject index, erasure scheduler");
909
+ return {
910
+ erasure: service,
911
+ scheduler,
912
+ stop: () => scheduler.stop()
913
+ };
914
+ }
915
+ //#endregion
587
916
  //#region src/builder-defaults.mts
588
917
  /**
589
918
  * Apply switchboard's standard configuration to a reactor + client builder
@@ -617,7 +946,13 @@ function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options
617
946
  });
618
947
  if (options.documentModelLoader) reactorBuilder.withDocumentModelLoader(options.documentModelLoader);
619
948
  if (options.logger) reactorBuilder.withLogger(options.logger);
620
- if (options.signer) clientBuilder.withSigner(options.signer);
949
+ if (options.signer) {
950
+ clientBuilder.withSigner(options.signer);
951
+ reactorBuilder.withSigner(options.signer.signer, options.signer.workerSigner);
952
+ }
953
+ if (options.createSignaturePolicy) clientBuilder.withCreateSignaturePolicy(options.createSignaturePolicy);
954
+ const trust = options.trustPolicy ?? options.signer;
955
+ if (trust?.trustPolicy) reactorBuilder.withTrustPolicy(trust.trustPolicy, trust.workerTrustPolicy);
621
956
  }
622
957
  //#endregion
623
958
  //#region src/worker-pool.mts
@@ -865,6 +1200,900 @@ async function initFeatureFlags() {
865
1200
  return OpenFeature.getClient();
866
1201
  }
867
1202
  //#endregion
1203
+ //#region src/mcp-flag.mts
1204
+ /** The env var and OpenFeature flag key that can keep the MCP server unmounted. */
1205
+ const MCP_ENABLED = "MCP_ENABLED";
1206
+ /** Whether to mount the MCP server at `/mcp`. On unless the host passes
1207
+ * `mcp: false` or MCP_ENABLED is "false": the env var can turn a host's MCP
1208
+ * off, never on, so a deployment that serves no MCP client can close the
1209
+ * endpoint without the bin growing a flag of its own. */
1210
+ async function resolveMcpEnabled({ featureFlags, option }) {
1211
+ if (option === false) return false;
1212
+ return featureFlags.getBooleanValue(MCP_ENABLED, true);
1213
+ }
1214
+ //#endregion
1215
+ //#region src/workflow/oauth-callback.ts
1216
+ const OAUTH_CALLBACK_PATH = "oauth/callback";
1217
+ function escapeHtml(text) {
1218
+ return text.replace(/[&<>"']/g, (char) => ({
1219
+ "&": "&amp;",
1220
+ "<": "&lt;",
1221
+ ">": "&gt;",
1222
+ "\"": "&quot;",
1223
+ "'": "&#39;"
1224
+ })[char]);
1225
+ }
1226
+ function resultPage(ok, detail) {
1227
+ const title = ok ? "Connected" : "Sign-in failed";
1228
+ return `<!doctype html>
1229
+ <html><head><meta charset="utf-8"><title>${title}</title>
1230
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1231
+ <style>body{font:15px system-ui,sans-serif;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#222}main{max-width:28rem;padding:1.5rem;text-align:center}h1{font-size:1.1rem}</style>
1232
+ </head><body><main><h1>${title}</h1><p>${ok ? "You can close this window." : escapeHtml(detail ?? "The sign-in did not finish.")}</p></main>
1233
+ ${ok ? "<script>setTimeout(function(){window.close()},600)<\/script>" : ""}
1234
+ </body></html>`;
1235
+ }
1236
+ function registerOAuthCallback(http, runtime) {
1237
+ return http.get(OAUTH_CALLBACK_PATH, { auth: "public" }, async (request) => {
1238
+ const params = new URL(request.url).searchParams;
1239
+ const state = params.get("state");
1240
+ const result = state ? await runtime.completeOAuth({
1241
+ state,
1242
+ code: params.get("code") ?? void 0,
1243
+ error: params.get("error") ?? void 0,
1244
+ errorDescription: params.get("error_description") ?? void 0
1245
+ }) : {
1246
+ ok: false,
1247
+ detail: "The provider sent no state",
1248
+ returnUrl: null
1249
+ };
1250
+ if (result.returnUrl && state) {
1251
+ const back = new URL(result.returnUrl);
1252
+ back.searchParams.set("ph_oauth", state);
1253
+ return new Response(null, {
1254
+ status: 303,
1255
+ headers: {
1256
+ location: back.href,
1257
+ "cache-control": "no-store"
1258
+ }
1259
+ });
1260
+ }
1261
+ return new Response(resultPage(result.ok, result.detail), {
1262
+ status: result.ok ? 200 : 400,
1263
+ headers: {
1264
+ "content-type": "text/html; charset=utf-8",
1265
+ "cache-control": "no-store",
1266
+ "referrer-policy": "no-referrer"
1267
+ }
1268
+ });
1269
+ });
1270
+ }
1271
+ function callbackUrlOf(http) {
1272
+ return `${http.baseUrl.replace(/\/+$/, "")}/${OAUTH_CALLBACK_PATH}`;
1273
+ }
1274
+ //#endregion
1275
+ //#region src/workflow/resolvers.ts
1276
+ function blockRef(input) {
1277
+ if (input.kind !== "action" && input.kind !== "trigger") throw new GraphQLError(`A block's kind is "action" or "trigger", got "${input.kind}"`);
1278
+ return {
1279
+ pieceName: input.pieceName,
1280
+ pieceVersion: input.pieceVersion,
1281
+ name: input.name,
1282
+ kind: input.kind
1283
+ };
1284
+ }
1285
+ async function cursorAware(call) {
1286
+ try {
1287
+ return await call();
1288
+ } catch (error) {
1289
+ if (error instanceof Error && error.name === "InvalidRunCursorError") throw new GraphQLError(error.message, { extensions: { code: "BAD_USER_INPUT" } });
1290
+ throw error;
1291
+ }
1292
+ }
1293
+ function selectsStepData(selections, path) {
1294
+ let level = selections;
1295
+ for (const name of [...path, "steps"]) {
1296
+ const next = [];
1297
+ for (const set of level) for (const selection of set.selections) {
1298
+ if (selection.kind !== Kind.FIELD) return true;
1299
+ if (selection.name.value === name && selection.selectionSet) next.push(selection.selectionSet);
1300
+ }
1301
+ if (next.length === 0) return false;
1302
+ level = next;
1303
+ }
1304
+ return level.some((set) => set.selections.some((selection) => selection.kind !== Kind.FIELD || selection.name.value === "input" || selection.name.value === "output"));
1305
+ }
1306
+ function readsStepData(info, path) {
1307
+ if (!info) return true;
1308
+ return selectsStepData(info.fieldNodes.flatMap((node) => node.selectionSet ? [node.selectionSet] : []), path);
1309
+ }
1310
+ function requireAdmin(authorizationService, ctx) {
1311
+ if (!authorizationService.isSupremeAdmin(ctx.user?.address)) throw new GraphQLError("Admin access required");
1312
+ }
1313
+ async function syncAware(call) {
1314
+ try {
1315
+ return await call();
1316
+ } catch (error) {
1317
+ if (error instanceof Error && error.name === "WorkflowSyncingError") throw new GraphQLError(error.message, { extensions: {
1318
+ code: "WORKFLOW_SYNCING",
1319
+ retryable: true
1320
+ } });
1321
+ throw error;
1322
+ }
1323
+ }
1324
+ function parseJson(value) {
1325
+ if (value === null) return null;
1326
+ try {
1327
+ return JSON.parse(value);
1328
+ } catch {
1329
+ return value;
1330
+ }
1331
+ }
1332
+ function toStepRecord(row) {
1333
+ return {
1334
+ stepId: row.step_id,
1335
+ stepKey: row.step_key,
1336
+ pieceName: row.piece_name,
1337
+ blockName: row.block_name,
1338
+ status: row.status,
1339
+ input: parseJson(row.input),
1340
+ output: parseJson(row.output),
1341
+ port: row.port,
1342
+ error: row.error,
1343
+ startedAt: row.started_at,
1344
+ endedAt: row.ended_at,
1345
+ pieceVersion: row.piece_version,
1346
+ pieceSource: row.piece_source,
1347
+ versionMatch: row.version_match,
1348
+ versionNote: row.version_note
1349
+ };
1350
+ }
1351
+ function toRunRecord(row, steps) {
1352
+ return {
1353
+ id: row.id,
1354
+ workflowId: row.workflow_id,
1355
+ workflowName: row.workflow_name,
1356
+ workflowVersion: row.workflow_version,
1357
+ triggerKind: row.trigger_kind,
1358
+ triggerPayload: parseJson(row.trigger_payload),
1359
+ status: row.status,
1360
+ error: row.error,
1361
+ startedAt: row.started_at,
1362
+ endedAt: row.ended_at,
1363
+ rerunOf: row.rerun_of,
1364
+ warnings: row.warnings,
1365
+ warningNotes: parseJson(row.warning_notes) ?? [],
1366
+ steps: steps.map(toStepRecord)
1367
+ };
1368
+ }
1369
+ function isAbsolute(url) {
1370
+ return /^https?:\/\//i.test(url);
1371
+ }
1372
+ function redirectUriFor(routing, requested) {
1373
+ if (!routing) throw new GraphQLError("This host serves no OAuth2 callback");
1374
+ if (isAbsolute(routing.callbackUrl)) return routing.callbackUrl;
1375
+ if (!requested) throw new GraphQLError("This host does not know its public URL; pass redirectUri");
1376
+ let url;
1377
+ try {
1378
+ url = new URL(requested);
1379
+ } catch {
1380
+ throw new GraphQLError(`"${requested}" is not a URL`);
1381
+ }
1382
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.pathname !== routing.callbackUrl || url.search !== "" || url.hash !== "") throw new GraphQLError(`redirectUri must be this host's ${routing.callbackUrl}`);
1383
+ return url.href;
1384
+ }
1385
+ function returnUrlFor(requested, ctx) {
1386
+ if (!requested) return void 0;
1387
+ const origin = ctx.headers.origin;
1388
+ let url;
1389
+ try {
1390
+ url = new URL(requested);
1391
+ } catch {
1392
+ url = void 0;
1393
+ }
1394
+ if (!url || !origin || url.origin !== origin) throw new GraphQLError("returnUrl must be on the requesting page's origin");
1395
+ return url.href;
1396
+ }
1397
+ const getResolvers = (runtime, authorizationService, oauth) => {
1398
+ return {
1399
+ Query: { workflowRuntime: () => ({}) },
1400
+ WorkflowRuntimeQueries: {
1401
+ health: () => "ok",
1402
+ blockDescriptor: (_parent, args) => runtime.blockDescriptor(blockRef(args.block)),
1403
+ blockOptions: (_parent, args, ctx) => runtime.blockOptions(blockRef(args.block), args.propName, args.input, args.connectionId ?? void 0, ctx, args.searchValue ?? void 0),
1404
+ pieceCatalog: () => runtime.pieceCatalog(),
1405
+ pieceActions: (_parent, args) => runtime.pieceActions(args.packageName, args.version ?? void 0),
1406
+ pieceTriggers: (_parent, args) => runtime.pieceTriggers(args.packageName, args.version ?? void 0),
1407
+ blockOutputTree: (_parent, args) => runtime.blockOutputTree(blockRef(args.block), args.config),
1408
+ stepOutputTree: (_parent, args, ctx) => runtime.stepOutputTree(args.workflowId, args.stepId, ctx),
1409
+ blockResolutions: (_parent, args, ctx) => runtime.blockResolutions(args.workflowId, ctx),
1410
+ pieceDetail: (_parent, args) => runtime.pieceDetail(args.packageName, args.version ?? void 0),
1411
+ searchBlocks: (_parent, args) => runtime.searchBlocks(args.query, args.limit ?? void 0),
1412
+ connections: (_parent, _args, ctx) => runtime.connections(ctx),
1413
+ webhookEndpoint: (_parent, args, ctx) => syncAware(() => runtime.webhookEndpoint(args.workflowId, ctx, { driveId: args.driveId ?? void 0 })),
1414
+ secret: async (_parent, args) => {
1415
+ try {
1416
+ return await (await runtime.secrets()).stat(args.ref);
1417
+ } catch {
1418
+ return null;
1419
+ }
1420
+ },
1421
+ secrets: async () => (await runtime.secrets()).list(),
1422
+ oauthRedirectUri: () => oauth?.callbackUrl ?? null,
1423
+ oauthAttempt: (_parent, args, ctx) => runtime.oauthAttempt(args.state, ctx),
1424
+ triggerStates: async (_parent, _args, ctx) => (await runtime.triggerStates(ctx)).map((row) => ({
1425
+ workflowId: row.workflow_id,
1426
+ pieceName: row.piece_name,
1427
+ triggerName: row.trigger_name,
1428
+ status: row.status,
1429
+ intervalMs: row.interval_ms,
1430
+ nextPollAt: row.next_poll_at,
1431
+ lastPollAt: row.last_poll_at,
1432
+ lastError: row.last_error,
1433
+ consecutiveFailures: row.consecutive_failures,
1434
+ nextRenewAt: row.next_renew_at,
1435
+ renewError: row.renew_error,
1436
+ renewFailures: row.renew_failures,
1437
+ pieceVersion: row.piece_version,
1438
+ pieceSource: row.piece_source,
1439
+ versionMatch: row.version_match,
1440
+ versionNote: row.version_note
1441
+ })),
1442
+ runs: async (_parent, args, ctx, info) => (await runtime.runs({
1443
+ ...args,
1444
+ withStepData: readsStepData(info, [])
1445
+ }, ctx)).map((record) => toRunRecord(record.row, record.steps)),
1446
+ runsPage: async (_parent, args, ctx, info) => {
1447
+ const { paging, ...scope } = args;
1448
+ const page = await cursorAware(() => runtime.runsPage({
1449
+ ...scope,
1450
+ limit: paging?.limit ?? void 0,
1451
+ cursor: paging?.cursor ?? null,
1452
+ withStepData: readsStepData(info, ["items"])
1453
+ }, ctx));
1454
+ return {
1455
+ items: page.records.map((record) => toRunRecord(record.row, record.steps)),
1456
+ hasNextPage: page.hasNextPage,
1457
+ hasPreviousPage: Boolean(paging?.cursor),
1458
+ cursor: page.cursor
1459
+ };
1460
+ },
1461
+ run: async (_parent, args, ctx) => {
1462
+ const record = await runtime.run(args.id, ctx);
1463
+ return record ? toRunRecord(record.row, record.steps) : null;
1464
+ }
1465
+ },
1466
+ Mutation: { workflowRuntime: () => ({}) },
1467
+ WorkflowRuntimeMutations: {
1468
+ fire: (_parent, args, ctx) => runtime.fire(args.workflowId, args.payload, "manual", void 0, ctx),
1469
+ testTrigger: (_parent, args, ctx) => syncAware(() => runtime.testTrigger(args.workflowId, ctx, {
1470
+ ...args.payload !== void 0 ? { payload: args.payload } : {},
1471
+ ...typeof args.timeoutSeconds === "number" ? { timeoutMs: args.timeoutSeconds * 1e3 } : {},
1472
+ driveId: args.driveId ?? void 0
1473
+ })),
1474
+ cancelTriggerTest: (_parent, args, ctx) => runtime.cancelTriggerTestFor(args.workflowId, ctx),
1475
+ testStep: (_parent, args, ctx) => syncAware(() => runtime.testStep(args.workflowId, args.stepId, ctx, { driveId: args.driveId ?? void 0 })),
1476
+ rerun: (_parent, args, ctx) => runtime.rerun(args.runId, ctx),
1477
+ createSecret: async (_parent, args, ctx) => {
1478
+ requireAdmin(authorizationService, ctx);
1479
+ return (await runtime.secrets()).create({
1480
+ value: args.value,
1481
+ label: args.label ?? void 0
1482
+ });
1483
+ },
1484
+ rotateSecret: async (_parent, args, ctx) => {
1485
+ requireAdmin(authorizationService, ctx);
1486
+ return (await runtime.secrets()).rotate(args.ref, args.value);
1487
+ },
1488
+ deleteSecret: async (_parent, args, ctx) => {
1489
+ requireAdmin(authorizationService, ctx);
1490
+ await (await runtime.secrets()).delete(args.ref);
1491
+ return true;
1492
+ },
1493
+ checkConnection: (_parent, args, ctx) => runtime.checkConnection(args.connectionId, ctx),
1494
+ startOAuth: (_parent, args, ctx) => {
1495
+ requireAdmin(authorizationService, ctx);
1496
+ const returnUrl = returnUrlFor(args.returnUrl, ctx);
1497
+ return runtime.startOAuth(args.connectionId, ctx, {
1498
+ redirectUri: redirectUriFor(oauth, args.redirectUri),
1499
+ ...returnUrl ? { returnUrl } : {}
1500
+ });
1501
+ }
1502
+ }
1503
+ };
1504
+ };
1505
+ //#endregion
1506
+ //#region src/workflow/schema.ts
1507
+ const schema = gql`
1508
+ """
1509
+ WorkflowRuntime Queries
1510
+ """
1511
+ type WorkflowRuntimeQueries {
1512
+ health: String!
1513
+ """
1514
+ Persisted runs, newest first. Scope them to one workflow, or to every
1515
+ workflow a drive holds; workflowId wins when both are given.
1516
+ """
1517
+ runs(
1518
+ workflowId: String
1519
+ driveId: String
1520
+ limit: Int
1521
+ "Trigger kinds to leave out, e.g. test."
1522
+ excludeTriggerKinds: [String!]
1523
+ ): [WorkflowRunRecord!]!
1524
+ """
1525
+ The same listing a page at a time, newest journaled first. Pass a page's
1526
+ cursor back in paging to read the next; a page is short only when
1527
+ hasNextPage is false, or when the caller could read none of a long
1528
+ stretch of runs. A run that starts between pages keeps its place.
1529
+ """
1530
+ runsPage(
1531
+ workflowId: String
1532
+ driveId: String
1533
+ excludeTriggerKinds: [String!]
1534
+ paging: WorkflowRunsPagingInput
1535
+ ): WorkflowRunResultPage!
1536
+ run(id: String!): WorkflowRunRecord
1537
+ """
1538
+ The descriptor (props, auth, ports) of one action or trigger, as the
1539
+ version that runs it describes it; triggers come back under "trigger".
1540
+ """
1541
+ blockDescriptor(block: BlockInput!): Unknown
1542
+ """
1543
+ Resolves a dynamic prop against the current config values: a DROPDOWN
1544
+ yields { options, placeholder, disabled }, a DYNAMIC prop yields the
1545
+ resolved sub-property descriptor list.
1546
+ """
1547
+ blockOptions(
1548
+ block: BlockInput!
1549
+ propName: String!
1550
+ input: Unknown
1551
+ connectionId: String
1552
+ "What the author typed, for a DROPDOWN declared with refreshOnSearch."
1553
+ searchValue: String
1554
+ ): Unknown
1555
+ """
1556
+ Every piece this reactor offers: the core piece, its package pieces, and
1557
+ the pieces the registry and Activepieces publish.
1558
+ """
1559
+ pieceCatalog: Unknown
1560
+ """
1561
+ A piece's actions at one version; pin a step to that version.
1562
+ """
1563
+ pieceActions(
1564
+ packageName: String!
1565
+ "That version; the installed or latest one when omitted."
1566
+ version: String
1567
+ ): Unknown
1568
+ """
1569
+ A piece's triggers at one version; pin a trigger to that version.
1570
+ """
1571
+ pieceTriggers(
1572
+ packageName: String!
1573
+ "That version; the installed or latest one when omitted."
1574
+ version: String
1575
+ ): Unknown
1576
+ """
1577
+ Full piece detail (PieceMetadataModel-shaped), verbatim from the cloud API.
1578
+ """
1579
+ pieceDetail(
1580
+ packageName: String!
1581
+ "That version; the installed or latest one when omitted."
1582
+ version: String
1583
+ ): Unknown
1584
+ """
1585
+ Action/trigger name search across the catalog. The index builds lazily on
1586
+ first use; poll while status is "indexing".
1587
+ """
1588
+ searchBlocks(query: String!, limit: Int): BlockSearchResult!
1589
+ """
1590
+ Health of every registered piece trigger (poll schedule, errors).
1591
+ """
1592
+ triggerStates: [TriggerStateRecord!]!
1593
+ """
1594
+ Authored output shape of a block (SDL / outputSchema / sampleData).
1595
+ """
1596
+ blockOutputTree(block: BlockInput!, config: Unknown): Unknown
1597
+ """
1598
+ Output shape of a draft step or trigger: its latest test output
1599
+ (source "test", with sample and testedAt) when tested, else as blockOutputTree.
1600
+ """
1601
+ stepOutputTree(workflowId: String!, stepId: String!): Unknown
1602
+ """
1603
+ Every block of the draft (trigger first) as this reactor resolves it:
1604
+ which piece version runs, from where, and how it matches the pin.
1605
+ """
1606
+ blockResolutions(workflowId: String!): [BlockResolutionRecord!]!
1607
+ """
1608
+ Every powerhouse/connection document, for connection pickers.
1609
+ """
1610
+ connections: [ConnectionRecord!]!
1611
+ """
1612
+ The webhook endpoint for a workflow: the URL to hand the provider. Minted
1613
+ on first ask, whether or not the workflow is armed — an author needs the
1614
+ URL before enabling, and the armed field carries that difference. Null
1615
+ only when the host has no webhook service. With driveId, a workflow not
1616
+ synced here yet is waited for briefly, then fails as WORKFLOW_SYNCING.
1617
+ """
1618
+ webhookEndpoint(workflowId: String!, driveId: String): WebhookEndpointRecord
1619
+ """
1620
+ Secret metadata (label, version, status). Never the value.
1621
+ """
1622
+ secret(ref: String!): SecretRecord
1623
+ secrets: [SecretRecord!]!
1624
+ """
1625
+ The URL an OAuth2 app must register as its redirect. A bare path when
1626
+ this host does not know its public origin; resolve it against the URL
1627
+ this API is reached at. Null when the host serves no HTTP routes.
1628
+ """
1629
+ oauthRedirectUri: String
1630
+ """
1631
+ How an OAuth2 sign-in opened by startOAuth stands; null when unknown.
1632
+ """
1633
+ oauthAttempt(state: String!): OAuthAttempt
1634
+ }
1635
+
1636
+ type OAuthAttempt {
1637
+ connectionId: String!
1638
+ "PENDING | EXCHANGING | OK | ERROR"
1639
+ status: String!
1640
+ error: String
1641
+ }
1642
+
1643
+ type OAuthStart {
1644
+ state: String!
1645
+ "Where to send the user to sign in."
1646
+ authorizationUrl: String!
1647
+ redirectUri: String!
1648
+ expiresAt: String!
1649
+ }
1650
+
1651
+ """
1652
+ One action or trigger of a piece, as a workflow pins it. The engine's own
1653
+ blocks are the piece @powerhousedao/piece-core.
1654
+ """
1655
+ input BlockInput {
1656
+ pieceName: String!
1657
+ "Exact semver."
1658
+ pieceVersion: String!
1659
+ "The action or trigger name."
1660
+ name: String!
1661
+ "action | trigger"
1662
+ kind: String!
1663
+ }
1664
+
1665
+ type BlockSearchHit {
1666
+ pieceName: String!
1667
+ "The version the hit is listed at; pin to it when picking the block."
1668
+ pieceVersion: String!
1669
+ "The action or trigger name."
1670
+ name: String!
1671
+ pieceDisplayName: String!
1672
+ logoUrl: String!
1673
+ displayName: String!
1674
+ description: String!
1675
+ "action | trigger"
1676
+ kind: String!
1677
+ "Triggers only: POLLING | WEBHOOK | APP_WEBHOOK | MANUAL"
1678
+ strategy: String
1679
+ "Why this block cannot run on this reactor; null when it can."
1680
+ unsupported: String
1681
+ }
1682
+
1683
+ type BlockResolutionRecord {
1684
+ "The trigger's or step's id."
1685
+ stepId: String!
1686
+ pieceName: String!
1687
+ "The version the block pins."
1688
+ pieceVersion: String!
1689
+ "The action or trigger name."
1690
+ name: String!
1691
+ "action | trigger"
1692
+ kind: String!
1693
+ "The version that runs; null when nothing resolves."
1694
+ resolvedVersion: String
1695
+ "local | registry | activepieces | npm; null when nothing resolves."
1696
+ source: String
1697
+ "exact | compatible | fallback | installed | missing"
1698
+ match: String!
1699
+ "Why the version that runs differs from the pin, or why nothing resolves."
1700
+ note: String
1701
+ "The newest version any source offers."
1702
+ latestVersion: String
1703
+ }
1704
+
1705
+ type BlockSearchResult {
1706
+ "ready | indexing | error"
1707
+ status: String!
1708
+ hits: [BlockSearchHit!]!
1709
+ indexedPieces: Int!
1710
+ error: String
1711
+ }
1712
+
1713
+ type SecretRecord {
1714
+ ref: String!
1715
+ label: String
1716
+ version: Int!
1717
+ status: String!
1718
+ createdAt: String!
1719
+ updatedAt: String!
1720
+ }
1721
+
1722
+ type ConnectionRecord {
1723
+ id: String!
1724
+ name: String!
1725
+ connectorId: String!
1726
+ authType: String!
1727
+ status: String!
1728
+ accountLabel: String
1729
+ }
1730
+
1731
+ type ConnectionCheckResult {
1732
+ ok: Boolean!
1733
+ detail: String
1734
+ """
1735
+ From auth.getConnectionIdentifier when the check passes; otherwise the
1736
+ label the connection already holds.
1737
+ """
1738
+ accountLabel: String
1739
+ }
1740
+
1741
+ type WebhookEndpointRecord {
1742
+ workflowId: String!
1743
+ url: String!
1744
+ """
1745
+ False when the host gave the webhook service no public origin, so the url
1746
+ above is a bare path. Switchboard always gives one, falling back to
1747
+ http://localhost:<port> when PUBLIC_URL is unset.
1748
+ """
1749
+ absoluteUrl: Boolean!
1750
+ "True while the workflow is ENABLED with a valid webhook trigger"
1751
+ armed: Boolean!
1752
+ createdAt: String!
1753
+ }
1754
+
1755
+ type TriggerStateRecord {
1756
+ workflowId: String!
1757
+ pieceName: String!
1758
+ triggerName: String!
1759
+ status: String!
1760
+ intervalMs: Int!
1761
+ nextPollAt: String
1762
+ lastPollAt: String
1763
+ lastError: String
1764
+ consecutiveFailures: Int!
1765
+ "When a webhook trigger next renews its subscription; null if it never does."
1766
+ nextRenewAt: String
1767
+ "The last failed renewal, kept apart from lastError, the poll's."
1768
+ renewError: String
1769
+ renewFailures: Int!
1770
+ "The piece version the trigger armed with; null for a host-fed trigger."
1771
+ pieceVersion: String
1772
+ pieceSource: String
1773
+ versionMatch: String
1774
+ versionNote: String
1775
+ }
1776
+
1777
+ type Query {
1778
+ workflowRuntime: WorkflowRuntimeQueries!
1779
+ }
1780
+
1781
+ type WorkflowStepRunRecord {
1782
+ stepId: String!
1783
+ stepKey: String!
1784
+ pieceName: String!
1785
+ "The action the step ran, or the trigger a trigger test sampled."
1786
+ blockName: String!
1787
+ status: String!
1788
+ input: Unknown
1789
+ output: Unknown
1790
+ port: String
1791
+ error: String
1792
+ startedAt: String
1793
+ endedAt: String
1794
+ "The piece version that ran; null for an unresolved block."
1795
+ pieceVersion: String
1796
+ "local | registry | activepieces | npm"
1797
+ pieceSource: String
1798
+ "exact | compatible | fallback | installed | missing"
1799
+ versionMatch: String
1800
+ "Why the version that ran differs from the pin."
1801
+ versionNote: String
1802
+ }
1803
+
1804
+ type WorkflowRunRecord {
1805
+ id: String!
1806
+ workflowId: String!
1807
+ workflowName: String!
1808
+ workflowVersion: Int!
1809
+ triggerKind: String!
1810
+ triggerPayload: Unknown
1811
+ status: String!
1812
+ error: String
1813
+ "When the run began executing; a PENDING run's is when it was journaled."
1814
+ startedAt: String!
1815
+ endedAt: String
1816
+ rerunOf: String
1817
+ "How many warningNotes the run has; a run with any is not a plain success."
1818
+ warnings: Int!
1819
+ "Steps that ran a fallback piece version, edges on ports nothing emits."
1820
+ warningNotes: [String!]!
1821
+ steps: [WorkflowStepRunRecord!]!
1822
+ }
1823
+
1824
+ input WorkflowRunsPagingInput {
1825
+ "Page size; default 25, at most 100."
1826
+ limit: Int
1827
+ "The cursor of the previous page."
1828
+ cursor: String
1829
+ }
1830
+
1831
+ type WorkflowRunResultPage {
1832
+ items: [WorkflowRunRecord!]!
1833
+ hasNextPage: Boolean!
1834
+ "True when the page was read after a cursor."
1835
+ hasPreviousPage: Boolean!
1836
+ "Resumes after this page's last run; null for an empty page."
1837
+ cursor: String
1838
+ }
1839
+
1840
+ type WorkflowStepRun {
1841
+ stepId: String!
1842
+ key: String!
1843
+ pieceName: String!
1844
+ blockName: String!
1845
+ status: String!
1846
+ input: Unknown
1847
+ output: Unknown
1848
+ port: String
1849
+ error: String
1850
+ }
1851
+
1852
+ type WorkflowRunPayload {
1853
+ runId: String
1854
+ status: String!
1855
+ error: String
1856
+ steps: [WorkflowStepRun!]!
1857
+ }
1858
+
1859
+ type WorkflowStepTestResult {
1860
+ "Null when the test never started, e.g. an upstream block is untested."
1861
+ runId: String
1862
+ "SUCCEEDED | FAILED"
1863
+ status: String!
1864
+ output: Unknown
1865
+ error: String
1866
+ durationMs: Int!
1867
+ }
1868
+
1869
+ """
1870
+ WorkflowRuntime Mutations
1871
+ """
1872
+ type WorkflowRuntimeMutations {
1873
+ """
1874
+ Fires a workflow's manual trigger (the core piece's) and runs it to completion.
1875
+ """
1876
+ fire(workflowId: String!, payload: Unknown): WorkflowRunPayload!
1877
+ """
1878
+ Samples the draft trigger and saves it as the trigger's last test. A piece
1879
+ trigger runs its test hook (no cursor changes). Of the core piece's
1880
+ triggers, manual takes payload, schedule samples a fire now, and webhook
1881
+ waits for the next delivery to the workflow's endpoint, up to
1882
+ timeoutSeconds (default and cap 300), and that delivery runs nothing.
1883
+ """
1884
+ testTrigger(
1885
+ workflowId: String!
1886
+ payload: Unknown
1887
+ timeoutSeconds: Int
1888
+ driveId: String
1889
+ ): Unknown
1890
+ """
1891
+ Stops a webhook trigger test that is waiting; false when none was.
1892
+ """
1893
+ cancelTriggerTest(workflowId: String!): Boolean!
1894
+ """
1895
+ Runs one draft step against the latest test outputs of the blocks it
1896
+ reads, journaled as a "test" run and noted as the step's lastTest.
1897
+ driveId waits for an unsynced workflow, as for webhookEndpoint.
1898
+ """
1899
+ testStep(
1900
+ workflowId: String!
1901
+ stepId: String!
1902
+ driveId: String
1903
+ ): WorkflowStepTestResult!
1904
+ """
1905
+ Resumes a FAILED run: succeeded steps replay from the journal,
1906
+ execution restarts at the failure. Produces a new run.
1907
+ """
1908
+ rerun(runId: String!): WorkflowRunPayload!
1909
+ """
1910
+ Mints a managed secret and returns its ref; the value is stored
1911
+ encrypted and is never readable back over any API.
1912
+ """
1913
+ createSecret(value: String!, label: String): SecretRecord!
1914
+ """
1915
+ Replaces the value behind an existing ref; documents stay untouched.
1916
+ """
1917
+ rotateSecret(ref: String!, value: String!): SecretRecord!
1918
+ """
1919
+ Tombstones a secret; its value becomes unrecoverable.
1920
+ """
1921
+ deleteSecret(ref: String!): Boolean!
1922
+ """
1923
+ Runs the piece's auth.validate against the connection's credentials,
1924
+ then auth.getConnectionIdentifier for the account label, and records the
1925
+ outcome on the connection document.
1926
+ """
1927
+ checkConnection(connectionId: String!): ConnectionCheckResult!
1928
+ """
1929
+ Opens an OAuth2 sign-in with the connection's own app (client_id in its
1930
+ config, client_secret in its secretRefs). redirectUri is needed only when
1931
+ oauthRedirectUri is a bare path. With returnUrl, which must be on the
1932
+ caller's origin, the callback sends the browser back there with
1933
+ ?ph_oauth=<state> instead of closing the window.
1934
+ """
1935
+ startOAuth(
1936
+ connectionId: String!
1937
+ redirectUri: String
1938
+ returnUrl: String
1939
+ ): OAuthStart!
1940
+ }
1941
+
1942
+ type Mutation {
1943
+ workflowRuntime: WorkflowRuntimeMutations!
1944
+ }
1945
+ `;
1946
+ //#endregion
1947
+ //#region src/workflow/subgraph.ts
1948
+ /** The runtime's read/write surface. The runtime itself is composed by the
1949
+ * host, so the subgraph only serves what it is handed. */
1950
+ function createWorkflowRuntimeSubgraph(runtime, oauth) {
1951
+ return class WorkflowRuntimeSubgraph extends BaseSubgraph {
1952
+ name = "workflow-runtime";
1953
+ typeDefs = schema;
1954
+ resolvers = getResolvers(runtime, this.authorizationService, oauth);
1955
+ additionalContextFields = {};
1956
+ };
1957
+ }
1958
+ //#endregion
1959
+ //#region src/workflow-runtime.mts
1960
+ /** The npm name the workflow package owns: its HTTP namespace and its models. */
1961
+ const WORKFLOW_PACKAGE_NAME = "@powerhousedao/workflow";
1962
+ /** The env var and OpenFeature flag key that turns workflows on. */
1963
+ const PH_WORKFLOWS_ENABLED = "PH_WORKFLOWS_ENABLED";
1964
+ /** Precedence, unchanged from the reactor-api resolver this replaces: the
1965
+ * host's option, then PH_WORKFLOWS_ENABLED, then the config file, then off. */
1966
+ async function resolveWorkflowsEnabled({ featureFlags, override, configEnabled = false, env = process.env }) {
1967
+ if (override !== void 0) return override;
1968
+ const raw = env[PH_WORKFLOWS_ENABLED]?.trim();
1969
+ if (raw === "1") return true;
1970
+ if (raw === "0") return false;
1971
+ return featureFlags.getBooleanValue(PH_WORKFLOWS_ENABLED, configEnabled);
1972
+ }
1973
+ async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao/workflow/document-models")) {
1974
+ try {
1975
+ await load();
1976
+ } catch (error) {
1977
+ throw new Error(`Workflows are enabled but ${WORKFLOW_PACKAGE_NAME} could not be loaded`, { cause: error });
1978
+ }
1979
+ }
1980
+ function readAssertion(authorizationService, reactorClient) {
1981
+ const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
1982
+ const hostCanRead = async (identifier, ctx) => {
1983
+ if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
1984
+ if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
1985
+ let documentId;
1986
+ try {
1987
+ documentId = await resolveCanonical(identifier);
1988
+ } catch {
1989
+ throw new ForbiddenError();
1990
+ }
1991
+ if (!await authorizationService.canRead(documentId, ctx.user?.address)) throw new ForbiddenError("to read this document");
1992
+ };
1993
+ return async (identifier, caller) => {
1994
+ const ctx = caller;
1995
+ await hostCanRead(identifier, ctx);
1996
+ let served;
1997
+ try {
1998
+ served = await reactorClient.isServed(identifier, { subject: callerSubject(ctx.user) });
1999
+ } catch {
2000
+ throw new ForbiddenError();
2001
+ }
2002
+ if (!served) throw new ForbiddenError("to read this document");
2003
+ };
2004
+ }
2005
+ /** The same, for a design-time call that writes what it names. */
2006
+ function writeAssertion(authorizationService, reactorClient) {
2007
+ const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
2008
+ return async (identifier, caller) => {
2009
+ const ctx = caller;
2010
+ if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
2011
+ if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
2012
+ let documentId;
2013
+ try {
2014
+ documentId = await resolveCanonical(identifier);
2015
+ } catch {
2016
+ throw new ForbiddenError();
2017
+ }
2018
+ if (!await authorizationService.canWrite(documentId, ctx.user?.address)) throw new ForbiddenError("to write this document");
2019
+ };
2020
+ }
2021
+ /** A step reads any well-formed attachment, as it reads any document: a run
2022
+ * carries no caller to check against. */
2023
+ function canReadAttachmentRef(_documentId, ref) {
2024
+ try {
2025
+ return Promise.resolve(parseRef(ref).version === 1);
2026
+ } catch {
2027
+ return Promise.resolve(false);
2028
+ }
2029
+ }
2030
+ async function registerWorkflowTriggersReadModel(engine, runtime, clientModule) {
2031
+ const reactorModule = clientModule?.reactorModule;
2032
+ if (!reactorModule) return {
2033
+ status: "unavailable",
2034
+ reason: "in-process-reactor-module-unavailable"
2035
+ };
2036
+ const coordinator = reactorModule.readModelCoordinator;
2037
+ if (!supportsLiveReadModelRegistration(coordinator)) return {
2038
+ status: "unavailable",
2039
+ reason: "live-read-model-registration-unsupported"
2040
+ };
2041
+ const readModel = new engine.WorkflowTriggersReadModel(reactorModule.database.withSchema(REACTOR_SCHEMA), reactorModule.operationIndex, reactorModule.writeCache, reactorModule.processorManagerConsistencyTracker, runtime);
2042
+ await readModel.init();
2043
+ coordinator.addReadModel(readModel, engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE);
2044
+ return { status: "available" };
2045
+ }
2046
+ function bindPackagePieces(registry, source) {
2047
+ const apply = (byPackage) => {
2048
+ registry.setPieces([...byPackage.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).flatMap(([, pieces]) => pieces));
2049
+ };
2050
+ apply(source.getPieces());
2051
+ source.onPiecesChange(apply);
2052
+ }
2053
+ async function composeWorkflowRuntime(deps) {
2054
+ const load = deps.load ?? (() => import("@powerhousedao/reactor-workflow"));
2055
+ let engine;
2056
+ try {
2057
+ engine = await load();
2058
+ } catch (error) {
2059
+ throw new Error("Workflows are enabled but @powerhousedao/reactor-workflow could not be loaded", { cause: error });
2060
+ }
2061
+ engine.setPieceRegistryUrl(deps.pieceRegistryUrl);
2062
+ if (deps.pieces) bindPackagePieces(engine.packagePieces, deps.pieces);
2063
+ const runtime = engine.createWorkflowRuntime({
2064
+ relationalDb: deps.relationalDb,
2065
+ secretsKeyFile: deps.secretsKeyFile,
2066
+ reactorClient: deps.reactorClient,
2067
+ assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
2068
+ subjectOf: (caller) => callerSubject(caller.user),
2069
+ assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
2070
+ webhooks: deps.webhooks,
2071
+ attachments: deps.attachments,
2072
+ canReadAttachmentRef,
2073
+ logger: deps.logger
2074
+ });
2075
+ const triggers = await registerWorkflowTriggersReadModel(engine, runtime, deps.clientModule);
2076
+ if (triggers.status === "available") deps.logger.info(`Workflow trigger read model registered (${engine.WORKFLOW_TRIGGERS_READ_MODEL}, ${engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE})`);
2077
+ else deps.logger.error("Workflow document triggers are NOT armed (@reason): this reactor's read-model coordinator takes no live registration, so no document operation reaches the runtime. Webhook and schedule triggers are unaffected.", triggers.reason);
2078
+ let stopped = false;
2079
+ const oauthCallback = deps.http ? registerOAuthCallback(deps.http, runtime) : void 0;
2080
+ return {
2081
+ subgraph: createWorkflowRuntimeSubgraph(runtime, deps.http ? { callbackUrl: callbackUrlOf(deps.http) } : void 0),
2082
+ triggers,
2083
+ async start() {
2084
+ await runtime.registerWebhookEndpoint();
2085
+ runtime.startTriggerSupervisor();
2086
+ },
2087
+ stop() {
2088
+ if (stopped) return Promise.resolve();
2089
+ stopped = true;
2090
+ oauthCallback?.dispose();
2091
+ runtime.shutdown();
2092
+ return Promise.resolve();
2093
+ }
2094
+ };
2095
+ }
2096
+ //#endregion
868
2097
  //#region src/pglite-dialect.ts
869
2098
  var ClosablePGliteDialect = class extends PGliteDialect {
870
2099
  #pglite;
@@ -1003,6 +2232,26 @@ function resolveReactorFeatureFlags(env) {
1003
2232
  };
1004
2233
  }
1005
2234
  //#endregion
2235
+ //#region src/create-signature-policy.mts
2236
+ /**
2237
+ * What new documents are created as, from CREATE_SIGNATURE_POLICY. Without a
2238
+ * signer this switchboard cannot write to a v2-required document, so it falls
2239
+ * back to legacy and says so.
2240
+ */
2241
+ function resolveCreateSignaturePolicy(env, options) {
2242
+ const raw = env.CREATE_SIGNATURE_POLICY?.trim();
2243
+ let policy = DEFAULT_SIGNATURE_POLICY;
2244
+ if (raw) {
2245
+ if (!isSignaturePolicy(raw)) throw new Error(`CREATE_SIGNATURE_POLICY must be "legacy" or "v2-required", got "${raw}"`);
2246
+ policy = raw;
2247
+ }
2248
+ if (policy === "v2-required" && !options.hasSigner) {
2249
+ options.logger?.warn("No signer is configured, so new documents are created legacy: a v2-required document refuses the unsigned operations this switchboard would write. Set up an identity (ph login) to create v2-required documents.");
2250
+ return "legacy";
2251
+ }
2252
+ return policy;
2253
+ }
2254
+ //#endregion
1006
2255
  //#region src/renown.ts
1007
2256
  const logger = childLogger(["switchboard", "renown"]);
1008
2257
  /**
@@ -1025,15 +2274,123 @@ async function initRenown(options = {}) {
1025
2274
  * Get the signer config for the given renown instance.
1026
2275
  *
1027
2276
  * @param renown - The renown instance
1028
- * @param requireSignature - If true, unsigned actions are rejected
2277
+ * @param keypairPath - Where `initRenown` loaded the key from
1029
2278
  */
1030
- function getRenownSignerConfig(renown, requireSignature) {
2279
+ function getRenownSignerConfig(renown, keypairPath) {
1031
2280
  return {
1032
2281
  signer: renown.signer,
1033
- verifier: createSignatureVerifier(requireSignature)
2282
+ workerSigner: getRenownWorkerSignerSpec(renown, keypairPath)
2283
+ };
2284
+ }
2285
+ /**
2286
+ * What a pooled executor worker imports to sign as this switchboard: it
2287
+ * reloads the key `initRenown` stored, and takes the user known at boot.
2288
+ */
2289
+ function getRenownWorkerSignerSpec(renown, keypairPath) {
2290
+ const { signer } = renown;
2291
+ const user = signer.user;
2292
+ return {
2293
+ module: {
2294
+ filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
2295
+ exportName: "createNodeRenownSigner"
2296
+ },
2297
+ initArgs: {
2298
+ appName: signer.app?.name ?? "switchboard",
2299
+ keypairPath: resolve(keypairPath ?? DEFAULT_KEYPAIR_PATH),
2300
+ ...signer.app?.key ? { did: signer.app.key } : {},
2301
+ ...user ? { user: {
2302
+ address: user.address,
2303
+ networkId: user.networkId,
2304
+ chainId: user.chainId
2305
+ } } : {}
2306
+ }
2307
+ };
2308
+ }
2309
+ /**
2310
+ * Admits a key as a signer for an address a Renown credential binds it to; a
2311
+ * `self` source has no worker spec. In process, the switchboard's own key is
2312
+ * matched against its current user; the worker spec carries the user known now.
2313
+ */
2314
+ async function getRenownTrustPolicyConfig(trustSource, renown) {
2315
+ const ownSigner = renown?.signer;
2316
+ if (trustSource.source === "self") return { trustPolicy: createRenownTrustPolicy({
2317
+ switchboard: trustSource.request,
2318
+ ownSigner
2319
+ }) };
2320
+ const switchboardUrl = await resolveSwitchboardEndpoint({
2321
+ switchboardUrl: trustSource.switchboardUrl,
2322
+ baseUrl: trustSource.renownUrl
2323
+ });
2324
+ const options = {
2325
+ ...switchboardUrl ? { switchboard: switchboardUrl } : {},
2326
+ ...trustSource.renownUrl ? { renownUrl: trustSource.renownUrl } : {}
2327
+ };
2328
+ const self = ownIdentity(renown);
2329
+ return {
2330
+ trustPolicy: createRenownTrustPolicy({
2331
+ ...options,
2332
+ ownSigner
2333
+ }),
2334
+ workerTrustPolicy: {
2335
+ module: {
2336
+ filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
2337
+ exportName: "createRenownTrustPolicy"
2338
+ },
2339
+ initArgs: {
2340
+ ...options,
2341
+ ...self ? { self } : {}
2342
+ }
2343
+ }
2344
+ };
2345
+ }
2346
+ /**
2347
+ * Fails the boot when pooled workers would refuse every signed write the
2348
+ * in-process executor accepts: a `self` Renown source gives workers no trust
2349
+ * policy spec, so under authEnforcement they apply the default.
2350
+ */
2351
+ function assertWorkerTrustPolicy(config) {
2352
+ if (config.workers > 0 && config.authEnforcement && config.renownSource === "self") throw new Error("The executor worker pool (REACTOR_WORKERS) cannot verify signers when REACTOR_AUTH_ENFORCEMENT is on and Renown credentials are read from this switchboard (\"self\" via auth.renown.source or RENOWN_SOURCE): pooled workers have no trust policy and would refuse every signed write. Set RENOWN_SOURCE=remote, or disable the worker pool (REACTOR_WORKERS=0).");
2353
+ }
2354
+ function ownIdentity(renown) {
2355
+ const key = renown?.signer.app?.key;
2356
+ if (!key) return;
2357
+ const user = renown.signer.user;
2358
+ return {
2359
+ key,
2360
+ user: {
2361
+ address: user?.address ?? "",
2362
+ networkId: user?.networkId ?? "",
2363
+ chainId: user?.chainId ?? 0
2364
+ }
1034
2365
  };
1035
2366
  }
1036
2367
  //#endregion
2368
+ //#region src/unsupported-stored-documents.mts
2369
+ const MODES = ["refuse", "read-only"];
2370
+ /** From REACTOR_UNSUPPORTED_STORED_DOCUMENTS; `input` wins when defined. */
2371
+ function resolveUnsupportedStoredDocuments(input, env) {
2372
+ if (input !== void 0) return input;
2373
+ const raw = env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS?.trim();
2374
+ if (!raw) return "refuse";
2375
+ if (!MODES.includes(raw)) throw new Error(`REACTOR_UNSUPPORTED_STORED_DOCUMENTS must be "refuse" or "read-only", got "${raw}"`);
2376
+ return raw;
2377
+ }
2378
+ /** The reactor refused to start over documents this build does not run. */
2379
+ var StoredDocumentsRefusedError = class extends Error {
2380
+ versions;
2381
+ documents;
2382
+ constructor(cause) {
2383
+ const named = cause.versions.map(({ protocol, version }) => `${protocol} ${version}`).join(", ");
2384
+ super(`Refusing to start: ${cause.documents} stored document(s) require ${named}, which this switchboard does not run. Either start a switchboard build that runs ${named}, or set REACTOR_UNSUPPORTED_STORED_DOCUMENTS=read-only to start with those documents read-only.`, { cause });
2385
+ this.name = "StoredDocumentsRefusedError";
2386
+ this.versions = cause.versions;
2387
+ this.documents = cause.documents;
2388
+ }
2389
+ static isError(error) {
2390
+ return Error.isError(error) && error.name === "StoredDocumentsRefusedError";
2391
+ }
2392
+ };
2393
+ //#endregion
1037
2394
  //#region src/server.mts
1038
2395
  const defaultLogger = childLogger(["switchboard"]);
1039
2396
  const LogLevel = process.env.LOG_LEVEL || "info";
@@ -1041,8 +2398,6 @@ setLogLevel(LogLevel);
1041
2398
  dotenv.config();
1042
2399
  const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
1043
2400
  const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
1044
- const REQUIRE_SIGNATURES = "REQUIRE_SIGNATURES";
1045
- const REQUIRE_SIGNATURES_DEFAULT = false;
1046
2401
  const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
1047
2402
  const PORT_FALLBACK_ATTEMPTS = 20;
1048
2403
  const PGLITE_FLUSH_INTERVAL_MS = (() => {
@@ -1051,6 +2406,9 @@ const PGLITE_FLUSH_INTERVAL_MS = (() => {
1051
2406
  const parsed = Number(raw);
1052
2407
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : 100;
1053
2408
  })();
2409
+ function onPgliteFlushError(err) {
2410
+ triggerFatalShutdown("PGlite snapshot flush failed", err);
2411
+ }
1054
2412
  const PGLITE_IN_MEMORY = process.env.PH_PGLITE_IN_MEMORY === "1";
1055
2413
  /**
1056
2414
  * Attempt to bind a throwaway TCP server to the given port. Resolves true if
@@ -1077,6 +2435,13 @@ function isPortAvailable(port) {
1077
2435
  function resolveConfigPath(configFile) {
1078
2436
  return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
1079
2437
  }
2438
+ function readConfigWorkflowsEnabled(configPath) {
2439
+ try {
2440
+ return getConfig(configPath).workflows?.enabled ?? false;
2441
+ } catch {
2442
+ return false;
2443
+ }
2444
+ }
1080
2445
  async function resolveServerPort(requested, strictPort, logger) {
1081
2446
  if (strictPort) return requested;
1082
2447
  for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
@@ -1108,7 +2473,8 @@ async function createReactorKysely(opts) {
1108
2473
  const { PGlite } = await loadPGliteModule(reactorPgliteMajor);
1109
2474
  const pglite = inMemory ? new PGlite() : new PGlite({ fs: new AtomicNodeFs(reactorPgliteDir, {
1110
2475
  logger,
1111
- flushIntervalMs
2476
+ flushIntervalMs,
2477
+ onFlushError: onPgliteFlushError
1112
2478
  }) });
1113
2479
  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
2480
  return {
@@ -1189,8 +2555,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
1189
2555
  }
1190
2556
  if (workerPool) {
1191
2557
  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");
2558
+ assertWorkerTrustPolicy({
2559
+ workers: workerPool.numWorkers,
2560
+ authEnforcement: resolveReactorFeatureFlags(process.env).flags.authEnforcement === true,
2561
+ renownSource: renownConfig.source
2562
+ });
1192
2563
  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
2564
  }
2565
+ const unsupportedStoredDocuments = resolveUnsupportedStoredDocuments(options.unsupportedStoredDocuments, process.env);
1194
2566
  let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
1195
2567
  if (projectionWorker && options.reactor) {
1196
2568
  logger.warn("Projection worker configuration ignored: the caller-provided reactor owns its own read-model coordinator");
@@ -1224,7 +2596,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
1224
2596
  reactorLogger.error(`Projection worker ${shardId} died; shutting down so the supervisor restarts a healthy process`, reason);
1225
2597
  process.kill(process.pid, "SIGTERM");
1226
2598
  };
2599
+ const workflowsEnabled = options.workflows?.enabled === true;
2600
+ const privacyConfig = resolvePrivacy(options.privacy, process.env);
2601
+ if (workflowsEnabled) {
2602
+ await assertWorkflowPackageLoadable();
2603
+ if (!packages.includes("@powerhousedao/workflow")) packages.push(WORKFLOW_PACKAGE_NAME);
2604
+ }
1227
2605
  let ownedReactorModule;
2606
+ let localRenownRequest;
1228
2607
  const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
1229
2608
  if (options.reactor) {
1230
2609
  const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
@@ -1251,7 +2630,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
1251
2630
  if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
1252
2631
  const { flags: reactorFeatureFlags, enabled: enabledFeatureFlags } = resolveReactorFeatureFlags(process.env);
1253
2632
  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" });
2633
+ const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" }).withUnsupportedStoredDocuments(unsupportedStoredDocuments);
1255
2634
  if (poolInstrumentation) reactorBuilder.withInstrumentedPool(poolInstrumentation);
1256
2635
  const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
1257
2636
  const vetraDocumentModels = dev ? Object.values(await import("@powerhousedao/vetra/document-models")).filter((m) => typeof m === "object" && m !== null && "documentModel" in m && "reducer" in m) : [];
@@ -1264,7 +2643,19 @@ async function initServer(serverPort, options, renown, renownConfig) {
1264
2643
  } : void 0,
1265
2644
  documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
1266
2645
  logger: reactorLogger,
1267
- signer: renown ? getRenownSignerConfig(renown, options.identity?.requireSignatures) : void 0
2646
+ signer: renown ? getRenownSignerConfig(renown, options.identity?.keypairPath) : void 0,
2647
+ createSignaturePolicy: resolveCreateSignaturePolicy(process.env, {
2648
+ hasSigner: renown !== null,
2649
+ logger
2650
+ }),
2651
+ trustPolicy: reactorFeatureFlags.authEnforcement ? await getRenownTrustPolicyConfig(renownConfig.source === "self" ? {
2652
+ source: "self",
2653
+ request: (query, variables) => localRenownRequest ? localRenownRequest(query, variables) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet"))
2654
+ } : {
2655
+ source: "remote",
2656
+ renownUrl: renownConfig.url,
2657
+ switchboardUrl: renownConfig.switchboardUrl
2658
+ }, renown) : void 0
1268
2659
  });
1269
2660
  if (workerPool) {
1270
2661
  if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
@@ -1308,17 +2699,30 @@ async function initServer(serverPort, options, renown, renownConfig) {
1308
2699
  reactorBuilder.withShutdownHook(async () => {
1309
2700
  if (apiRef.current) await apiRef.current.dispose();
1310
2701
  });
1311
- const module = await clientBuilder.buildModule();
2702
+ let module;
2703
+ try {
2704
+ module = await clientBuilder.buildModule();
2705
+ } catch (error) {
2706
+ try {
2707
+ await baseKysely.destroy();
2708
+ } catch (destroyError) {
2709
+ logger.error("Aborting boot: reactor database destroy failed: @error", destroyError);
2710
+ }
2711
+ if (UnsupportedStoredProtocolError.isError(error)) throw new StoredDocumentsRefusedError(error);
2712
+ throw error;
2713
+ }
1312
2714
  if (module.reactorModule) {
1313
2715
  new ReactorInstrumentation(module.reactorModule).start();
1314
2716
  reactorLogger.info("Reactor metrics instrumentation started");
1315
2717
  }
1316
- driveNodeView = new DriveNodeView(baseKysely.withSchema(REACTOR_SCHEMA));
2718
+ const reactorDriveSchemaDb = baseKysely.withSchema(REACTOR_SCHEMA);
2719
+ driveNodeView = new DriveNodeView(reactorDriveSchemaDb);
1317
2720
  const reactorDriveClient = new ReactorDriveClient({
1318
2721
  reactor: module.client,
1319
2722
  readModel: driveNodeView
1320
2723
  });
1321
2724
  ownedReactorModule = module;
2725
+ if (options.fatalErrorShutdown) installFatalErrorShutdown(logger);
1322
2726
  return {
1323
2727
  module,
1324
2728
  reactorDriveClient,
@@ -1352,7 +2756,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
1352
2756
  if (dev) {
1353
2757
  const { VitePackageLoader, createViteLogger, startViteServer } = await import("@powerhousedao/reactor-api/vite");
1354
2758
  vite = await startViteServer(process.cwd(), createViteLogger(logger));
1355
- viteLoader = VitePackageLoader.build(vite);
2759
+ viteLoader = VitePackageLoader.build(vite, { resolveFrom: [import.meta.url] });
1356
2760
  }
1357
2761
  const vetraProcessorFactory = dev ? (await import("@powerhousedao/vetra/processors")).processorFactory : void 0;
1358
2762
  if (!options.disableLocalPackages) packages.push(basePath);
@@ -1373,7 +2777,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
1373
2777
  pgliteFactory = PGLITE_IN_MEMORY ? () => new ReadModelPGlite({ parsers: PGLITE_UTC_PARSERS }) : (connectionString) => new ReadModelPGlite({
1374
2778
  fs: new AtomicNodeFs(connectionString ?? readModelPgliteDir, {
1375
2779
  logger,
1376
- flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS
2780
+ flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS,
2781
+ onFlushError: onPgliteFlushError
1377
2782
  }),
1378
2783
  parsers: PGLITE_UTC_PARSERS
1379
2784
  });
@@ -1401,7 +2806,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
1401
2806
  await abortBoot(api);
1402
2807
  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
2808
  }
1404
- localCredentialCheck = createLocalCredentialVerifier((query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables), { onError: (error) => logger.error("Renown read model query failed: @error", error) });
2809
+ localRenownRequest = (query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables);
2810
+ localCredentialCheck = createLocalCredentialVerifier(localRenownRequest, { onError: (error) => logger.error("Renown read model query failed: @error", error) });
1405
2811
  logger.info("Renown credentials will be verified against this switchboard's own renown read model");
1406
2812
  }
1407
2813
  registerAttachmentRoutes(api);
@@ -1409,6 +2815,65 @@ async function initServer(serverPort, options, renown, renownConfig) {
1409
2815
  if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
1410
2816
  const { client, graphqlManager, documentModelRegistry } = api;
1411
2817
  const lateSubgraphs = [];
2818
+ let workflows;
2819
+ if (workflowsEnabled) {
2820
+ workflows = await composeWorkflowRuntime({
2821
+ reactorClient: client,
2822
+ clientModule: options.reactor ?? ownedReactorModule,
2823
+ relationalDb: api.relationalDb,
2824
+ secretsKeyFile: readModelPgliteDir === null ? false : void 0,
2825
+ attachments: createAttachmentClient(api.attachments.service),
2826
+ webhooks: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME).webhooks,
2827
+ http: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME),
2828
+ authorizationService: api.authorizationService,
2829
+ pieces: api.packageManager,
2830
+ pieceRegistryUrl: registryUrl,
2831
+ logger: logger.child(["workflow-runtime"])
2832
+ });
2833
+ const WorkflowRuntimeSubgraph = workflows.subgraph;
2834
+ const workflowSubgraph = new WorkflowRuntimeSubgraph({
2835
+ reactorClient: client,
2836
+ http: graphqlManager.scopeForPackage(WORKFLOW_PACKAGE_NAME),
2837
+ relationalDb: api.relationalDb,
2838
+ analyticsStore: void 0,
2839
+ graphqlManager,
2840
+ syncManager: api.syncManager,
2841
+ authorizationService: graphqlManager.getAuthorizationService(),
2842
+ path: graphqlManager.getBasePath(),
2843
+ attachments: api.attachmentClientProvider
2844
+ });
2845
+ lateSubgraphs.push(graphqlManager.registerSubgraphInstance(workflowSubgraph, "graphql", false).catch((error) => {
2846
+ logger.error("Failed to register workflow-runtime subgraph: @error", error);
2847
+ }));
2848
+ await workflows.start();
2849
+ logger.info("Workflow runtime started");
2850
+ }
2851
+ let privacy;
2852
+ if (privacyConfig.enabled) {
2853
+ const signer = options.reactor ? options.privacy?.signer : options.privacy?.signer ?? renown?.signer;
2854
+ try {
2855
+ privacy = await startPrivacy({
2856
+ config: privacyConfig,
2857
+ reactorModule: (options.reactor ?? ownedReactorModule)?.reactorModule,
2858
+ signer,
2859
+ authorizationService: api.authorizationService,
2860
+ documentPermissionService: api.documentPermissionService,
2861
+ graphqlManager,
2862
+ reactorClient: client,
2863
+ logger: logger.child(["privacy"])
2864
+ });
2865
+ } catch (error) {
2866
+ await workflows?.stop();
2867
+ await abortBoot(api);
2868
+ throw error;
2869
+ }
2870
+ }
2871
+ const shutdown = async () => {
2872
+ await privacy?.stop();
2873
+ await workflows?.stop();
2874
+ await api.dispose();
2875
+ };
2876
+ apiRef.current = { dispose: shutdown };
1412
2877
  if (httpLoader) {
1413
2878
  const packageManagementService = new PackageManagementService({
1414
2879
  defaultRegistryUrl: registryUrl,
@@ -1430,7 +2895,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
1430
2895
  path: graphqlManager.getBasePath(),
1431
2896
  authorizationService: graphqlManager.getAuthorizationService(),
1432
2897
  packageManagementService,
1433
- http: graphqlManager.scopeForPackage("@powerhousedao/switchboard")
2898
+ http: graphqlManager.scopeForPackage("@powerhousedao/switchboard"),
2899
+ attachments: api.attachmentClientProvider
1434
2900
  });
1435
2901
  lateSubgraphs.push(graphqlManager.registerSubgraphInstance(packagesSubgraph, "graphql", false).catch((error) => {
1436
2902
  logger.error("Failed to register packages subgraph: @error", error);
@@ -1438,10 +2904,15 @@ async function initServer(serverPort, options, renown, renownConfig) {
1438
2904
  }
1439
2905
  if (driveNodeView) {
1440
2906
  graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
2907
+ const authorizationService = graphqlManager.getAuthorizationService();
1441
2908
  const reactorDriveSubgraph = {
1442
2909
  name: "reactor-drive",
1443
2910
  path: graphqlManager.getBasePath(),
1444
- resolvers: createReactorDriveResolvers(),
2911
+ resolvers: createReactorDriveResolvers({
2912
+ reactorClient: client,
2913
+ readModel: driveNodeView,
2914
+ hostCanRead: (documentId, address) => authorizationService.canRead(documentId, address)
2915
+ }),
1445
2916
  typeDefs: reactorDriveSubgraphTypeDefs,
1446
2917
  reactorClient: client,
1447
2918
  relationalDb: void 0
@@ -1492,9 +2963,13 @@ async function initServer(serverPort, options, renown, renownConfig) {
1492
2963
  reactor: client,
1493
2964
  attachmentService,
1494
2965
  attachmentReferenceProjection: api.attachmentReferenceProjection,
2966
+ workflowTriggers: workflows?.triggers,
2967
+ workflowsEnabled,
2968
+ privacy: privacy ? { erasure: privacy.erasure } : void 0,
2969
+ mcpEnabled: options.mcp !== false,
1495
2970
  renown,
1496
2971
  port: serverPort,
1497
- shutdown: () => api.dispose()
2972
+ shutdown
1498
2973
  };
1499
2974
  }
1500
2975
  /**
@@ -1521,16 +2996,26 @@ const startSwitchboard = async (options = {}) => {
1521
2996
  const featureFlags = await initFeatureFlags();
1522
2997
  const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
1523
2998
  options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
1524
- const requireSignatures = options.identity?.requireSignatures ?? await featureFlags.getBooleanValue(REQUIRE_SIGNATURES, REQUIRE_SIGNATURES_DEFAULT);
2999
+ options.mcp = await resolveMcpEnabled({
3000
+ featureFlags,
3001
+ option: options.mcp
3002
+ });
3003
+ const configPathForFlags = resolveConfigPath(options.configFile);
3004
+ const workflowsEnabled = await resolveWorkflowsEnabled({
3005
+ featureFlags,
3006
+ override: options.workflows?.enabled,
3007
+ configEnabled: readConfigWorkflowsEnabled(configPathForFlags)
3008
+ });
3009
+ options.workflows = { enabled: workflowsEnabled };
1525
3010
  const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
1526
3011
  options.identity = {
1527
3012
  ...options.identity,
1528
- requireSignatures,
1529
3013
  baseUrl: options.identity?.baseUrl ?? renownConfig.url
1530
3014
  };
1531
3015
  logger.info("Feature flags: @flags", JSON.stringify({
1532
3016
  DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
1533
- REQUIRE_SIGNATURES: requireSignatures
3017
+ MCP_ENABLED: options.mcp,
3018
+ PH_WORKFLOWS_ENABLED: workflowsEnabled
1534
3019
  }, null, 2));
1535
3020
  let renown = null;
1536
3021
  try {
@@ -1543,13 +3028,14 @@ const startSwitchboard = async (options = {}) => {
1543
3028
  return await initServer(serverPort, options, renown, renownConfig);
1544
3029
  } catch (e) {
1545
3030
  Sentry.captureException(e);
1546
- logger.error("App crashed: @error", e);
3031
+ if (StoredDocumentsRefusedError.isError(e)) logger.error(e.message);
3032
+ else logger.error("App crashed: @error", e);
1547
3033
  throw e;
1548
3034
  }
1549
3035
  };
1550
- if (import.meta.main) await startSwitchboard();
3036
+ if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
1551
3037
  //#endregion
1552
- export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
3038
+ export { resolveUnsupportedStoredDocuments as a, StoredDocumentsRefusedError as i, isPortAvailable as n, applySwitchboardReactorDefaults as o, startSwitchboard as r, parseForcePgVersion as s, deriveAttachmentServiceConfig as t };
1553
3039
 
1554
- //# sourceMappingURL=server-CiKkVE6g.mjs.map
1555
- //# debugId=e2c62b7b-68fd-59c2-ab82-7d035e11698b
3040
+ //# sourceMappingURL=server-D3CYuEka.mjs.map
3041
+ //# debugId=98e9dab8-b145-5eb1-8457-7a9d38dda98e