@powerhousedao/switchboard 6.2.3-dev.9 → 6.2.3-staging.0

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 (108) 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/url-signer.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  7. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  8. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  19. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  20. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  21. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  24. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  25. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  26. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  27. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  28. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  29. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  30. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  31. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  32. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  33. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  34. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  35. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  36. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  37. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  38. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  39. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  40. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  41. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  42. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  43. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  44. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  45. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  46. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  48. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  49. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  50. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  51. package/.tsbuild/vitest.config.d.ts.map +1 -1
  52. package/Auth.md +94 -0
  53. package/CHANGELOG.md +298 -0
  54. package/README.md +35 -0
  55. package/dist/index.d.mts +2 -1
  56. package/dist/index.mjs +10 -8
  57. package/dist/index.mjs.map +1 -1
  58. package/dist/install-packages.d.mts +2 -1
  59. package/dist/install-packages.mjs +2 -2
  60. package/dist/install-packages.mjs.map +1 -1
  61. package/dist/migrate.d.mts +2 -1
  62. package/dist/migrate.mjs +4 -3
  63. package/dist/migrate.mjs.map +1 -1
  64. package/dist/{server-CiKkVE6g.mjs → server-sQoWp21G.mjs} +1454 -70
  65. package/dist/server-sQoWp21G.mjs.map +1 -0
  66. package/dist/server.d.mts +103 -29
  67. package/dist/server.d.mts.map +1 -1
  68. package/dist/server.mjs +4 -5
  69. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  70. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  71. package/dist/utils.d.mts +4 -5
  72. package/dist/utils.d.mts.map +1 -1
  73. package/dist/utils.mjs +3 -3
  74. package/package.json +21 -17
  75. package/test/attachments/auth.test.ts +85 -0
  76. package/test/attachments/byte-grants.test.ts +287 -0
  77. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  78. package/test/attachments/download-target.test.ts +194 -31
  79. package/test/attachments/index.test.ts +34 -3
  80. package/test/attachments/routes-integration.test.ts +59 -4
  81. package/test/attachments/routes.test.ts +165 -23
  82. package/test/attachments/service-config.test.ts +13 -3
  83. package/test/attachments/url-signer.test.ts +105 -0
  84. package/test/boot-create-signature-policy.test.ts +108 -0
  85. package/test/boot-mcp-enabled.test.ts +121 -0
  86. package/test/boot-unsupported-stored-documents.test.ts +210 -0
  87. package/test/boot-without-renown.test.ts +81 -0
  88. package/test/boot-without-require-signatures.test.ts +74 -0
  89. package/test/boot-worker-trust-policy.test.ts +90 -0
  90. package/test/builder-defaults.test.ts +80 -0
  91. package/test/create-signature-policy.test.ts +69 -0
  92. package/test/fatal-shutdown.test.ts +102 -0
  93. package/test/mcp-flag.test.ts +56 -0
  94. package/test/renown-trust-policy.test.ts +315 -0
  95. package/test/unsupported-stored-documents.test.ts +72 -0
  96. package/test/workflow/harness.ts +289 -0
  97. package/test/workflow/oauth.test.ts +214 -0
  98. package/test/workflow/resolvers.test.ts +296 -0
  99. package/test/workflow/webhook-dedupe.test.ts +295 -0
  100. package/test/workflow/webhook-signed.test.ts +509 -0
  101. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  102. package/test/workflow/webhook-unsigned.test.ts +345 -0
  103. package/test/workflow-read-gate.test.ts +145 -0
  104. package/test/workflow-runtime.test.ts +486 -0
  105. package/tsconfig.json +6 -0
  106. package/tsdown.config.ts +3 -1
  107. package/dist/server-CiKkVE6g.mjs.map +0 -1
  108. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -1,33 +1,38 @@
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]="66287c02-682f-57d0-8260-a1005691b2bc")}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, 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, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget, parseRef } 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";
24
26
  import { ReactorGroupV1 } from "@powerhousedao/reactor-group";
25
27
  import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
28
+ import { DEFAULT_SIGNATURE_POLICY, isSignaturePolicy } from "@powerhousedao/shared/document-model";
26
29
  import { readFile } from "node:fs/promises";
27
30
  import os from "node:os";
28
31
  import { fileURLToPath } from "node:url";
29
32
  import { EnvVarProvider } from "@openfeature/env-var-provider";
30
33
  import { OpenFeature } from "@openfeature/server-sdk";
34
+ import { GraphQLError, Kind } from "graphql";
35
+ import { gql } from "graphql-tag";
31
36
  import { PGliteDialect } from "kysely-pglite-dialect";
32
37
  //#region src/pglite-version.ts
33
38
  const SUPPORTED_PG_MAJORS = [16, 17];
@@ -74,7 +79,15 @@ const ANONYMOUS_ACTOR = {
74
79
  * enabled, the request must carry a verifiable Bearer token. The handler always
75
80
  * receives an actor context: the verified bearer user when auth is enabled, or
76
81
  * the anonymous context when it is disabled. With `allowAnonymous`, a missing
77
- * bearer yields an anonymous actor with `authEnabled: true` instead of a 401.
82
+ * bearer yields an anonymous actor with `authEnabled: true` instead of a 401 —
83
+ * unless `requireAuthenticatedCaller` says the deployment refuses anonymous
84
+ * callers outright, which overrides it.
85
+ *
86
+ * With no `authService` there is no bearer to read and every caller is
87
+ * anonymous, so nothing is refused here. That combination cannot carry the
88
+ * floor: `assertRequireAuthenticatedCallerAllowed` refuses to boot with
89
+ * `REQUIRE_AUTHENTICATED_CALLER` set and no identity resolution, and identity
90
+ * resolution is what builds the service.
78
91
  */
79
92
  function requireAuth(authService, handler, options) {
80
93
  if (!authService) return (req, res, body) => handler(req, res, body, ANONYMOUS_ACTOR);
@@ -96,11 +109,13 @@ function requireAuth(authService, handler, options) {
96
109
  res.end(body);
97
110
  return;
98
111
  }
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;
112
+ if (!result.user) {
113
+ if (options?.requireAuthenticatedCaller === true || result.auth_enabled && !options?.allowAnonymous) {
114
+ res.statusCode = 401;
115
+ res.setHeader("Content-Type", "application/json");
116
+ res.end(JSON.stringify({ error: "Authentication required" }));
117
+ return;
118
+ }
104
119
  }
105
120
  await handler(req, res, body, {
106
121
  user: result.user,
@@ -118,14 +133,22 @@ function requireAuth(authService, handler, options) {
118
133
  * wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
119
134
  *
120
135
  * `allowAnonymous` is reserved for routes whose handlers make a per-document
121
- * authorization decision themselves; identity-only routes must not use it.
136
+ * authorization decision themselves; identity-only routes must not use it. It
137
+ * is also not the last word: a deployment that refuses anonymous callers
138
+ * (`REQUIRE_AUTHENTICATED_CALLER`) refuses them here too, because these routes
139
+ * are mounted on the HTTP adapter and never reach the fetch chain that carries
140
+ * that floor for GraphQL. The flag is read from the API, so every route
141
+ * mounted through here inherits it without each one remembering to.
122
142
  */
123
143
  function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
124
- api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, options));
144
+ api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, {
145
+ ...options,
146
+ requireAuthenticatedCaller: api.requireAuthenticatedCaller
147
+ }));
125
148
  }
126
149
  //#endregion
127
150
  //#region src/attachments/routes.ts
128
- const logger$1 = childLogger(["switchboard", "attachments"]);
151
+ const logger$2 = childLogger(["switchboard", "attachments"]);
129
152
  const RETRY_AFTER_SECONDS = 5;
130
153
  const HASH_PATTERN = /^[a-f0-9]{64}$/i;
131
154
  const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
@@ -150,7 +173,7 @@ function statusForError(err) {
150
173
  function sendErrorFromException(res, err) {
151
174
  const status = statusForError(err);
152
175
  if (status >= 500) {
153
- logger$1.error("Attachment route error: @error", err);
176
+ logger$2.error("Attachment route error: @error", err);
154
177
  sendError(res, status, "Internal error");
155
178
  return;
156
179
  }
@@ -279,24 +302,27 @@ function makeUploadHandler(attachments) {
279
302
  }
280
303
  };
281
304
  }
282
- function makeDownloadHandler(attachments) {
283
- return async (req, res) => {
305
+ function makeDownloadHandler(attachments, attachmentAccess, urlSigner) {
306
+ return async (req, res, _body, actor) => {
284
307
  const hash = extractParam(req, "hash");
285
308
  if (!hash || !HASH_PATTERN.test(hash)) {
286
309
  sendError(res, 400, "Invalid attachment hash");
287
310
  return;
288
311
  }
312
+ const canonicalHash = hash.toLowerCase();
313
+ const grant = await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner);
314
+ if (!admitGrant(res, grant)) return;
289
315
  const controller = new AbortController();
290
316
  req.once("close", () => controller.abort());
291
- const canonicalHash = hash.toLowerCase();
292
317
  let response;
293
318
  try {
294
- response = await attachments.store.get(canonicalHash, controller.signal);
319
+ response = await attachments.store.get(canonicalHash, controller.signal, grant.documentId);
295
320
  } catch (err) {
296
321
  if (err instanceof AttachmentPending) {
297
322
  res.statusCode = 202;
323
+ res.setHeader("Cache-Control", "private");
298
324
  res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
299
- res.setHeader("Attachment-Pending", JSON.stringify({
325
+ res.setHeader("Attachment-Pending", buildPendingHeader({
300
326
  expiresAtUtc: err.expiresAtUtc,
301
327
  ...err.metadata ?? {}
302
328
  }));
@@ -308,6 +334,7 @@ function makeDownloadHandler(attachments) {
308
334
  }
309
335
  const { header, body } = response;
310
336
  res.statusCode = 200;
337
+ res.setHeader("Cache-Control", "private");
311
338
  res.setHeader("Content-Type", header.mimeType);
312
339
  res.setHeader("Content-Length", String(header.sizeBytes));
313
340
  res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
@@ -315,8 +342,14 @@ function makeDownloadHandler(attachments) {
315
342
  Readable.fromWeb(body).pipe(res);
316
343
  };
317
344
  }
345
+ function toAsciiJson(value) {
346
+ return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
347
+ }
348
+ function buildPendingHeader(pending) {
349
+ return toAsciiJson(pending);
350
+ }
318
351
  function buildMetadataHeader(header) {
319
- return JSON.stringify({
352
+ return toAsciiJson({
320
353
  mimeType: header.mimeType,
321
354
  fileName: header.fileName,
322
355
  sizeBytes: header.sizeBytes,
@@ -325,14 +358,15 @@ function buildMetadataHeader(header) {
325
358
  lastAccessedAtUtc: header.lastAccessedAtUtc
326
359
  });
327
360
  }
328
- function makeStatHandler(attachments) {
329
- return async (req, res) => {
361
+ function makeStatHandler(attachments, attachmentAccess, urlSigner) {
362
+ return async (req, res, _body, actor) => {
330
363
  const hash = extractParam(req, "hash");
331
364
  if (!hash || !HASH_PATTERN.test(hash)) {
332
365
  sendError(res, 400, "Invalid attachment hash");
333
366
  return;
334
367
  }
335
368
  const canonicalHash = hash.toLowerCase();
369
+ if (!admitGrant(res, await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner))) return;
336
370
  let header;
337
371
  try {
338
372
  header = await attachments.store.stat(canonicalHash);
@@ -340,10 +374,11 @@ function makeStatHandler(attachments) {
340
374
  sendErrorFromException(res, err);
341
375
  return;
342
376
  }
377
+ res.setHeader("Cache-Control", "private");
343
378
  if (header.status === "pending") {
344
379
  res.statusCode = 202;
345
380
  res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
346
- res.setHeader("Attachment-Pending", JSON.stringify({
381
+ res.setHeader("Attachment-Pending", buildPendingHeader({
347
382
  expiresAtUtc: header.expiresAtUtc,
348
383
  mimeType: header.mimeType,
349
384
  fileName: header.fileName,
@@ -395,7 +430,37 @@ function extractParam(req, name) {
395
430
  }
396
431
  const MAX_DOCUMENT_ID_LEN = 512;
397
432
  const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
398
- const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 10080 * 60;
433
+ const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 604800;
434
+ /**
435
+ * How long a download target may live, by default.
436
+ *
437
+ * The authorization behind a presigned URL is decided once, when it is issued,
438
+ * and the URL keeps working until it expires however the document's policy
439
+ * changes in between. A week of that is a revocation that does not revoke: an
440
+ * account deactivated, a company reassigned or a group left, and the bytes stay
441
+ * fetchable by anyone holding the link. Minutes are enough to open a file;
442
+ * days are only enough to lose control of it.
443
+ *
444
+ * A deployment that needs longer sets ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS,
445
+ * still bounded by the signing ceiling above.
446
+ */
447
+ const DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS = 300;
448
+ /**
449
+ * The ceiling in force, read per call so a deployment can set it without the
450
+ * value being frozen at import — which is also what lets a test cover it.
451
+ * Anything unparseable falls back to the default rather than to the maximum:
452
+ * a typo must not quietly widen the window it was meant to narrow.
453
+ */
454
+ function downloadTargetTtlCapSeconds() {
455
+ const configured = process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
456
+ if (configured === void 0) return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
457
+ const parsed = Number(configured);
458
+ if (!Number.isInteger(parsed) || parsed <= 0) {
459
+ 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}`);
460
+ return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
461
+ }
462
+ return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
463
+ }
399
464
  /**
400
465
  * Returns the single `documentId` query value, or null when it is missing,
401
466
  * duplicated, blank, or oversized. Validation happens before authorization so
@@ -416,29 +481,95 @@ function extractSingleDocumentId(req) {
416
481
  return value;
417
482
  }
418
483
  /**
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.
484
+ * Returns the target TTL in seconds, or "invalid" when the caller's own value
485
+ * is malformed (duplicated, non-integer, or non-positive).
486
+ *
487
+ * The cap applies whether or not the caller asked for a lifetime: omitting
488
+ * `expiresIn` used to fall through to whatever the storage backend was
489
+ * configured with, which would make the ceiling something a client opts out of
490
+ * by saying nothing.
422
491
  */
423
492
  function extractExpiresIn(req) {
424
- if (!req.url) return void 0;
493
+ const cap = downloadTargetTtlCapSeconds();
494
+ if (!req.url) return cap;
425
495
  let url;
426
496
  try {
427
497
  url = new URL(req.url, "http://switchboard.invalid");
428
498
  } catch {
429
- return;
499
+ return cap;
430
500
  }
431
501
  const values = url.searchParams.getAll("expiresIn");
432
- if (values.length === 0) return void 0;
502
+ if (values.length === 0) return cap;
433
503
  if (values.length > 1) return "invalid";
434
504
  const parsed = Number(values[0]);
435
505
  if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
436
- return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
506
+ return Math.min(parsed, cap);
507
+ }
508
+ function singleQueryValue(params, name) {
509
+ const values = params.getAll(name);
510
+ return values.length === 1 ? values[0] : null;
511
+ }
512
+ /**
513
+ * Byte routes serve only under a grant: a signed URL minted by the
514
+ * download-target route, or a documentId the caller may read the attachment
515
+ * through. A signed request is judged by its signature alone.
516
+ */
517
+ async function resolveByteGrant(req, hash, actor, attachmentAccess, urlSigner) {
518
+ let params;
519
+ try {
520
+ params = new URL(req.url ?? "", "http://switchboard.invalid").searchParams;
521
+ } catch {
522
+ return { kind: "denied" };
523
+ }
524
+ const documentId = extractSingleDocumentId(req);
525
+ if (documentId === null) return { kind: "denied" };
526
+ if (params.has("signature") || params.has("expires")) {
527
+ const signature = singleQueryValue(params, "signature");
528
+ const expires = singleQueryValue(params, "expires");
529
+ if (!urlSigner || signature === null || expires === null) return { kind: "denied" };
530
+ return urlSigner.verify(hash, documentId, expires, signature) ? {
531
+ kind: "granted",
532
+ documentId
533
+ } : { kind: "denied" };
534
+ }
535
+ let decision;
536
+ try {
537
+ decision = await attachmentAccess.canReadAttachment({
538
+ documentId,
539
+ attachmentRef: createRef(hash),
540
+ userAddress: actor?.user?.address,
541
+ appKey: actor?.user?.appKey
542
+ });
543
+ } catch (err) {
544
+ logger$2.error("Attachment access decision failed: @error", err);
545
+ return { kind: "error" };
546
+ }
547
+ if (decision.kind === "projection-unavailable") return { kind: "unavailable" };
548
+ if (decision.kind === "denied") return { kind: "denied" };
549
+ return {
550
+ kind: "granted",
551
+ documentId: decision.documentId
552
+ };
553
+ }
554
+ /** Answers a refused grant; true when the request may proceed. */
555
+ function admitGrant(res, grant) {
556
+ switch (grant.kind) {
557
+ case "granted": return true;
558
+ case "denied":
559
+ sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
560
+ return false;
561
+ case "unavailable":
562
+ sendError(res, 503, "Attachment downloads are temporarily unavailable");
563
+ return false;
564
+ case "error":
565
+ sendError(res, 500, "Internal error");
566
+ return false;
567
+ }
437
568
  }
438
569
  /**
439
570
  * 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.
571
+ * filesystem `switchboard` download targets that point back at the signed
572
+ * byte route.
442
573
  */
443
574
  function requestBaseUrl(req) {
444
575
  const forwardedProto = req.headers["x-forwarded-proto"];
@@ -448,7 +579,7 @@ function requestBaseUrl(req) {
448
579
  if (!host) return null;
449
580
  return `${proto}://${host}`;
450
581
  }
451
- function makeDownloadTargetHandler(attachments, attachmentAccess) {
582
+ function makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner) {
452
583
  return async (req, res, _body, actor) => {
453
584
  res.setHeader("Cache-Control", "no-store");
454
585
  const hash = extractParam(req, "hash");
@@ -472,10 +603,11 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
472
603
  decision = await attachmentAccess.canReadAttachment({
473
604
  documentId,
474
605
  attachmentRef: createRef(canonicalHash),
475
- userAddress: actor?.user?.address
606
+ userAddress: actor?.user?.address,
607
+ appKey: actor?.user?.appKey
476
608
  });
477
609
  } catch (err) {
478
- logger$1.error("Attachment access decision failed: @error", err);
610
+ logger$2.error("Attachment access decision failed: @error", err);
479
611
  sendError(res, 500, "Internal error");
480
612
  return;
481
613
  }
@@ -495,7 +627,7 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
495
627
  sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
496
628
  return;
497
629
  }
498
- logger$1.error("Attachment metadata lookup failed: @error", err);
630
+ logger$2.error("Attachment metadata lookup failed: @error", err);
499
631
  sendError(res, 500, "Internal error");
500
632
  return;
501
633
  }
@@ -511,17 +643,23 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
511
643
  return;
512
644
  }
513
645
  else {
646
+ if (!urlSigner) {
647
+ sendError(res, 503, "Attachment download signing is not configured");
648
+ return;
649
+ }
514
650
  const base = requestBaseUrl(req);
515
651
  if (!base) {
516
652
  sendError(res, 500, "Internal error");
517
653
  return;
518
654
  }
655
+ const signed = urlSigner.sign(canonicalHash, decision.documentId, expiresIn);
519
656
  try {
520
657
  target = parseAttachmentDownloadTarget({
521
658
  kind: "switchboard",
522
659
  method: "GET",
523
- url: `${base}/attachments/${canonicalHash}`,
524
- headers: {}
660
+ url: `${base}/attachments/${canonicalHash}?${signed.query}`,
661
+ headers: {},
662
+ expiresAtUtc: signed.expiresAtUtc
525
663
  });
526
664
  } catch {
527
665
  sendError(res, 500, "Internal error");
@@ -532,16 +670,82 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
532
670
  };
533
671
  }
534
672
  //#endregion
673
+ //#region src/attachments/url-signer.ts
674
+ const ATTACHMENT_URL_SIGNING_SECRET_ENV = "PH_ATTACHMENT_URL_SIGNING_SECRET";
675
+ const MIN_SECRET_LENGTH = 32;
676
+ const EXPIRES_PATTERN = /^[1-9]\d{0,15}$/;
677
+ const SIGNATURE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
678
+ var AttachmentUrlSigner = class {
679
+ secret;
680
+ now;
681
+ constructor(secret, now = Date.now) {
682
+ this.secret = secret;
683
+ this.now = now;
684
+ }
685
+ sign(hash, documentId, ttlSeconds) {
686
+ const expires = Math.floor(this.now() / 1e3) + ttlSeconds;
687
+ return {
688
+ query: new URLSearchParams({
689
+ documentId,
690
+ expires: String(expires),
691
+ signature: this.mac(hash, documentId, String(expires))
692
+ }).toString(),
693
+ expiresAtUtc: (/* @__PURE__ */ new Date(expires * 1e3)).toISOString()
694
+ };
695
+ }
696
+ verify(hash, documentId, expires, signature) {
697
+ if (!EXPIRES_PATTERN.test(expires) || !SIGNATURE_PATTERN.test(signature)) return false;
698
+ if (Number(expires) * 1e3 <= this.now()) return false;
699
+ const expected = Buffer.from(this.mac(hash, documentId, expires));
700
+ const presented = Buffer.from(signature);
701
+ return expected.length === presented.length && timingSafeEqual(expected, presented);
702
+ }
703
+ mac(hash, documentId, expires) {
704
+ return createHmac("sha256", this.secret).update(JSON.stringify([
705
+ "attachment-download/v1",
706
+ hash,
707
+ documentId,
708
+ expires
709
+ ])).digest("base64url");
710
+ }
711
+ };
712
+ function resolveAttachmentUrlSigning(env = process.env) {
713
+ const secret = env[ATTACHMENT_URL_SIGNING_SECRET_ENV];
714
+ if (secret !== void 0) {
715
+ 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`);
716
+ return {
717
+ status: "configured",
718
+ signer: new AttachmentUrlSigner(secret)
719
+ };
720
+ }
721
+ if (env.NODE_ENV === "production") return { status: "unconfigured" };
722
+ return {
723
+ status: "ephemeral",
724
+ signer: new AttachmentUrlSigner(randomBytes(32))
725
+ };
726
+ }
727
+ //#endregion
535
728
  //#region src/attachments/index.ts
536
- function registerAttachmentRoutes(api) {
537
- const { attachments } = api;
729
+ const logger$1 = childLogger(["switchboard", "attachments"]);
730
+ function registerAttachmentRoutes(api, options = {}) {
731
+ const { attachments, attachmentAccess } = api;
732
+ const urlSigner = options.urlSigner === void 0 ? signerFromEnvironment(attachments.backend?.kind ?? "filesystem") : options.urlSigner;
538
733
  mountAuthenticatedNodeRoute(api, "POST", "/attachments/reservations", makeReserveHandler(attachments));
539
734
  mountAuthenticatedNodeRoute(api, "GET", "/attachments/reservations/:reservationId", makeGetReservationHandler(attachments));
540
735
  mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
541
736
  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));
737
+ mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
738
+ mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
739
+ mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
740
+ }
741
+ function signerFromEnvironment(backend) {
742
+ const signing = resolveAttachmentUrlSigning();
743
+ if (signing.status === "unconfigured") {
744
+ if (backend === "filesystem") logger$1.error(`${ATTACHMENT_URL_SIGNING_SECRET_ENV} is not set: filesystem attachment download targets are refused until it is`);
745
+ return null;
746
+ }
747
+ 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`);
748
+ return signing.signer;
545
749
  }
546
750
  //#endregion
547
751
  //#region src/attachment-reference-read-model.mts
@@ -584,6 +788,43 @@ async function registerAttachmentReferenceReadModelOnModule(clientModule, attach
584
788
  return { status: "available" };
585
789
  }
586
790
  //#endregion
791
+ //#region src/fatal-shutdown.mts
792
+ const FORCED_EXIT_MS = 15e3;
793
+ const installed = /* @__PURE__ */ new WeakSet();
794
+ /**
795
+ * Sends an uncaught exception or unhandled rejection through the SIGTERM
796
+ * shutdown that `withSignalHandlers()` installs, so the reactor and read-model
797
+ * PGlite stores write their snapshots before the process exits with code 1.
798
+ * Node's default is to exit at once, which drops every write AtomicNodeFs has
799
+ * not flushed yet. A shutdown that hangs is cut off after FORCED_EXIT_MS.
800
+ *
801
+ * A rejection is fatal only when this is its sole listener: Node exits on an
802
+ * unhandled rejection only when nothing listens for it, and Sentry's
803
+ * listener deliberately keeps the process running.
804
+ */
805
+ function installFatalErrorShutdown(logger, proc = process) {
806
+ if (installed.has(proc)) return;
807
+ installed.add(proc);
808
+ const realExit = proc.exit.bind(proc);
809
+ let shuttingDown = false;
810
+ const onFatal = (kind, err) => {
811
+ logger.error(`${kind}: @error`, err);
812
+ if (shuttingDown) return;
813
+ shuttingDown = true;
814
+ proc.exitCode = 1;
815
+ setTimeout(() => {
816
+ logger.error("Shutdown after fatal error timed out; exiting");
817
+ realExit(1);
818
+ }, FORCED_EXIT_MS).unref();
819
+ proc.kill(proc.pid, "SIGTERM");
820
+ };
821
+ proc.on("uncaughtException", (err) => onFatal("Uncaught exception", err));
822
+ proc.on("unhandledRejection", (reason) => {
823
+ if (proc.listenerCount("unhandledRejection") > 1) return;
824
+ onFatal("Unhandled rejection", reason);
825
+ });
826
+ }
827
+ //#endregion
587
828
  //#region src/builder-defaults.mts
588
829
  /**
589
830
  * Apply switchboard's standard configuration to a reactor + client builder
@@ -617,7 +858,13 @@ function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options
617
858
  });
618
859
  if (options.documentModelLoader) reactorBuilder.withDocumentModelLoader(options.documentModelLoader);
619
860
  if (options.logger) reactorBuilder.withLogger(options.logger);
620
- if (options.signer) clientBuilder.withSigner(options.signer);
861
+ if (options.signer) {
862
+ clientBuilder.withSigner(options.signer);
863
+ reactorBuilder.withSigner(options.signer.signer, options.signer.workerSigner);
864
+ }
865
+ if (options.createSignaturePolicy) clientBuilder.withCreateSignaturePolicy(options.createSignaturePolicy);
866
+ const trust = options.trustPolicy ?? options.signer;
867
+ if (trust?.trustPolicy) reactorBuilder.withTrustPolicy(trust.trustPolicy, trust.workerTrustPolicy);
621
868
  }
622
869
  //#endregion
623
870
  //#region src/worker-pool.mts
@@ -865,6 +1112,914 @@ async function initFeatureFlags() {
865
1112
  return OpenFeature.getClient();
866
1113
  }
867
1114
  //#endregion
1115
+ //#region src/mcp-flag.mts
1116
+ /** The env var and OpenFeature flag key that can keep the MCP server unmounted. */
1117
+ const MCP_ENABLED = "MCP_ENABLED";
1118
+ /** Whether to mount the MCP server at `/mcp`. On unless the host passes
1119
+ * `mcp: false` or MCP_ENABLED is "false": the env var can turn a host's MCP
1120
+ * off, never on, so a deployment that serves no MCP client can close the
1121
+ * endpoint without the bin growing a flag of its own. */
1122
+ async function resolveMcpEnabled({ featureFlags, option }) {
1123
+ if (option === false) return false;
1124
+ return featureFlags.getBooleanValue(MCP_ENABLED, true);
1125
+ }
1126
+ //#endregion
1127
+ //#region src/workflow/oauth-callback.ts
1128
+ const OAUTH_CALLBACK_PATH = "oauth/callback";
1129
+ function escapeHtml(text) {
1130
+ return text.replace(/[&<>"']/g, (char) => ({
1131
+ "&": "&amp;",
1132
+ "<": "&lt;",
1133
+ ">": "&gt;",
1134
+ "\"": "&quot;",
1135
+ "'": "&#39;"
1136
+ })[char]);
1137
+ }
1138
+ function resultPage(ok, detail) {
1139
+ const title = ok ? "Connected" : "Sign-in failed";
1140
+ return `<!doctype html>
1141
+ <html><head><meta charset="utf-8"><title>${title}</title>
1142
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1143
+ <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>
1144
+ </head><body><main><h1>${title}</h1><p>${ok ? "You can close this window." : escapeHtml(detail ?? "The sign-in did not finish.")}</p></main>
1145
+ ${ok ? "<script>setTimeout(function(){window.close()},600)<\/script>" : ""}
1146
+ </body></html>`;
1147
+ }
1148
+ function registerOAuthCallback(http, runtime) {
1149
+ return http.get(OAUTH_CALLBACK_PATH, { auth: "public" }, async (request) => {
1150
+ const params = new URL(request.url).searchParams;
1151
+ const state = params.get("state");
1152
+ const result = state ? await runtime.completeOAuth({
1153
+ state,
1154
+ code: params.get("code") ?? void 0,
1155
+ error: params.get("error") ?? void 0,
1156
+ errorDescription: params.get("error_description") ?? void 0
1157
+ }) : {
1158
+ ok: false,
1159
+ detail: "The provider sent no state",
1160
+ returnUrl: null
1161
+ };
1162
+ if (result.returnUrl && state) {
1163
+ const back = new URL(result.returnUrl);
1164
+ back.searchParams.set("ph_oauth", state);
1165
+ return new Response(null, {
1166
+ status: 303,
1167
+ headers: {
1168
+ location: back.href,
1169
+ "cache-control": "no-store"
1170
+ }
1171
+ });
1172
+ }
1173
+ return new Response(resultPage(result.ok, result.detail), {
1174
+ status: result.ok ? 200 : 400,
1175
+ headers: {
1176
+ "content-type": "text/html; charset=utf-8",
1177
+ "cache-control": "no-store",
1178
+ "referrer-policy": "no-referrer"
1179
+ }
1180
+ });
1181
+ });
1182
+ }
1183
+ function callbackUrlOf(http) {
1184
+ return `${http.baseUrl.replace(/\/+$/, "")}/${OAUTH_CALLBACK_PATH}`;
1185
+ }
1186
+ //#endregion
1187
+ //#region src/workflow/resolvers.ts
1188
+ function blockRef(input) {
1189
+ if (input.kind !== "action" && input.kind !== "trigger") throw new GraphQLError(`A block's kind is "action" or "trigger", got "${input.kind}"`);
1190
+ return {
1191
+ pieceName: input.pieceName,
1192
+ pieceVersion: input.pieceVersion,
1193
+ name: input.name,
1194
+ kind: input.kind
1195
+ };
1196
+ }
1197
+ async function cursorAware(call) {
1198
+ try {
1199
+ return await call();
1200
+ } catch (error) {
1201
+ if (error instanceof Error && error.name === "InvalidRunCursorError") throw new GraphQLError(error.message, { extensions: { code: "BAD_USER_INPUT" } });
1202
+ throw error;
1203
+ }
1204
+ }
1205
+ function selectsStepData(selections, path) {
1206
+ let level = selections;
1207
+ for (const name of [...path, "steps"]) {
1208
+ const next = [];
1209
+ for (const set of level) for (const selection of set.selections) {
1210
+ if (selection.kind !== Kind.FIELD) return true;
1211
+ if (selection.name.value === name && selection.selectionSet) next.push(selection.selectionSet);
1212
+ }
1213
+ if (next.length === 0) return false;
1214
+ level = next;
1215
+ }
1216
+ return level.some((set) => set.selections.some((selection) => selection.kind !== Kind.FIELD || selection.name.value === "input" || selection.name.value === "output"));
1217
+ }
1218
+ function readsStepData(info, path) {
1219
+ if (!info) return true;
1220
+ return selectsStepData(info.fieldNodes.flatMap((node) => node.selectionSet ? [node.selectionSet] : []), path);
1221
+ }
1222
+ function requireAdmin(authorizationService, ctx) {
1223
+ if (!authorizationService.isSupremeAdmin(ctx.user?.address)) throw new GraphQLError("Admin access required");
1224
+ }
1225
+ async function syncAware(call) {
1226
+ try {
1227
+ return await call();
1228
+ } catch (error) {
1229
+ if (error instanceof Error && error.name === "WorkflowSyncingError") throw new GraphQLError(error.message, { extensions: {
1230
+ code: "WORKFLOW_SYNCING",
1231
+ retryable: true
1232
+ } });
1233
+ throw error;
1234
+ }
1235
+ }
1236
+ function parseJson(value) {
1237
+ if (value === null) return null;
1238
+ try {
1239
+ return JSON.parse(value);
1240
+ } catch {
1241
+ return value;
1242
+ }
1243
+ }
1244
+ function toStepRecord(row) {
1245
+ return {
1246
+ stepId: row.step_id,
1247
+ stepKey: row.step_key,
1248
+ pieceName: row.piece_name,
1249
+ blockName: row.block_name,
1250
+ status: row.status,
1251
+ input: parseJson(row.input),
1252
+ output: parseJson(row.output),
1253
+ port: row.port,
1254
+ error: row.error,
1255
+ startedAt: row.started_at,
1256
+ endedAt: row.ended_at,
1257
+ pieceVersion: row.piece_version,
1258
+ pieceSource: row.piece_source,
1259
+ versionMatch: row.version_match,
1260
+ versionNote: row.version_note
1261
+ };
1262
+ }
1263
+ function toRunRecord(row, steps) {
1264
+ return {
1265
+ id: row.id,
1266
+ workflowId: row.workflow_id,
1267
+ workflowName: row.workflow_name,
1268
+ workflowVersion: row.workflow_version,
1269
+ triggerKind: row.trigger_kind,
1270
+ triggerPayload: parseJson(row.trigger_payload),
1271
+ status: row.status,
1272
+ error: row.error,
1273
+ startedAt: row.started_at,
1274
+ endedAt: row.ended_at,
1275
+ rerunOf: row.rerun_of,
1276
+ warnings: row.warnings,
1277
+ warningNotes: parseJson(row.warning_notes) ?? [],
1278
+ steps: steps.map(toStepRecord)
1279
+ };
1280
+ }
1281
+ function isAbsolute(url) {
1282
+ return /^https?:\/\//i.test(url);
1283
+ }
1284
+ function redirectUriFor(routing, requested) {
1285
+ if (!routing) throw new GraphQLError("This host serves no OAuth2 callback");
1286
+ if (isAbsolute(routing.callbackUrl)) return routing.callbackUrl;
1287
+ if (!requested) throw new GraphQLError("This host does not know its public URL; pass redirectUri");
1288
+ let url;
1289
+ try {
1290
+ url = new URL(requested);
1291
+ } catch {
1292
+ throw new GraphQLError(`"${requested}" is not a URL`);
1293
+ }
1294
+ 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}`);
1295
+ return url.href;
1296
+ }
1297
+ function returnUrlFor(requested, ctx) {
1298
+ if (!requested) return void 0;
1299
+ const origin = ctx.headers.origin;
1300
+ let url;
1301
+ try {
1302
+ url = new URL(requested);
1303
+ } catch {
1304
+ url = void 0;
1305
+ }
1306
+ if (!url || !origin || url.origin !== origin) throw new GraphQLError("returnUrl must be on the requesting page's origin");
1307
+ return url.href;
1308
+ }
1309
+ const getResolvers = (runtime, authorizationService, oauth) => {
1310
+ return {
1311
+ Query: { workflowRuntime: () => ({}) },
1312
+ WorkflowRuntimeQueries: {
1313
+ health: () => "ok",
1314
+ blockDescriptor: (_parent, args) => runtime.blockDescriptor(blockRef(args.block)),
1315
+ blockOptions: (_parent, args, ctx) => runtime.blockOptions(blockRef(args.block), args.propName, args.input, args.connectionId ?? void 0, ctx, args.searchValue ?? void 0),
1316
+ pieceCatalog: () => runtime.pieceCatalog(),
1317
+ pieceActions: (_parent, args) => runtime.pieceActions(args.packageName, args.version ?? void 0),
1318
+ pieceTriggers: (_parent, args) => runtime.pieceTriggers(args.packageName, args.version ?? void 0),
1319
+ blockOutputTree: (_parent, args) => runtime.blockOutputTree(blockRef(args.block), args.config),
1320
+ stepOutputTree: (_parent, args, ctx) => runtime.stepOutputTree(args.workflowId, args.stepId, ctx),
1321
+ blockResolutions: (_parent, args, ctx) => runtime.blockResolutions(args.workflowId, ctx),
1322
+ pieceDetail: (_parent, args) => runtime.pieceDetail(args.packageName, args.version ?? void 0),
1323
+ searchBlocks: (_parent, args) => runtime.searchBlocks(args.query, args.limit ?? void 0),
1324
+ connections: (_parent, _args, ctx) => runtime.connections(ctx),
1325
+ webhookEndpoint: (_parent, args, ctx) => syncAware(() => runtime.webhookEndpoint(args.workflowId, ctx, { driveId: args.driveId ?? void 0 })),
1326
+ secret: async (_parent, args) => {
1327
+ try {
1328
+ return await (await runtime.secrets()).stat(args.ref);
1329
+ } catch {
1330
+ return null;
1331
+ }
1332
+ },
1333
+ secrets: async () => (await runtime.secrets()).list(),
1334
+ oauthRedirectUri: () => oauth?.callbackUrl ?? null,
1335
+ oauthAttempt: (_parent, args, ctx) => runtime.oauthAttempt(args.state, ctx),
1336
+ triggerStates: async (_parent, _args, ctx) => (await runtime.triggerStates(ctx)).map((row) => ({
1337
+ workflowId: row.workflow_id,
1338
+ pieceName: row.piece_name,
1339
+ triggerName: row.trigger_name,
1340
+ status: row.status,
1341
+ intervalMs: row.interval_ms,
1342
+ nextPollAt: row.next_poll_at,
1343
+ lastPollAt: row.last_poll_at,
1344
+ lastError: row.last_error,
1345
+ consecutiveFailures: row.consecutive_failures,
1346
+ nextRenewAt: row.next_renew_at,
1347
+ renewError: row.renew_error,
1348
+ renewFailures: row.renew_failures,
1349
+ pieceVersion: row.piece_version,
1350
+ pieceSource: row.piece_source,
1351
+ versionMatch: row.version_match,
1352
+ versionNote: row.version_note
1353
+ })),
1354
+ runs: async (_parent, args, ctx, info) => (await runtime.runs({
1355
+ ...args,
1356
+ withStepData: readsStepData(info, [])
1357
+ }, ctx)).map((record) => toRunRecord(record.row, record.steps)),
1358
+ runsPage: async (_parent, args, ctx, info) => {
1359
+ const { paging, ...scope } = args;
1360
+ const page = await cursorAware(() => runtime.runsPage({
1361
+ ...scope,
1362
+ limit: paging?.limit ?? void 0,
1363
+ cursor: paging?.cursor ?? null,
1364
+ withStepData: readsStepData(info, ["items"])
1365
+ }, ctx));
1366
+ return {
1367
+ items: page.records.map((record) => toRunRecord(record.row, record.steps)),
1368
+ hasNextPage: page.hasNextPage,
1369
+ hasPreviousPage: Boolean(paging?.cursor),
1370
+ cursor: page.cursor
1371
+ };
1372
+ },
1373
+ run: async (_parent, args, ctx) => {
1374
+ const record = await runtime.run(args.id, ctx);
1375
+ return record ? toRunRecord(record.row, record.steps) : null;
1376
+ }
1377
+ },
1378
+ Mutation: { workflowRuntime: () => ({}) },
1379
+ WorkflowRuntimeMutations: {
1380
+ fire: (_parent, args, ctx) => runtime.fire(args.workflowId, args.payload, "manual", void 0, ctx),
1381
+ testTrigger: (_parent, args, ctx) => syncAware(() => runtime.testTrigger(args.workflowId, ctx, {
1382
+ ...args.payload !== void 0 ? { payload: args.payload } : {},
1383
+ ...typeof args.timeoutSeconds === "number" ? { timeoutMs: args.timeoutSeconds * 1e3 } : {},
1384
+ driveId: args.driveId ?? void 0
1385
+ })),
1386
+ cancelTriggerTest: (_parent, args, ctx) => runtime.cancelTriggerTestFor(args.workflowId, ctx),
1387
+ testStep: (_parent, args, ctx) => syncAware(() => runtime.testStep(args.workflowId, args.stepId, ctx, { driveId: args.driveId ?? void 0 })),
1388
+ rerun: (_parent, args, ctx) => runtime.rerun(args.runId, ctx),
1389
+ createSecret: async (_parent, args, ctx) => {
1390
+ requireAdmin(authorizationService, ctx);
1391
+ return (await runtime.secrets()).create({
1392
+ value: args.value,
1393
+ label: args.label ?? void 0
1394
+ });
1395
+ },
1396
+ rotateSecret: async (_parent, args, ctx) => {
1397
+ requireAdmin(authorizationService, ctx);
1398
+ return (await runtime.secrets()).rotate(args.ref, args.value);
1399
+ },
1400
+ deleteSecret: async (_parent, args, ctx) => {
1401
+ requireAdmin(authorizationService, ctx);
1402
+ await (await runtime.secrets()).delete(args.ref);
1403
+ return true;
1404
+ },
1405
+ checkConnection: (_parent, args, ctx) => runtime.checkConnection(args.connectionId, ctx),
1406
+ startOAuth: (_parent, args, ctx) => {
1407
+ requireAdmin(authorizationService, ctx);
1408
+ const returnUrl = returnUrlFor(args.returnUrl, ctx);
1409
+ return runtime.startOAuth(args.connectionId, ctx, {
1410
+ redirectUri: redirectUriFor(oauth, args.redirectUri),
1411
+ ...returnUrl ? { returnUrl } : {}
1412
+ });
1413
+ }
1414
+ }
1415
+ };
1416
+ };
1417
+ //#endregion
1418
+ //#region src/workflow/schema.ts
1419
+ const schema = gql`
1420
+ """
1421
+ WorkflowRuntime Queries
1422
+ """
1423
+ type WorkflowRuntimeQueries {
1424
+ health: String!
1425
+ """
1426
+ Persisted runs, newest first. Scope them to one workflow, or to every
1427
+ workflow a drive holds; workflowId wins when both are given.
1428
+ """
1429
+ runs(
1430
+ workflowId: String
1431
+ driveId: String
1432
+ limit: Int
1433
+ "Trigger kinds to leave out, e.g. test."
1434
+ excludeTriggerKinds: [String!]
1435
+ ): [WorkflowRunRecord!]!
1436
+ """
1437
+ The same listing a page at a time, newest journaled first. Pass a page's
1438
+ cursor back in paging to read the next; a page is short only when
1439
+ hasNextPage is false, or when the caller could read none of a long
1440
+ stretch of runs. A run that starts between pages keeps its place.
1441
+ """
1442
+ runsPage(
1443
+ workflowId: String
1444
+ driveId: String
1445
+ excludeTriggerKinds: [String!]
1446
+ paging: WorkflowRunsPagingInput
1447
+ ): WorkflowRunResultPage!
1448
+ run(id: String!): WorkflowRunRecord
1449
+ """
1450
+ The descriptor (props, auth, ports) of one action or trigger, as the
1451
+ version that runs it describes it; triggers come back under "trigger".
1452
+ """
1453
+ blockDescriptor(block: BlockInput!): Unknown
1454
+ """
1455
+ Resolves a dynamic prop against the current config values: a DROPDOWN
1456
+ yields { options, placeholder, disabled }, a DYNAMIC prop yields the
1457
+ resolved sub-property descriptor list.
1458
+ """
1459
+ blockOptions(
1460
+ block: BlockInput!
1461
+ propName: String!
1462
+ input: Unknown
1463
+ connectionId: String
1464
+ "What the author typed, for a DROPDOWN declared with refreshOnSearch."
1465
+ searchValue: String
1466
+ ): Unknown
1467
+ """
1468
+ Every piece this reactor offers: the core piece, its package pieces, and
1469
+ the pieces the registry and Activepieces publish.
1470
+ """
1471
+ pieceCatalog: Unknown
1472
+ """
1473
+ A piece's actions at one version; pin a step to that version.
1474
+ """
1475
+ pieceActions(
1476
+ packageName: String!
1477
+ "That version; the installed or latest one when omitted."
1478
+ version: String
1479
+ ): Unknown
1480
+ """
1481
+ A piece's triggers at one version; pin a trigger to that version.
1482
+ """
1483
+ pieceTriggers(
1484
+ packageName: String!
1485
+ "That version; the installed or latest one when omitted."
1486
+ version: String
1487
+ ): Unknown
1488
+ """
1489
+ Full piece detail (PieceMetadataModel-shaped), verbatim from the cloud API.
1490
+ """
1491
+ pieceDetail(
1492
+ packageName: String!
1493
+ "That version; the installed or latest one when omitted."
1494
+ version: String
1495
+ ): Unknown
1496
+ """
1497
+ Action/trigger name search across the catalog. The index builds lazily on
1498
+ first use; poll while status is "indexing".
1499
+ """
1500
+ searchBlocks(query: String!, limit: Int): BlockSearchResult!
1501
+ """
1502
+ Health of every registered piece trigger (poll schedule, errors).
1503
+ """
1504
+ triggerStates: [TriggerStateRecord!]!
1505
+ """
1506
+ Authored output shape of a block (SDL / outputSchema / sampleData).
1507
+ """
1508
+ blockOutputTree(block: BlockInput!, config: Unknown): Unknown
1509
+ """
1510
+ Output shape of a draft step or trigger: its latest test output
1511
+ (source "test", with sample and testedAt) when tested, else as blockOutputTree.
1512
+ """
1513
+ stepOutputTree(workflowId: String!, stepId: String!): Unknown
1514
+ """
1515
+ Every block of the draft (trigger first) as this reactor resolves it:
1516
+ which piece version runs, from where, and how it matches the pin.
1517
+ """
1518
+ blockResolutions(workflowId: String!): [BlockResolutionRecord!]!
1519
+ """
1520
+ Every powerhouse/connection document, for connection pickers.
1521
+ """
1522
+ connections: [ConnectionRecord!]!
1523
+ """
1524
+ The webhook endpoint for a workflow: the URL to hand the provider. Minted
1525
+ on first ask, whether or not the workflow is armed — an author needs the
1526
+ URL before enabling, and the armed field carries that difference. Null
1527
+ only when the host has no webhook service. With driveId, a workflow not
1528
+ synced here yet is waited for briefly, then fails as WORKFLOW_SYNCING.
1529
+ """
1530
+ webhookEndpoint(workflowId: String!, driveId: String): WebhookEndpointRecord
1531
+ """
1532
+ Secret metadata (label, version, status). Never the value.
1533
+ """
1534
+ secret(ref: String!): SecretRecord
1535
+ secrets: [SecretRecord!]!
1536
+ """
1537
+ The URL an OAuth2 app must register as its redirect. A bare path when
1538
+ this host does not know its public origin; resolve it against the URL
1539
+ this API is reached at. Null when the host serves no HTTP routes.
1540
+ """
1541
+ oauthRedirectUri: String
1542
+ """
1543
+ How an OAuth2 sign-in opened by startOAuth stands; null when unknown.
1544
+ """
1545
+ oauthAttempt(state: String!): OAuthAttempt
1546
+ }
1547
+
1548
+ type OAuthAttempt {
1549
+ connectionId: String!
1550
+ "PENDING | EXCHANGING | OK | ERROR"
1551
+ status: String!
1552
+ error: String
1553
+ }
1554
+
1555
+ type OAuthStart {
1556
+ state: String!
1557
+ "Where to send the user to sign in."
1558
+ authorizationUrl: String!
1559
+ redirectUri: String!
1560
+ expiresAt: String!
1561
+ }
1562
+
1563
+ """
1564
+ One action or trigger of a piece, as a workflow pins it. The engine's own
1565
+ blocks are the piece @powerhousedao/piece-core.
1566
+ """
1567
+ input BlockInput {
1568
+ pieceName: String!
1569
+ "Exact semver."
1570
+ pieceVersion: String!
1571
+ "The action or trigger name."
1572
+ name: String!
1573
+ "action | trigger"
1574
+ kind: String!
1575
+ }
1576
+
1577
+ type BlockSearchHit {
1578
+ pieceName: String!
1579
+ "The version the hit is listed at; pin to it when picking the block."
1580
+ pieceVersion: String!
1581
+ "The action or trigger name."
1582
+ name: String!
1583
+ pieceDisplayName: String!
1584
+ logoUrl: String!
1585
+ displayName: String!
1586
+ description: String!
1587
+ "action | trigger"
1588
+ kind: String!
1589
+ "Triggers only: POLLING | WEBHOOK | APP_WEBHOOK | MANUAL"
1590
+ strategy: String
1591
+ "Why this block cannot run on this reactor; null when it can."
1592
+ unsupported: String
1593
+ }
1594
+
1595
+ type BlockResolutionRecord {
1596
+ "The trigger's or step's id."
1597
+ stepId: String!
1598
+ pieceName: String!
1599
+ "The version the block pins."
1600
+ pieceVersion: String!
1601
+ "The action or trigger name."
1602
+ name: String!
1603
+ "action | trigger"
1604
+ kind: String!
1605
+ "The version that runs; null when nothing resolves."
1606
+ resolvedVersion: String
1607
+ "local | registry | activepieces | npm; null when nothing resolves."
1608
+ source: String
1609
+ "exact | compatible | fallback | installed | missing"
1610
+ match: String!
1611
+ "Why the version that runs differs from the pin, or why nothing resolves."
1612
+ note: String
1613
+ "The newest version any source offers."
1614
+ latestVersion: String
1615
+ }
1616
+
1617
+ type BlockSearchResult {
1618
+ "ready | indexing | error"
1619
+ status: String!
1620
+ hits: [BlockSearchHit!]!
1621
+ indexedPieces: Int!
1622
+ error: String
1623
+ }
1624
+
1625
+ type SecretRecord {
1626
+ ref: String!
1627
+ label: String
1628
+ version: Int!
1629
+ status: String!
1630
+ createdAt: String!
1631
+ updatedAt: String!
1632
+ }
1633
+
1634
+ type ConnectionRecord {
1635
+ id: String!
1636
+ name: String!
1637
+ connectorId: String!
1638
+ authType: String!
1639
+ status: String!
1640
+ accountLabel: String
1641
+ }
1642
+
1643
+ type ConnectionCheckResult {
1644
+ ok: Boolean!
1645
+ detail: String
1646
+ """
1647
+ From auth.getConnectionIdentifier when the check passes; otherwise the
1648
+ label the connection already holds.
1649
+ """
1650
+ accountLabel: String
1651
+ }
1652
+
1653
+ type WebhookEndpointRecord {
1654
+ workflowId: String!
1655
+ url: String!
1656
+ """
1657
+ False when the host gave the webhook service no public origin, so the url
1658
+ above is a bare path. Switchboard always gives one, falling back to
1659
+ http://localhost:<port> when PUBLIC_URL is unset.
1660
+ """
1661
+ absoluteUrl: Boolean!
1662
+ "True while the workflow is ENABLED with a valid webhook trigger"
1663
+ armed: Boolean!
1664
+ createdAt: String!
1665
+ }
1666
+
1667
+ type TriggerStateRecord {
1668
+ workflowId: String!
1669
+ pieceName: String!
1670
+ triggerName: String!
1671
+ status: String!
1672
+ intervalMs: Int!
1673
+ nextPollAt: String
1674
+ lastPollAt: String
1675
+ lastError: String
1676
+ consecutiveFailures: Int!
1677
+ "When a webhook trigger next renews its subscription; null if it never does."
1678
+ nextRenewAt: String
1679
+ "The last failed renewal, kept apart from lastError, the poll's."
1680
+ renewError: String
1681
+ renewFailures: Int!
1682
+ "The piece version the trigger armed with; null for a host-fed trigger."
1683
+ pieceVersion: String
1684
+ pieceSource: String
1685
+ versionMatch: String
1686
+ versionNote: String
1687
+ }
1688
+
1689
+ type Query {
1690
+ workflowRuntime: WorkflowRuntimeQueries!
1691
+ }
1692
+
1693
+ type WorkflowStepRunRecord {
1694
+ stepId: String!
1695
+ stepKey: String!
1696
+ pieceName: String!
1697
+ "The action the step ran, or the trigger a trigger test sampled."
1698
+ blockName: String!
1699
+ status: String!
1700
+ input: Unknown
1701
+ output: Unknown
1702
+ port: String
1703
+ error: String
1704
+ startedAt: String
1705
+ endedAt: String
1706
+ "The piece version that ran; null for an unresolved block."
1707
+ pieceVersion: String
1708
+ "local | registry | activepieces | npm"
1709
+ pieceSource: String
1710
+ "exact | compatible | fallback | installed | missing"
1711
+ versionMatch: String
1712
+ "Why the version that ran differs from the pin."
1713
+ versionNote: String
1714
+ }
1715
+
1716
+ type WorkflowRunRecord {
1717
+ id: String!
1718
+ workflowId: String!
1719
+ workflowName: String!
1720
+ workflowVersion: Int!
1721
+ triggerKind: String!
1722
+ triggerPayload: Unknown
1723
+ status: String!
1724
+ error: String
1725
+ "When the run began executing; a PENDING run's is when it was journaled."
1726
+ startedAt: String!
1727
+ endedAt: String
1728
+ rerunOf: String
1729
+ "How many warningNotes the run has; a run with any is not a plain success."
1730
+ warnings: Int!
1731
+ "Steps that ran a fallback piece version, edges on ports nothing emits."
1732
+ warningNotes: [String!]!
1733
+ steps: [WorkflowStepRunRecord!]!
1734
+ }
1735
+
1736
+ input WorkflowRunsPagingInput {
1737
+ "Page size; default 25, at most 100."
1738
+ limit: Int
1739
+ "The cursor of the previous page."
1740
+ cursor: String
1741
+ }
1742
+
1743
+ type WorkflowRunResultPage {
1744
+ items: [WorkflowRunRecord!]!
1745
+ hasNextPage: Boolean!
1746
+ "True when the page was read after a cursor."
1747
+ hasPreviousPage: Boolean!
1748
+ "Resumes after this page's last run; null for an empty page."
1749
+ cursor: String
1750
+ }
1751
+
1752
+ type WorkflowStepRun {
1753
+ stepId: String!
1754
+ key: String!
1755
+ pieceName: String!
1756
+ blockName: String!
1757
+ status: String!
1758
+ input: Unknown
1759
+ output: Unknown
1760
+ port: String
1761
+ error: String
1762
+ }
1763
+
1764
+ type WorkflowRunPayload {
1765
+ runId: String
1766
+ status: String!
1767
+ error: String
1768
+ steps: [WorkflowStepRun!]!
1769
+ }
1770
+
1771
+ type WorkflowStepTestResult {
1772
+ "Null when the test never started, e.g. an upstream block is untested."
1773
+ runId: String
1774
+ "SUCCEEDED | FAILED"
1775
+ status: String!
1776
+ output: Unknown
1777
+ error: String
1778
+ durationMs: Int!
1779
+ }
1780
+
1781
+ """
1782
+ WorkflowRuntime Mutations
1783
+ """
1784
+ type WorkflowRuntimeMutations {
1785
+ """
1786
+ Fires a workflow's manual trigger (the core piece's) and runs it to completion.
1787
+ """
1788
+ fire(workflowId: String!, payload: Unknown): WorkflowRunPayload!
1789
+ """
1790
+ Samples the draft trigger and saves it as the trigger's last test. A piece
1791
+ trigger runs its test hook (no cursor changes). Of the core piece's
1792
+ triggers, manual takes payload, schedule samples a fire now, and webhook
1793
+ waits for the next delivery to the workflow's endpoint, up to
1794
+ timeoutSeconds (default and cap 300), and that delivery runs nothing.
1795
+ """
1796
+ testTrigger(
1797
+ workflowId: String!
1798
+ payload: Unknown
1799
+ timeoutSeconds: Int
1800
+ driveId: String
1801
+ ): Unknown
1802
+ """
1803
+ Stops a webhook trigger test that is waiting; false when none was.
1804
+ """
1805
+ cancelTriggerTest(workflowId: String!): Boolean!
1806
+ """
1807
+ Runs one draft step against the latest test outputs of the blocks it
1808
+ reads, journaled as a "test" run and noted as the step's lastTest.
1809
+ driveId waits for an unsynced workflow, as for webhookEndpoint.
1810
+ """
1811
+ testStep(
1812
+ workflowId: String!
1813
+ stepId: String!
1814
+ driveId: String
1815
+ ): WorkflowStepTestResult!
1816
+ """
1817
+ Resumes a FAILED run: succeeded steps replay from the journal,
1818
+ execution restarts at the failure. Produces a new run.
1819
+ """
1820
+ rerun(runId: String!): WorkflowRunPayload!
1821
+ """
1822
+ Mints a managed secret and returns its ref; the value is stored
1823
+ encrypted and is never readable back over any API.
1824
+ """
1825
+ createSecret(value: String!, label: String): SecretRecord!
1826
+ """
1827
+ Replaces the value behind an existing ref; documents stay untouched.
1828
+ """
1829
+ rotateSecret(ref: String!, value: String!): SecretRecord!
1830
+ """
1831
+ Tombstones a secret; its value becomes unrecoverable.
1832
+ """
1833
+ deleteSecret(ref: String!): Boolean!
1834
+ """
1835
+ Runs the piece's auth.validate against the connection's credentials,
1836
+ then auth.getConnectionIdentifier for the account label, and records the
1837
+ outcome on the connection document.
1838
+ """
1839
+ checkConnection(connectionId: String!): ConnectionCheckResult!
1840
+ """
1841
+ Opens an OAuth2 sign-in with the connection's own app (client_id in its
1842
+ config, client_secret in its secretRefs). redirectUri is needed only when
1843
+ oauthRedirectUri is a bare path. With returnUrl, which must be on the
1844
+ caller's origin, the callback sends the browser back there with
1845
+ ?ph_oauth=<state> instead of closing the window.
1846
+ """
1847
+ startOAuth(
1848
+ connectionId: String!
1849
+ redirectUri: String
1850
+ returnUrl: String
1851
+ ): OAuthStart!
1852
+ }
1853
+
1854
+ type Mutation {
1855
+ workflowRuntime: WorkflowRuntimeMutations!
1856
+ }
1857
+ `;
1858
+ //#endregion
1859
+ //#region src/workflow/subgraph.ts
1860
+ /** The runtime's read/write surface. The runtime itself is composed by the
1861
+ * host, so the subgraph only serves what it is handed. */
1862
+ function createWorkflowRuntimeSubgraph(runtime, oauth) {
1863
+ return class WorkflowRuntimeSubgraph extends BaseSubgraph {
1864
+ name = "workflow-runtime";
1865
+ typeDefs = schema;
1866
+ resolvers = getResolvers(runtime, this.authorizationService, oauth);
1867
+ additionalContextFields = {};
1868
+ };
1869
+ }
1870
+ //#endregion
1871
+ //#region src/workflow-runtime.mts
1872
+ /** The npm name the workflow package owns: its HTTP namespace and its models. */
1873
+ const WORKFLOW_PACKAGE_NAME = "@powerhousedao/workflow";
1874
+ /** The env var and OpenFeature flag key that turns workflows on. */
1875
+ const PH_WORKFLOWS_ENABLED = "PH_WORKFLOWS_ENABLED";
1876
+ /** Precedence, unchanged from the reactor-api resolver this replaces: the
1877
+ * host's option, then PH_WORKFLOWS_ENABLED, then the config file, then off. */
1878
+ async function resolveWorkflowsEnabled({ featureFlags, override, configEnabled = false, env = process.env }) {
1879
+ if (override !== void 0) return override;
1880
+ const raw = env[PH_WORKFLOWS_ENABLED]?.trim();
1881
+ if (raw === "1") return true;
1882
+ if (raw === "0") return false;
1883
+ return featureFlags.getBooleanValue(PH_WORKFLOWS_ENABLED, configEnabled);
1884
+ }
1885
+ async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao/workflow/document-models")) {
1886
+ try {
1887
+ await load();
1888
+ } catch (error) {
1889
+ throw new Error(`Workflows are enabled but ${WORKFLOW_PACKAGE_NAME} could not be loaded`, { cause: error });
1890
+ }
1891
+ }
1892
+ function readAssertion(authorizationService, reactorClient) {
1893
+ const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
1894
+ const hostCanRead = async (identifier, ctx) => {
1895
+ if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
1896
+ if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
1897
+ let documentId;
1898
+ try {
1899
+ documentId = await resolveCanonical(identifier);
1900
+ } catch {
1901
+ throw new ForbiddenError();
1902
+ }
1903
+ if (!await authorizationService.canRead(documentId, ctx.user?.address)) throw new ForbiddenError("to read this document");
1904
+ };
1905
+ return async (identifier, caller) => {
1906
+ const ctx = caller;
1907
+ await hostCanRead(identifier, ctx);
1908
+ let served;
1909
+ try {
1910
+ served = await reactorClient.isServed(identifier, { subject: callerSubject(ctx.user) });
1911
+ } catch {
1912
+ throw new ForbiddenError();
1913
+ }
1914
+ if (!served) throw new ForbiddenError("to read this document");
1915
+ };
1916
+ }
1917
+ /** The same, for a design-time call that writes what it names. */
1918
+ function writeAssertion(authorizationService, reactorClient) {
1919
+ const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
1920
+ return async (identifier, caller) => {
1921
+ const ctx = caller;
1922
+ if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
1923
+ if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
1924
+ let documentId;
1925
+ try {
1926
+ documentId = await resolveCanonical(identifier);
1927
+ } catch {
1928
+ throw new ForbiddenError();
1929
+ }
1930
+ if (!await authorizationService.canWrite(documentId, ctx.user?.address)) throw new ForbiddenError("to write this document");
1931
+ };
1932
+ }
1933
+ /** Whether the workflow document really references the attachment. A step
1934
+ * carries no caller, so the relationship is the whole check. */
1935
+ function attachmentRefCheck(deps) {
1936
+ const resolveCanonical = createCanonicalDocumentIdResolver(deps.reactorClient);
1937
+ const references = deps.attachmentReferences;
1938
+ const projection = deps.attachmentReferenceProjection;
1939
+ return async (documentId, ref) => {
1940
+ if (!references || projection?.status !== "available") return false;
1941
+ let parsed;
1942
+ try {
1943
+ parsed = parseRef(ref);
1944
+ } catch {
1945
+ return false;
1946
+ }
1947
+ if (parsed.version !== 1) return false;
1948
+ const canonicalRef = createRef(parsed.hash.toLowerCase());
1949
+ try {
1950
+ return await references.hasReference(await resolveCanonical(documentId), canonicalRef);
1951
+ } catch {
1952
+ return false;
1953
+ }
1954
+ };
1955
+ }
1956
+ async function registerWorkflowTriggersReadModel(engine, runtime, clientModule) {
1957
+ const reactorModule = clientModule?.reactorModule;
1958
+ if (!reactorModule) return {
1959
+ status: "unavailable",
1960
+ reason: "in-process-reactor-module-unavailable"
1961
+ };
1962
+ const coordinator = reactorModule.readModelCoordinator;
1963
+ if (!supportsLiveReadModelRegistration(coordinator)) return {
1964
+ status: "unavailable",
1965
+ reason: "live-read-model-registration-unsupported"
1966
+ };
1967
+ const readModel = new engine.WorkflowTriggersReadModel(reactorModule.database.withSchema(REACTOR_SCHEMA), reactorModule.operationIndex, reactorModule.writeCache, reactorModule.processorManagerConsistencyTracker, runtime);
1968
+ await readModel.init();
1969
+ coordinator.addReadModel(readModel, engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE);
1970
+ return { status: "available" };
1971
+ }
1972
+ function bindPackagePieces(registry, source) {
1973
+ const apply = (byPackage) => {
1974
+ registry.setPieces([...byPackage.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).flatMap(([, pieces]) => pieces));
1975
+ };
1976
+ apply(source.getPieces());
1977
+ source.onPiecesChange(apply);
1978
+ }
1979
+ async function composeWorkflowRuntime(deps) {
1980
+ const load = deps.load ?? (() => import("@powerhousedao/reactor-workflow"));
1981
+ let engine;
1982
+ try {
1983
+ engine = await load();
1984
+ } catch (error) {
1985
+ throw new Error("Workflows are enabled but @powerhousedao/reactor-workflow could not be loaded", { cause: error });
1986
+ }
1987
+ engine.setPieceRegistryUrl(deps.pieceRegistryUrl);
1988
+ if (deps.pieces) bindPackagePieces(engine.packagePieces, deps.pieces);
1989
+ const runtime = engine.createWorkflowRuntime({
1990
+ relationalDb: deps.relationalDb,
1991
+ secretsKeyFile: deps.secretsKeyFile,
1992
+ reactorClient: deps.reactorClient,
1993
+ assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
1994
+ subjectOf: (caller) => callerSubject(caller.user),
1995
+ assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
1996
+ webhooks: deps.webhooks,
1997
+ attachments: deps.attachments,
1998
+ canReadAttachmentRef: attachmentRefCheck(deps),
1999
+ logger: deps.logger
2000
+ });
2001
+ const triggers = await registerWorkflowTriggersReadModel(engine, runtime, deps.clientModule);
2002
+ if (triggers.status === "available") deps.logger.info(`Workflow trigger read model registered (${engine.WORKFLOW_TRIGGERS_READ_MODEL}, ${engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE})`);
2003
+ 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);
2004
+ let stopped = false;
2005
+ const oauthCallback = deps.http ? registerOAuthCallback(deps.http, runtime) : void 0;
2006
+ return {
2007
+ subgraph: createWorkflowRuntimeSubgraph(runtime, deps.http ? { callbackUrl: callbackUrlOf(deps.http) } : void 0),
2008
+ triggers,
2009
+ async start() {
2010
+ await runtime.registerWebhookEndpoint();
2011
+ runtime.startTriggerSupervisor();
2012
+ },
2013
+ stop() {
2014
+ if (stopped) return Promise.resolve();
2015
+ stopped = true;
2016
+ oauthCallback?.dispose();
2017
+ runtime.shutdown();
2018
+ return Promise.resolve();
2019
+ }
2020
+ };
2021
+ }
2022
+ //#endregion
868
2023
  //#region src/pglite-dialect.ts
869
2024
  var ClosablePGliteDialect = class extends PGliteDialect {
870
2025
  #pglite;
@@ -1003,6 +2158,26 @@ function resolveReactorFeatureFlags(env) {
1003
2158
  };
1004
2159
  }
1005
2160
  //#endregion
2161
+ //#region src/create-signature-policy.mts
2162
+ /**
2163
+ * What new documents are created as, from CREATE_SIGNATURE_POLICY. Without a
2164
+ * signer this switchboard cannot write to a v2-required document, so it falls
2165
+ * back to legacy and says so.
2166
+ */
2167
+ function resolveCreateSignaturePolicy(env, options) {
2168
+ const raw = env.CREATE_SIGNATURE_POLICY?.trim();
2169
+ let policy = DEFAULT_SIGNATURE_POLICY;
2170
+ if (raw) {
2171
+ if (!isSignaturePolicy(raw)) throw new Error(`CREATE_SIGNATURE_POLICY must be "legacy" or "v2-required", got "${raw}"`);
2172
+ policy = raw;
2173
+ }
2174
+ if (policy === "v2-required" && !options.hasSigner) {
2175
+ 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.");
2176
+ return "legacy";
2177
+ }
2178
+ return policy;
2179
+ }
2180
+ //#endregion
1006
2181
  //#region src/renown.ts
1007
2182
  const logger = childLogger(["switchboard", "renown"]);
1008
2183
  /**
@@ -1025,14 +2200,122 @@ async function initRenown(options = {}) {
1025
2200
  * Get the signer config for the given renown instance.
1026
2201
  *
1027
2202
  * @param renown - The renown instance
1028
- * @param requireSignature - If true, unsigned actions are rejected
2203
+ * @param keypairPath - Where `initRenown` loaded the key from
1029
2204
  */
1030
- function getRenownSignerConfig(renown, requireSignature) {
2205
+ function getRenownSignerConfig(renown, keypairPath) {
1031
2206
  return {
1032
2207
  signer: renown.signer,
1033
- verifier: createSignatureVerifier(requireSignature)
2208
+ workerSigner: getRenownWorkerSignerSpec(renown, keypairPath)
1034
2209
  };
1035
2210
  }
2211
+ /**
2212
+ * What a pooled executor worker imports to sign as this switchboard: it
2213
+ * reloads the key `initRenown` stored, and takes the user known at boot.
2214
+ */
2215
+ function getRenownWorkerSignerSpec(renown, keypairPath) {
2216
+ const { signer } = renown;
2217
+ const user = signer.user;
2218
+ return {
2219
+ module: {
2220
+ filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
2221
+ exportName: "createNodeRenownSigner"
2222
+ },
2223
+ initArgs: {
2224
+ appName: signer.app?.name ?? "switchboard",
2225
+ keypairPath: resolve(keypairPath ?? DEFAULT_KEYPAIR_PATH),
2226
+ ...signer.app?.key ? { did: signer.app.key } : {},
2227
+ ...user ? { user: {
2228
+ address: user.address,
2229
+ networkId: user.networkId,
2230
+ chainId: user.chainId
2231
+ } } : {}
2232
+ }
2233
+ };
2234
+ }
2235
+ /**
2236
+ * Admits a key as a signer for an address a Renown credential binds it to; a
2237
+ * `self` source has no worker spec. In process, the switchboard's own key is
2238
+ * matched against its current user; the worker spec carries the user known now.
2239
+ */
2240
+ async function getRenownTrustPolicyConfig(trustSource, renown) {
2241
+ const ownSigner = renown?.signer;
2242
+ if (trustSource.source === "self") return { trustPolicy: createRenownTrustPolicy({
2243
+ switchboard: trustSource.request,
2244
+ ownSigner
2245
+ }) };
2246
+ const switchboardUrl = await resolveSwitchboardEndpoint({
2247
+ switchboardUrl: trustSource.switchboardUrl,
2248
+ baseUrl: trustSource.renownUrl
2249
+ });
2250
+ const options = {
2251
+ ...switchboardUrl ? { switchboard: switchboardUrl } : {},
2252
+ ...trustSource.renownUrl ? { renownUrl: trustSource.renownUrl } : {}
2253
+ };
2254
+ const self = ownIdentity(renown);
2255
+ return {
2256
+ trustPolicy: createRenownTrustPolicy({
2257
+ ...options,
2258
+ ownSigner
2259
+ }),
2260
+ workerTrustPolicy: {
2261
+ module: {
2262
+ filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
2263
+ exportName: "createRenownTrustPolicy"
2264
+ },
2265
+ initArgs: {
2266
+ ...options,
2267
+ ...self ? { self } : {}
2268
+ }
2269
+ }
2270
+ };
2271
+ }
2272
+ /**
2273
+ * Fails the boot when pooled workers would refuse every signed write the
2274
+ * in-process executor accepts: a `self` Renown source gives workers no trust
2275
+ * policy spec, so under authEnforcement they apply the default.
2276
+ */
2277
+ function assertWorkerTrustPolicy(config) {
2278
+ 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).");
2279
+ }
2280
+ function ownIdentity(renown) {
2281
+ const key = renown?.signer.app?.key;
2282
+ if (!key) return;
2283
+ const user = renown.signer.user;
2284
+ return {
2285
+ key,
2286
+ user: {
2287
+ address: user?.address ?? "",
2288
+ networkId: user?.networkId ?? "",
2289
+ chainId: user?.chainId ?? 0
2290
+ }
2291
+ };
2292
+ }
2293
+ //#endregion
2294
+ //#region src/unsupported-stored-documents.mts
2295
+ const MODES = ["refuse", "read-only"];
2296
+ /** From REACTOR_UNSUPPORTED_STORED_DOCUMENTS; `input` wins when defined. */
2297
+ function resolveUnsupportedStoredDocuments(input, env) {
2298
+ if (input !== void 0) return input;
2299
+ const raw = env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS?.trim();
2300
+ if (!raw) return "refuse";
2301
+ if (!MODES.includes(raw)) throw new Error(`REACTOR_UNSUPPORTED_STORED_DOCUMENTS must be "refuse" or "read-only", got "${raw}"`);
2302
+ return raw;
2303
+ }
2304
+ /** The reactor refused to start over documents this build does not run. */
2305
+ var StoredDocumentsRefusedError = class extends Error {
2306
+ versions;
2307
+ documents;
2308
+ constructor(cause) {
2309
+ const named = cause.versions.map(({ protocol, version }) => `${protocol} ${version}`).join(", ");
2310
+ 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 });
2311
+ this.name = "StoredDocumentsRefusedError";
2312
+ this.versions = cause.versions;
2313
+ this.documents = cause.documents;
2314
+ }
2315
+ static isError(error) {
2316
+ return Error.isError(error) && error.name === "StoredDocumentsRefusedError";
2317
+ }
2318
+ };
1036
2319
  //#endregion
1037
2320
  //#region src/server.mts
1038
2321
  const defaultLogger = childLogger(["switchboard"]);
@@ -1041,8 +2324,6 @@ setLogLevel(LogLevel);
1041
2324
  dotenv.config();
1042
2325
  const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
1043
2326
  const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
1044
- const REQUIRE_SIGNATURES = "REQUIRE_SIGNATURES";
1045
- const REQUIRE_SIGNATURES_DEFAULT = false;
1046
2327
  const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
1047
2328
  const PORT_FALLBACK_ATTEMPTS = 20;
1048
2329
  const PGLITE_FLUSH_INTERVAL_MS = (() => {
@@ -1077,6 +2358,13 @@ function isPortAvailable(port) {
1077
2358
  function resolveConfigPath(configFile) {
1078
2359
  return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
1079
2360
  }
2361
+ function readConfigWorkflowsEnabled(configPath) {
2362
+ try {
2363
+ return getConfig(configPath).workflows?.enabled ?? false;
2364
+ } catch {
2365
+ return false;
2366
+ }
2367
+ }
1080
2368
  async function resolveServerPort(requested, strictPort, logger) {
1081
2369
  if (strictPort) return requested;
1082
2370
  for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
@@ -1189,8 +2477,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
1189
2477
  }
1190
2478
  if (workerPool) {
1191
2479
  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");
2480
+ assertWorkerTrustPolicy({
2481
+ workers: workerPool.numWorkers,
2482
+ authEnforcement: resolveReactorFeatureFlags(process.env).flags.authEnforcement === true,
2483
+ renownSource: renownConfig.source
2484
+ });
1192
2485
  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
2486
  }
2487
+ const unsupportedStoredDocuments = resolveUnsupportedStoredDocuments(options.unsupportedStoredDocuments, process.env);
1194
2488
  let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
1195
2489
  if (projectionWorker && options.reactor) {
1196
2490
  logger.warn("Projection worker configuration ignored: the caller-provided reactor owns its own read-model coordinator");
@@ -1224,7 +2518,13 @@ async function initServer(serverPort, options, renown, renownConfig) {
1224
2518
  reactorLogger.error(`Projection worker ${shardId} died; shutting down so the supervisor restarts a healthy process`, reason);
1225
2519
  process.kill(process.pid, "SIGTERM");
1226
2520
  };
2521
+ const workflowsEnabled = options.workflows?.enabled === true;
2522
+ if (workflowsEnabled) {
2523
+ await assertWorkflowPackageLoadable();
2524
+ if (!packages.includes("@powerhousedao/workflow")) packages.push(WORKFLOW_PACKAGE_NAME);
2525
+ }
1227
2526
  let ownedReactorModule;
2527
+ let localRenownRequest;
1228
2528
  const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
1229
2529
  if (options.reactor) {
1230
2530
  const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
@@ -1251,7 +2551,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
1251
2551
  if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
1252
2552
  const { flags: reactorFeatureFlags, enabled: enabledFeatureFlags } = resolveReactorFeatureFlags(process.env);
1253
2553
  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" });
2554
+ const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" }).withUnsupportedStoredDocuments(unsupportedStoredDocuments);
1255
2555
  if (poolInstrumentation) reactorBuilder.withInstrumentedPool(poolInstrumentation);
1256
2556
  const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
1257
2557
  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 +2564,19 @@ async function initServer(serverPort, options, renown, renownConfig) {
1264
2564
  } : void 0,
1265
2565
  documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
1266
2566
  logger: reactorLogger,
1267
- signer: renown ? getRenownSignerConfig(renown, options.identity?.requireSignatures) : void 0
2567
+ signer: renown ? getRenownSignerConfig(renown, options.identity?.keypairPath) : void 0,
2568
+ createSignaturePolicy: resolveCreateSignaturePolicy(process.env, {
2569
+ hasSigner: renown !== null,
2570
+ logger
2571
+ }),
2572
+ trustPolicy: reactorFeatureFlags.authEnforcement ? await getRenownTrustPolicyConfig(renownConfig.source === "self" ? {
2573
+ source: "self",
2574
+ request: (query, variables) => localRenownRequest ? localRenownRequest(query, variables) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet"))
2575
+ } : {
2576
+ source: "remote",
2577
+ renownUrl: renownConfig.url,
2578
+ switchboardUrl: renownConfig.switchboardUrl
2579
+ }, renown) : void 0
1268
2580
  });
1269
2581
  if (workerPool) {
1270
2582
  if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
@@ -1308,17 +2620,30 @@ async function initServer(serverPort, options, renown, renownConfig) {
1308
2620
  reactorBuilder.withShutdownHook(async () => {
1309
2621
  if (apiRef.current) await apiRef.current.dispose();
1310
2622
  });
1311
- const module = await clientBuilder.buildModule();
2623
+ let module;
2624
+ try {
2625
+ module = await clientBuilder.buildModule();
2626
+ } catch (error) {
2627
+ try {
2628
+ await baseKysely.destroy();
2629
+ } catch (destroyError) {
2630
+ logger.error("Aborting boot: reactor database destroy failed: @error", destroyError);
2631
+ }
2632
+ if (UnsupportedStoredProtocolError.isError(error)) throw new StoredDocumentsRefusedError(error);
2633
+ throw error;
2634
+ }
1312
2635
  if (module.reactorModule) {
1313
2636
  new ReactorInstrumentation(module.reactorModule).start();
1314
2637
  reactorLogger.info("Reactor metrics instrumentation started");
1315
2638
  }
1316
- driveNodeView = new DriveNodeView(baseKysely.withSchema(REACTOR_SCHEMA));
2639
+ const reactorDriveSchemaDb = baseKysely.withSchema(REACTOR_SCHEMA);
2640
+ driveNodeView = new DriveNodeView(reactorDriveSchemaDb);
1317
2641
  const reactorDriveClient = new ReactorDriveClient({
1318
2642
  reactor: module.client,
1319
2643
  readModel: driveNodeView
1320
2644
  });
1321
2645
  ownedReactorModule = module;
2646
+ if (options.fatalErrorShutdown) installFatalErrorShutdown(logger);
1322
2647
  return {
1323
2648
  module,
1324
2649
  reactorDriveClient,
@@ -1352,7 +2677,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
1352
2677
  if (dev) {
1353
2678
  const { VitePackageLoader, createViteLogger, startViteServer } = await import("@powerhousedao/reactor-api/vite");
1354
2679
  vite = await startViteServer(process.cwd(), createViteLogger(logger));
1355
- viteLoader = VitePackageLoader.build(vite);
2680
+ viteLoader = VitePackageLoader.build(vite, { resolveFrom: [import.meta.url] });
1356
2681
  }
1357
2682
  const vetraProcessorFactory = dev ? (await import("@powerhousedao/vetra/processors")).processorFactory : void 0;
1358
2683
  if (!options.disableLocalPackages) packages.push(basePath);
@@ -1401,7 +2726,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
1401
2726
  await abortBoot(api);
1402
2727
  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
2728
  }
1404
- localCredentialCheck = createLocalCredentialVerifier((query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables), { onError: (error) => logger.error("Renown read model query failed: @error", error) });
2729
+ localRenownRequest = (query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables);
2730
+ localCredentialCheck = createLocalCredentialVerifier(localRenownRequest, { onError: (error) => logger.error("Renown read model query failed: @error", error) });
1405
2731
  logger.info("Renown credentials will be verified against this switchboard's own renown read model");
1406
2732
  }
1407
2733
  registerAttachmentRoutes(api);
@@ -1409,6 +2735,45 @@ async function initServer(serverPort, options, renown, renownConfig) {
1409
2735
  if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
1410
2736
  const { client, graphqlManager, documentModelRegistry } = api;
1411
2737
  const lateSubgraphs = [];
2738
+ let workflows;
2739
+ if (workflowsEnabled) {
2740
+ workflows = await composeWorkflowRuntime({
2741
+ reactorClient: client,
2742
+ clientModule: options.reactor ?? ownedReactorModule,
2743
+ relationalDb: api.relationalDb,
2744
+ secretsKeyFile: readModelPgliteDir === null ? false : void 0,
2745
+ attachments: createAttachmentClient(api.attachments.service),
2746
+ attachmentReferences: api.attachmentReferenceIndex.store,
2747
+ attachmentReferenceProjection: api.attachmentReferenceProjection,
2748
+ webhooks: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME).webhooks,
2749
+ http: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME),
2750
+ authorizationService: api.authorizationService,
2751
+ pieces: api.packageManager,
2752
+ pieceRegistryUrl: registryUrl,
2753
+ logger: logger.child(["workflow-runtime"])
2754
+ });
2755
+ const WorkflowRuntimeSubgraph = workflows.subgraph;
2756
+ const workflowSubgraph = new WorkflowRuntimeSubgraph({
2757
+ reactorClient: client,
2758
+ http: graphqlManager.scopeForPackage(WORKFLOW_PACKAGE_NAME),
2759
+ relationalDb: api.relationalDb,
2760
+ analyticsStore: void 0,
2761
+ graphqlManager,
2762
+ syncManager: api.syncManager,
2763
+ authorizationService: graphqlManager.getAuthorizationService(),
2764
+ path: graphqlManager.getBasePath()
2765
+ });
2766
+ lateSubgraphs.push(graphqlManager.registerSubgraphInstance(workflowSubgraph, "graphql", false).catch((error) => {
2767
+ logger.error("Failed to register workflow-runtime subgraph: @error", error);
2768
+ }));
2769
+ await workflows.start();
2770
+ logger.info("Workflow runtime started");
2771
+ }
2772
+ const shutdown = async () => {
2773
+ await workflows?.stop();
2774
+ await api.dispose();
2775
+ };
2776
+ apiRef.current = { dispose: shutdown };
1412
2777
  if (httpLoader) {
1413
2778
  const packageManagementService = new PackageManagementService({
1414
2779
  defaultRegistryUrl: registryUrl,
@@ -1438,10 +2803,15 @@ async function initServer(serverPort, options, renown, renownConfig) {
1438
2803
  }
1439
2804
  if (driveNodeView) {
1440
2805
  graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
2806
+ const authorizationService = graphqlManager.getAuthorizationService();
1441
2807
  const reactorDriveSubgraph = {
1442
2808
  name: "reactor-drive",
1443
2809
  path: graphqlManager.getBasePath(),
1444
- resolvers: createReactorDriveResolvers(),
2810
+ resolvers: createReactorDriveResolvers({
2811
+ reactorClient: client,
2812
+ readModel: driveNodeView,
2813
+ hostCanRead: (documentId, address) => authorizationService.canRead(documentId, address)
2814
+ }),
1445
2815
  typeDefs: reactorDriveSubgraphTypeDefs,
1446
2816
  reactorClient: client,
1447
2817
  relationalDb: void 0
@@ -1492,9 +2862,12 @@ async function initServer(serverPort, options, renown, renownConfig) {
1492
2862
  reactor: client,
1493
2863
  attachmentService,
1494
2864
  attachmentReferenceProjection: api.attachmentReferenceProjection,
2865
+ workflowTriggers: workflows?.triggers,
2866
+ workflowsEnabled,
2867
+ mcpEnabled: options.mcp !== false,
1495
2868
  renown,
1496
2869
  port: serverPort,
1497
- shutdown: () => api.dispose()
2870
+ shutdown
1498
2871
  };
1499
2872
  }
1500
2873
  /**
@@ -1521,16 +2894,26 @@ const startSwitchboard = async (options = {}) => {
1521
2894
  const featureFlags = await initFeatureFlags();
1522
2895
  const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
1523
2896
  options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
1524
- const requireSignatures = options.identity?.requireSignatures ?? await featureFlags.getBooleanValue(REQUIRE_SIGNATURES, REQUIRE_SIGNATURES_DEFAULT);
2897
+ options.mcp = await resolveMcpEnabled({
2898
+ featureFlags,
2899
+ option: options.mcp
2900
+ });
2901
+ const configPathForFlags = resolveConfigPath(options.configFile);
2902
+ const workflowsEnabled = await resolveWorkflowsEnabled({
2903
+ featureFlags,
2904
+ override: options.workflows?.enabled,
2905
+ configEnabled: readConfigWorkflowsEnabled(configPathForFlags)
2906
+ });
2907
+ options.workflows = { enabled: workflowsEnabled };
1525
2908
  const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
1526
2909
  options.identity = {
1527
2910
  ...options.identity,
1528
- requireSignatures,
1529
2911
  baseUrl: options.identity?.baseUrl ?? renownConfig.url
1530
2912
  };
1531
2913
  logger.info("Feature flags: @flags", JSON.stringify({
1532
2914
  DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
1533
- REQUIRE_SIGNATURES: requireSignatures
2915
+ MCP_ENABLED: options.mcp,
2916
+ PH_WORKFLOWS_ENABLED: workflowsEnabled
1534
2917
  }, null, 2));
1535
2918
  let renown = null;
1536
2919
  try {
@@ -1543,13 +2926,14 @@ const startSwitchboard = async (options = {}) => {
1543
2926
  return await initServer(serverPort, options, renown, renownConfig);
1544
2927
  } catch (e) {
1545
2928
  Sentry.captureException(e);
1546
- logger.error("App crashed: @error", e);
2929
+ if (StoredDocumentsRefusedError.isError(e)) logger.error(e.message);
2930
+ else logger.error("App crashed: @error", e);
1547
2931
  throw e;
1548
2932
  }
1549
2933
  };
1550
- if (import.meta.main) await startSwitchboard();
2934
+ if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
1551
2935
  //#endregion
1552
- export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
2936
+ export { resolveUnsupportedStoredDocuments as a, StoredDocumentsRefusedError as i, isPortAvailable as n, applySwitchboardReactorDefaults as o, startSwitchboard as r, parseForcePgVersion as s, deriveAttachmentServiceConfig as t };
1553
2937
 
1554
- //# sourceMappingURL=server-CiKkVE6g.mjs.map
1555
- //# debugId=e2c62b7b-68fd-59c2-ab82-7d035e11698b
2938
+ //# sourceMappingURL=server-sQoWp21G.mjs.map
2939
+ //# debugId=66287c02-682f-57d0-8260-a1005691b2bc