@powerhousedao/switchboard 6.2.3-dev.3 → 6.2.3-dev.31

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 (96) 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-without-renown.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  15. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  16. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  17. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  18. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  19. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  20. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  21. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  24. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  25. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  26. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  27. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  28. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  29. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  30. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  31. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  32. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  33. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  34. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  35. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  36. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  37. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  38. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  39. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  40. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  41. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  42. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  43. package/.tsbuild/vitest.config.d.ts.map +1 -1
  44. package/Auth.md +94 -0
  45. package/CHANGELOG.md +299 -0
  46. package/README.md +14 -0
  47. package/dist/index.d.mts +2 -1
  48. package/dist/index.mjs +6 -6
  49. package/dist/index.mjs.map +1 -1
  50. package/dist/install-packages.d.mts +2 -1
  51. package/dist/install-packages.mjs +2 -2
  52. package/dist/install-packages.mjs.map +1 -1
  53. package/dist/migrate.d.mts +2 -1
  54. package/dist/migrate.mjs +4 -3
  55. package/dist/migrate.mjs.map +1 -1
  56. package/dist/{server-B2r6AQ4Q.mjs → server-CKKn0aXV.mjs} +1394 -65
  57. package/dist/server-CKKn0aXV.mjs.map +1 -0
  58. package/dist/server.d.mts +76 -28
  59. package/dist/server.d.mts.map +1 -1
  60. package/dist/server.mjs +3 -4
  61. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  62. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  63. package/dist/utils.d.mts +4 -5
  64. package/dist/utils.d.mts.map +1 -1
  65. package/dist/utils.mjs +3 -3
  66. package/package.json +21 -17
  67. package/test/attachments/auth.test.ts +85 -0
  68. package/test/attachments/byte-grants.test.ts +287 -0
  69. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  70. package/test/attachments/download-target.test.ts +194 -31
  71. package/test/attachments/index.test.ts +34 -3
  72. package/test/attachments/routes-integration.test.ts +59 -4
  73. package/test/attachments/routes.test.ts +165 -23
  74. package/test/attachments/service-config.test.ts +13 -3
  75. package/test/attachments/url-signer.test.ts +105 -0
  76. package/test/boot-create-signature-policy.test.ts +108 -0
  77. package/test/boot-without-renown.test.ts +81 -0
  78. package/test/boot-without-require-signatures.test.ts +74 -0
  79. package/test/boot-worker-trust-policy.test.ts +90 -0
  80. package/test/builder-defaults.test.ts +80 -0
  81. package/test/create-signature-policy.test.ts +69 -0
  82. package/test/fatal-shutdown.test.ts +102 -0
  83. package/test/renown-trust-policy.test.ts +315 -0
  84. package/test/workflow/harness.ts +289 -0
  85. package/test/workflow/oauth.test.ts +214 -0
  86. package/test/workflow/resolvers.test.ts +296 -0
  87. package/test/workflow/webhook-dedupe.test.ts +295 -0
  88. package/test/workflow/webhook-signed.test.ts +509 -0
  89. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  90. package/test/workflow/webhook-unsigned.test.ts +345 -0
  91. package/test/workflow-read-gate.test.ts +145 -0
  92. package/test/workflow-runtime.test.ts +486 -0
  93. package/tsconfig.json +6 -0
  94. package/tsdown.config.ts +3 -1
  95. package/dist/server-B2r6AQ4Q.mjs.map +0 -1
  96. 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]="0a17ca6d-016f-53aa-9705-cef103da6f09")}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]="ec5120f6-92b3-53b4-bab1-94d88478784d")}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
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";
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,902 @@ async function initFeatureFlags() {
865
1112
  return OpenFeature.getClient();
866
1113
  }
867
1114
  //#endregion
1115
+ //#region src/workflow/oauth-callback.ts
1116
+ const OAUTH_CALLBACK_PATH = "oauth/callback";
1117
+ function escapeHtml(text) {
1118
+ return text.replace(/[&<>"']/g, (char) => ({
1119
+ "&": "&amp;",
1120
+ "<": "&lt;",
1121
+ ">": "&gt;",
1122
+ "\"": "&quot;",
1123
+ "'": "&#39;"
1124
+ })[char]);
1125
+ }
1126
+ function resultPage(ok, detail) {
1127
+ const title = ok ? "Connected" : "Sign-in failed";
1128
+ return `<!doctype html>
1129
+ <html><head><meta charset="utf-8"><title>${title}</title>
1130
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1131
+ <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>
1132
+ </head><body><main><h1>${title}</h1><p>${ok ? "You can close this window." : escapeHtml(detail ?? "The sign-in did not finish.")}</p></main>
1133
+ ${ok ? "<script>setTimeout(function(){window.close()},600)<\/script>" : ""}
1134
+ </body></html>`;
1135
+ }
1136
+ function registerOAuthCallback(http, runtime) {
1137
+ return http.get(OAUTH_CALLBACK_PATH, { auth: "public" }, async (request) => {
1138
+ const params = new URL(request.url).searchParams;
1139
+ const state = params.get("state");
1140
+ const result = state ? await runtime.completeOAuth({
1141
+ state,
1142
+ code: params.get("code") ?? void 0,
1143
+ error: params.get("error") ?? void 0,
1144
+ errorDescription: params.get("error_description") ?? void 0
1145
+ }) : {
1146
+ ok: false,
1147
+ detail: "The provider sent no state",
1148
+ returnUrl: null
1149
+ };
1150
+ if (result.returnUrl && state) {
1151
+ const back = new URL(result.returnUrl);
1152
+ back.searchParams.set("ph_oauth", state);
1153
+ return new Response(null, {
1154
+ status: 303,
1155
+ headers: {
1156
+ location: back.href,
1157
+ "cache-control": "no-store"
1158
+ }
1159
+ });
1160
+ }
1161
+ return new Response(resultPage(result.ok, result.detail), {
1162
+ status: result.ok ? 200 : 400,
1163
+ headers: {
1164
+ "content-type": "text/html; charset=utf-8",
1165
+ "cache-control": "no-store",
1166
+ "referrer-policy": "no-referrer"
1167
+ }
1168
+ });
1169
+ });
1170
+ }
1171
+ function callbackUrlOf(http) {
1172
+ return `${http.baseUrl.replace(/\/+$/, "")}/${OAUTH_CALLBACK_PATH}`;
1173
+ }
1174
+ //#endregion
1175
+ //#region src/workflow/resolvers.ts
1176
+ function blockRef(input) {
1177
+ if (input.kind !== "action" && input.kind !== "trigger") throw new GraphQLError(`A block's kind is "action" or "trigger", got "${input.kind}"`);
1178
+ return {
1179
+ pieceName: input.pieceName,
1180
+ pieceVersion: input.pieceVersion,
1181
+ name: input.name,
1182
+ kind: input.kind
1183
+ };
1184
+ }
1185
+ async function cursorAware(call) {
1186
+ try {
1187
+ return await call();
1188
+ } catch (error) {
1189
+ if (error instanceof Error && error.name === "InvalidRunCursorError") throw new GraphQLError(error.message, { extensions: { code: "BAD_USER_INPUT" } });
1190
+ throw error;
1191
+ }
1192
+ }
1193
+ function selectsStepData(selections, path) {
1194
+ let level = selections;
1195
+ for (const name of [...path, "steps"]) {
1196
+ const next = [];
1197
+ for (const set of level) for (const selection of set.selections) {
1198
+ if (selection.kind !== Kind.FIELD) return true;
1199
+ if (selection.name.value === name && selection.selectionSet) next.push(selection.selectionSet);
1200
+ }
1201
+ if (next.length === 0) return false;
1202
+ level = next;
1203
+ }
1204
+ return level.some((set) => set.selections.some((selection) => selection.kind !== Kind.FIELD || selection.name.value === "input" || selection.name.value === "output"));
1205
+ }
1206
+ function readsStepData(info, path) {
1207
+ if (!info) return true;
1208
+ return selectsStepData(info.fieldNodes.flatMap((node) => node.selectionSet ? [node.selectionSet] : []), path);
1209
+ }
1210
+ function requireAdmin(authorizationService, ctx) {
1211
+ if (!authorizationService.isSupremeAdmin(ctx.user?.address)) throw new GraphQLError("Admin access required");
1212
+ }
1213
+ async function syncAware(call) {
1214
+ try {
1215
+ return await call();
1216
+ } catch (error) {
1217
+ if (error instanceof Error && error.name === "WorkflowSyncingError") throw new GraphQLError(error.message, { extensions: {
1218
+ code: "WORKFLOW_SYNCING",
1219
+ retryable: true
1220
+ } });
1221
+ throw error;
1222
+ }
1223
+ }
1224
+ function parseJson(value) {
1225
+ if (value === null) return null;
1226
+ try {
1227
+ return JSON.parse(value);
1228
+ } catch {
1229
+ return value;
1230
+ }
1231
+ }
1232
+ function toStepRecord(row) {
1233
+ return {
1234
+ stepId: row.step_id,
1235
+ stepKey: row.step_key,
1236
+ pieceName: row.piece_name,
1237
+ blockName: row.block_name,
1238
+ status: row.status,
1239
+ input: parseJson(row.input),
1240
+ output: parseJson(row.output),
1241
+ port: row.port,
1242
+ error: row.error,
1243
+ startedAt: row.started_at,
1244
+ endedAt: row.ended_at,
1245
+ pieceVersion: row.piece_version,
1246
+ pieceSource: row.piece_source,
1247
+ versionMatch: row.version_match,
1248
+ versionNote: row.version_note
1249
+ };
1250
+ }
1251
+ function toRunRecord(row, steps) {
1252
+ return {
1253
+ id: row.id,
1254
+ workflowId: row.workflow_id,
1255
+ workflowName: row.workflow_name,
1256
+ workflowVersion: row.workflow_version,
1257
+ triggerKind: row.trigger_kind,
1258
+ triggerPayload: parseJson(row.trigger_payload),
1259
+ status: row.status,
1260
+ error: row.error,
1261
+ startedAt: row.started_at,
1262
+ endedAt: row.ended_at,
1263
+ rerunOf: row.rerun_of,
1264
+ warnings: row.warnings,
1265
+ warningNotes: parseJson(row.warning_notes) ?? [],
1266
+ steps: steps.map(toStepRecord)
1267
+ };
1268
+ }
1269
+ function isAbsolute(url) {
1270
+ return /^https?:\/\//i.test(url);
1271
+ }
1272
+ function redirectUriFor(routing, requested) {
1273
+ if (!routing) throw new GraphQLError("This host serves no OAuth2 callback");
1274
+ if (isAbsolute(routing.callbackUrl)) return routing.callbackUrl;
1275
+ if (!requested) throw new GraphQLError("This host does not know its public URL; pass redirectUri");
1276
+ let url;
1277
+ try {
1278
+ url = new URL(requested);
1279
+ } catch {
1280
+ throw new GraphQLError(`"${requested}" is not a URL`);
1281
+ }
1282
+ 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}`);
1283
+ return url.href;
1284
+ }
1285
+ function returnUrlFor(requested, ctx) {
1286
+ if (!requested) return void 0;
1287
+ const origin = ctx.headers.origin;
1288
+ let url;
1289
+ try {
1290
+ url = new URL(requested);
1291
+ } catch {
1292
+ url = void 0;
1293
+ }
1294
+ if (!url || !origin || url.origin !== origin) throw new GraphQLError("returnUrl must be on the requesting page's origin");
1295
+ return url.href;
1296
+ }
1297
+ const getResolvers = (runtime, authorizationService, oauth) => {
1298
+ return {
1299
+ Query: { workflowRuntime: () => ({}) },
1300
+ WorkflowRuntimeQueries: {
1301
+ health: () => "ok",
1302
+ blockDescriptor: (_parent, args) => runtime.blockDescriptor(blockRef(args.block)),
1303
+ blockOptions: (_parent, args, ctx) => runtime.blockOptions(blockRef(args.block), args.propName, args.input, args.connectionId ?? void 0, ctx, args.searchValue ?? void 0),
1304
+ pieceCatalog: () => runtime.pieceCatalog(),
1305
+ pieceActions: (_parent, args) => runtime.pieceActions(args.packageName, args.version ?? void 0),
1306
+ pieceTriggers: (_parent, args) => runtime.pieceTriggers(args.packageName, args.version ?? void 0),
1307
+ blockOutputTree: (_parent, args) => runtime.blockOutputTree(blockRef(args.block), args.config),
1308
+ stepOutputTree: (_parent, args, ctx) => runtime.stepOutputTree(args.workflowId, args.stepId, ctx),
1309
+ blockResolutions: (_parent, args, ctx) => runtime.blockResolutions(args.workflowId, ctx),
1310
+ pieceDetail: (_parent, args) => runtime.pieceDetail(args.packageName, args.version ?? void 0),
1311
+ searchBlocks: (_parent, args) => runtime.searchBlocks(args.query, args.limit ?? void 0),
1312
+ connections: (_parent, _args, ctx) => runtime.connections(ctx),
1313
+ webhookEndpoint: (_parent, args, ctx) => syncAware(() => runtime.webhookEndpoint(args.workflowId, ctx, { driveId: args.driveId ?? void 0 })),
1314
+ secret: async (_parent, args) => {
1315
+ try {
1316
+ return await (await runtime.secrets()).stat(args.ref);
1317
+ } catch {
1318
+ return null;
1319
+ }
1320
+ },
1321
+ secrets: async () => (await runtime.secrets()).list(),
1322
+ oauthRedirectUri: () => oauth?.callbackUrl ?? null,
1323
+ oauthAttempt: (_parent, args, ctx) => runtime.oauthAttempt(args.state, ctx),
1324
+ triggerStates: async (_parent, _args, ctx) => (await runtime.triggerStates(ctx)).map((row) => ({
1325
+ workflowId: row.workflow_id,
1326
+ pieceName: row.piece_name,
1327
+ triggerName: row.trigger_name,
1328
+ status: row.status,
1329
+ intervalMs: row.interval_ms,
1330
+ nextPollAt: row.next_poll_at,
1331
+ lastPollAt: row.last_poll_at,
1332
+ lastError: row.last_error,
1333
+ consecutiveFailures: row.consecutive_failures,
1334
+ nextRenewAt: row.next_renew_at,
1335
+ renewError: row.renew_error,
1336
+ renewFailures: row.renew_failures,
1337
+ pieceVersion: row.piece_version,
1338
+ pieceSource: row.piece_source,
1339
+ versionMatch: row.version_match,
1340
+ versionNote: row.version_note
1341
+ })),
1342
+ runs: async (_parent, args, ctx, info) => (await runtime.runs({
1343
+ ...args,
1344
+ withStepData: readsStepData(info, [])
1345
+ }, ctx)).map((record) => toRunRecord(record.row, record.steps)),
1346
+ runsPage: async (_parent, args, ctx, info) => {
1347
+ const { paging, ...scope } = args;
1348
+ const page = await cursorAware(() => runtime.runsPage({
1349
+ ...scope,
1350
+ limit: paging?.limit ?? void 0,
1351
+ cursor: paging?.cursor ?? null,
1352
+ withStepData: readsStepData(info, ["items"])
1353
+ }, ctx));
1354
+ return {
1355
+ items: page.records.map((record) => toRunRecord(record.row, record.steps)),
1356
+ hasNextPage: page.hasNextPage,
1357
+ hasPreviousPage: Boolean(paging?.cursor),
1358
+ cursor: page.cursor
1359
+ };
1360
+ },
1361
+ run: async (_parent, args, ctx) => {
1362
+ const record = await runtime.run(args.id, ctx);
1363
+ return record ? toRunRecord(record.row, record.steps) : null;
1364
+ }
1365
+ },
1366
+ Mutation: { workflowRuntime: () => ({}) },
1367
+ WorkflowRuntimeMutations: {
1368
+ fire: (_parent, args, ctx) => runtime.fire(args.workflowId, args.payload, "manual", void 0, ctx),
1369
+ testTrigger: (_parent, args, ctx) => syncAware(() => runtime.testTrigger(args.workflowId, ctx, {
1370
+ ...args.payload !== void 0 ? { payload: args.payload } : {},
1371
+ ...typeof args.timeoutSeconds === "number" ? { timeoutMs: args.timeoutSeconds * 1e3 } : {},
1372
+ driveId: args.driveId ?? void 0
1373
+ })),
1374
+ cancelTriggerTest: (_parent, args, ctx) => runtime.cancelTriggerTestFor(args.workflowId, ctx),
1375
+ testStep: (_parent, args, ctx) => syncAware(() => runtime.testStep(args.workflowId, args.stepId, ctx, { driveId: args.driveId ?? void 0 })),
1376
+ rerun: (_parent, args, ctx) => runtime.rerun(args.runId, ctx),
1377
+ createSecret: async (_parent, args, ctx) => {
1378
+ requireAdmin(authorizationService, ctx);
1379
+ return (await runtime.secrets()).create({
1380
+ value: args.value,
1381
+ label: args.label ?? void 0
1382
+ });
1383
+ },
1384
+ rotateSecret: async (_parent, args, ctx) => {
1385
+ requireAdmin(authorizationService, ctx);
1386
+ return (await runtime.secrets()).rotate(args.ref, args.value);
1387
+ },
1388
+ deleteSecret: async (_parent, args, ctx) => {
1389
+ requireAdmin(authorizationService, ctx);
1390
+ await (await runtime.secrets()).delete(args.ref);
1391
+ return true;
1392
+ },
1393
+ checkConnection: (_parent, args, ctx) => runtime.checkConnection(args.connectionId, ctx),
1394
+ startOAuth: (_parent, args, ctx) => {
1395
+ requireAdmin(authorizationService, ctx);
1396
+ const returnUrl = returnUrlFor(args.returnUrl, ctx);
1397
+ return runtime.startOAuth(args.connectionId, ctx, {
1398
+ redirectUri: redirectUriFor(oauth, args.redirectUri),
1399
+ ...returnUrl ? { returnUrl } : {}
1400
+ });
1401
+ }
1402
+ }
1403
+ };
1404
+ };
1405
+ //#endregion
1406
+ //#region src/workflow/schema.ts
1407
+ const schema = gql`
1408
+ """
1409
+ WorkflowRuntime Queries
1410
+ """
1411
+ type WorkflowRuntimeQueries {
1412
+ health: String!
1413
+ """
1414
+ Persisted runs, newest first. Scope them to one workflow, or to every
1415
+ workflow a drive holds; workflowId wins when both are given.
1416
+ """
1417
+ runs(
1418
+ workflowId: String
1419
+ driveId: String
1420
+ limit: Int
1421
+ "Trigger kinds to leave out, e.g. test."
1422
+ excludeTriggerKinds: [String!]
1423
+ ): [WorkflowRunRecord!]!
1424
+ """
1425
+ The same listing a page at a time, newest journaled first. Pass a page's
1426
+ cursor back in paging to read the next; a page is short only when
1427
+ hasNextPage is false, or when the caller could read none of a long
1428
+ stretch of runs. A run that starts between pages keeps its place.
1429
+ """
1430
+ runsPage(
1431
+ workflowId: String
1432
+ driveId: String
1433
+ excludeTriggerKinds: [String!]
1434
+ paging: WorkflowRunsPagingInput
1435
+ ): WorkflowRunResultPage!
1436
+ run(id: String!): WorkflowRunRecord
1437
+ """
1438
+ The descriptor (props, auth, ports) of one action or trigger, as the
1439
+ version that runs it describes it; triggers come back under "trigger".
1440
+ """
1441
+ blockDescriptor(block: BlockInput!): Unknown
1442
+ """
1443
+ Resolves a dynamic prop against the current config values: a DROPDOWN
1444
+ yields { options, placeholder, disabled }, a DYNAMIC prop yields the
1445
+ resolved sub-property descriptor list.
1446
+ """
1447
+ blockOptions(
1448
+ block: BlockInput!
1449
+ propName: String!
1450
+ input: Unknown
1451
+ connectionId: String
1452
+ "What the author typed, for a DROPDOWN declared with refreshOnSearch."
1453
+ searchValue: String
1454
+ ): Unknown
1455
+ """
1456
+ Every piece this reactor offers: the core piece, its package pieces, and
1457
+ the pieces the registry and Activepieces publish.
1458
+ """
1459
+ pieceCatalog: Unknown
1460
+ """
1461
+ A piece's actions at one version; pin a step to that version.
1462
+ """
1463
+ pieceActions(
1464
+ packageName: String!
1465
+ "That version; the installed or latest one when omitted."
1466
+ version: String
1467
+ ): Unknown
1468
+ """
1469
+ A piece's triggers at one version; pin a trigger to that version.
1470
+ """
1471
+ pieceTriggers(
1472
+ packageName: String!
1473
+ "That version; the installed or latest one when omitted."
1474
+ version: String
1475
+ ): Unknown
1476
+ """
1477
+ Full piece detail (PieceMetadataModel-shaped), verbatim from the cloud API.
1478
+ """
1479
+ pieceDetail(
1480
+ packageName: String!
1481
+ "That version; the installed or latest one when omitted."
1482
+ version: String
1483
+ ): Unknown
1484
+ """
1485
+ Action/trigger name search across the catalog. The index builds lazily on
1486
+ first use; poll while status is "indexing".
1487
+ """
1488
+ searchBlocks(query: String!, limit: Int): BlockSearchResult!
1489
+ """
1490
+ Health of every registered piece trigger (poll schedule, errors).
1491
+ """
1492
+ triggerStates: [TriggerStateRecord!]!
1493
+ """
1494
+ Authored output shape of a block (SDL / outputSchema / sampleData).
1495
+ """
1496
+ blockOutputTree(block: BlockInput!, config: Unknown): Unknown
1497
+ """
1498
+ Output shape of a draft step or trigger: its latest test output
1499
+ (source "test", with sample and testedAt) when tested, else as blockOutputTree.
1500
+ """
1501
+ stepOutputTree(workflowId: String!, stepId: String!): Unknown
1502
+ """
1503
+ Every block of the draft (trigger first) as this reactor resolves it:
1504
+ which piece version runs, from where, and how it matches the pin.
1505
+ """
1506
+ blockResolutions(workflowId: String!): [BlockResolutionRecord!]!
1507
+ """
1508
+ Every powerhouse/connection document, for connection pickers.
1509
+ """
1510
+ connections: [ConnectionRecord!]!
1511
+ """
1512
+ The webhook endpoint for a workflow: the URL to hand the provider. Minted
1513
+ on first ask, whether or not the workflow is armed — an author needs the
1514
+ URL before enabling, and the armed field carries that difference. Null
1515
+ only when the host has no webhook service. With driveId, a workflow not
1516
+ synced here yet is waited for briefly, then fails as WORKFLOW_SYNCING.
1517
+ """
1518
+ webhookEndpoint(workflowId: String!, driveId: String): WebhookEndpointRecord
1519
+ """
1520
+ Secret metadata (label, version, status). Never the value.
1521
+ """
1522
+ secret(ref: String!): SecretRecord
1523
+ secrets: [SecretRecord!]!
1524
+ """
1525
+ The URL an OAuth2 app must register as its redirect. A bare path when
1526
+ this host does not know its public origin; resolve it against the URL
1527
+ this API is reached at. Null when the host serves no HTTP routes.
1528
+ """
1529
+ oauthRedirectUri: String
1530
+ """
1531
+ How an OAuth2 sign-in opened by startOAuth stands; null when unknown.
1532
+ """
1533
+ oauthAttempt(state: String!): OAuthAttempt
1534
+ }
1535
+
1536
+ type OAuthAttempt {
1537
+ connectionId: String!
1538
+ "PENDING | EXCHANGING | OK | ERROR"
1539
+ status: String!
1540
+ error: String
1541
+ }
1542
+
1543
+ type OAuthStart {
1544
+ state: String!
1545
+ "Where to send the user to sign in."
1546
+ authorizationUrl: String!
1547
+ redirectUri: String!
1548
+ expiresAt: String!
1549
+ }
1550
+
1551
+ """
1552
+ One action or trigger of a piece, as a workflow pins it. The engine's own
1553
+ blocks are the piece @powerhousedao/piece-core.
1554
+ """
1555
+ input BlockInput {
1556
+ pieceName: String!
1557
+ "Exact semver."
1558
+ pieceVersion: String!
1559
+ "The action or trigger name."
1560
+ name: String!
1561
+ "action | trigger"
1562
+ kind: String!
1563
+ }
1564
+
1565
+ type BlockSearchHit {
1566
+ pieceName: String!
1567
+ "The version the hit is listed at; pin to it when picking the block."
1568
+ pieceVersion: String!
1569
+ "The action or trigger name."
1570
+ name: String!
1571
+ pieceDisplayName: String!
1572
+ logoUrl: String!
1573
+ displayName: String!
1574
+ description: String!
1575
+ "action | trigger"
1576
+ kind: String!
1577
+ "Triggers only: POLLING | WEBHOOK | APP_WEBHOOK | MANUAL"
1578
+ strategy: String
1579
+ "Why this block cannot run on this reactor; null when it can."
1580
+ unsupported: String
1581
+ }
1582
+
1583
+ type BlockResolutionRecord {
1584
+ "The trigger's or step's id."
1585
+ stepId: String!
1586
+ pieceName: String!
1587
+ "The version the block pins."
1588
+ pieceVersion: String!
1589
+ "The action or trigger name."
1590
+ name: String!
1591
+ "action | trigger"
1592
+ kind: String!
1593
+ "The version that runs; null when nothing resolves."
1594
+ resolvedVersion: String
1595
+ "local | registry | activepieces | npm; null when nothing resolves."
1596
+ source: String
1597
+ "exact | compatible | fallback | installed | missing"
1598
+ match: String!
1599
+ "Why the version that runs differs from the pin, or why nothing resolves."
1600
+ note: String
1601
+ "The newest version any source offers."
1602
+ latestVersion: String
1603
+ }
1604
+
1605
+ type BlockSearchResult {
1606
+ "ready | indexing | error"
1607
+ status: String!
1608
+ hits: [BlockSearchHit!]!
1609
+ indexedPieces: Int!
1610
+ error: String
1611
+ }
1612
+
1613
+ type SecretRecord {
1614
+ ref: String!
1615
+ label: String
1616
+ version: Int!
1617
+ status: String!
1618
+ createdAt: String!
1619
+ updatedAt: String!
1620
+ }
1621
+
1622
+ type ConnectionRecord {
1623
+ id: String!
1624
+ name: String!
1625
+ connectorId: String!
1626
+ authType: String!
1627
+ status: String!
1628
+ accountLabel: String
1629
+ }
1630
+
1631
+ type ConnectionCheckResult {
1632
+ ok: Boolean!
1633
+ detail: String
1634
+ """
1635
+ From auth.getConnectionIdentifier when the check passes; otherwise the
1636
+ label the connection already holds.
1637
+ """
1638
+ accountLabel: String
1639
+ }
1640
+
1641
+ type WebhookEndpointRecord {
1642
+ workflowId: String!
1643
+ url: String!
1644
+ """
1645
+ False when the host gave the webhook service no public origin, so the url
1646
+ above is a bare path. Switchboard always gives one, falling back to
1647
+ http://localhost:<port> when PUBLIC_URL is unset.
1648
+ """
1649
+ absoluteUrl: Boolean!
1650
+ "True while the workflow is ENABLED with a valid webhook trigger"
1651
+ armed: Boolean!
1652
+ createdAt: String!
1653
+ }
1654
+
1655
+ type TriggerStateRecord {
1656
+ workflowId: String!
1657
+ pieceName: String!
1658
+ triggerName: String!
1659
+ status: String!
1660
+ intervalMs: Int!
1661
+ nextPollAt: String
1662
+ lastPollAt: String
1663
+ lastError: String
1664
+ consecutiveFailures: Int!
1665
+ "When a webhook trigger next renews its subscription; null if it never does."
1666
+ nextRenewAt: String
1667
+ "The last failed renewal, kept apart from lastError, the poll's."
1668
+ renewError: String
1669
+ renewFailures: Int!
1670
+ "The piece version the trigger armed with; null for a host-fed trigger."
1671
+ pieceVersion: String
1672
+ pieceSource: String
1673
+ versionMatch: String
1674
+ versionNote: String
1675
+ }
1676
+
1677
+ type Query {
1678
+ workflowRuntime: WorkflowRuntimeQueries!
1679
+ }
1680
+
1681
+ type WorkflowStepRunRecord {
1682
+ stepId: String!
1683
+ stepKey: String!
1684
+ pieceName: String!
1685
+ "The action the step ran, or the trigger a trigger test sampled."
1686
+ blockName: String!
1687
+ status: String!
1688
+ input: Unknown
1689
+ output: Unknown
1690
+ port: String
1691
+ error: String
1692
+ startedAt: String
1693
+ endedAt: String
1694
+ "The piece version that ran; null for an unresolved block."
1695
+ pieceVersion: String
1696
+ "local | registry | activepieces | npm"
1697
+ pieceSource: String
1698
+ "exact | compatible | fallback | installed | missing"
1699
+ versionMatch: String
1700
+ "Why the version that ran differs from the pin."
1701
+ versionNote: String
1702
+ }
1703
+
1704
+ type WorkflowRunRecord {
1705
+ id: String!
1706
+ workflowId: String!
1707
+ workflowName: String!
1708
+ workflowVersion: Int!
1709
+ triggerKind: String!
1710
+ triggerPayload: Unknown
1711
+ status: String!
1712
+ error: String
1713
+ "When the run began executing; a PENDING run's is when it was journaled."
1714
+ startedAt: String!
1715
+ endedAt: String
1716
+ rerunOf: String
1717
+ "How many warningNotes the run has; a run with any is not a plain success."
1718
+ warnings: Int!
1719
+ "Steps that ran a fallback piece version, edges on ports nothing emits."
1720
+ warningNotes: [String!]!
1721
+ steps: [WorkflowStepRunRecord!]!
1722
+ }
1723
+
1724
+ input WorkflowRunsPagingInput {
1725
+ "Page size; default 25, at most 100."
1726
+ limit: Int
1727
+ "The cursor of the previous page."
1728
+ cursor: String
1729
+ }
1730
+
1731
+ type WorkflowRunResultPage {
1732
+ items: [WorkflowRunRecord!]!
1733
+ hasNextPage: Boolean!
1734
+ "True when the page was read after a cursor."
1735
+ hasPreviousPage: Boolean!
1736
+ "Resumes after this page's last run; null for an empty page."
1737
+ cursor: String
1738
+ }
1739
+
1740
+ type WorkflowStepRun {
1741
+ stepId: String!
1742
+ key: String!
1743
+ pieceName: String!
1744
+ blockName: String!
1745
+ status: String!
1746
+ input: Unknown
1747
+ output: Unknown
1748
+ port: String
1749
+ error: String
1750
+ }
1751
+
1752
+ type WorkflowRunPayload {
1753
+ runId: String
1754
+ status: String!
1755
+ error: String
1756
+ steps: [WorkflowStepRun!]!
1757
+ }
1758
+
1759
+ type WorkflowStepTestResult {
1760
+ "Null when the test never started, e.g. an upstream block is untested."
1761
+ runId: String
1762
+ "SUCCEEDED | FAILED"
1763
+ status: String!
1764
+ output: Unknown
1765
+ error: String
1766
+ durationMs: Int!
1767
+ }
1768
+
1769
+ """
1770
+ WorkflowRuntime Mutations
1771
+ """
1772
+ type WorkflowRuntimeMutations {
1773
+ """
1774
+ Fires a workflow's manual trigger (the core piece's) and runs it to completion.
1775
+ """
1776
+ fire(workflowId: String!, payload: Unknown): WorkflowRunPayload!
1777
+ """
1778
+ Samples the draft trigger and saves it as the trigger's last test. A piece
1779
+ trigger runs its test hook (no cursor changes). Of the core piece's
1780
+ triggers, manual takes payload, schedule samples a fire now, and webhook
1781
+ waits for the next delivery to the workflow's endpoint, up to
1782
+ timeoutSeconds (default and cap 300), and that delivery runs nothing.
1783
+ """
1784
+ testTrigger(
1785
+ workflowId: String!
1786
+ payload: Unknown
1787
+ timeoutSeconds: Int
1788
+ driveId: String
1789
+ ): Unknown
1790
+ """
1791
+ Stops a webhook trigger test that is waiting; false when none was.
1792
+ """
1793
+ cancelTriggerTest(workflowId: String!): Boolean!
1794
+ """
1795
+ Runs one draft step against the latest test outputs of the blocks it
1796
+ reads, journaled as a "test" run and noted as the step's lastTest.
1797
+ driveId waits for an unsynced workflow, as for webhookEndpoint.
1798
+ """
1799
+ testStep(
1800
+ workflowId: String!
1801
+ stepId: String!
1802
+ driveId: String
1803
+ ): WorkflowStepTestResult!
1804
+ """
1805
+ Resumes a FAILED run: succeeded steps replay from the journal,
1806
+ execution restarts at the failure. Produces a new run.
1807
+ """
1808
+ rerun(runId: String!): WorkflowRunPayload!
1809
+ """
1810
+ Mints a managed secret and returns its ref; the value is stored
1811
+ encrypted and is never readable back over any API.
1812
+ """
1813
+ createSecret(value: String!, label: String): SecretRecord!
1814
+ """
1815
+ Replaces the value behind an existing ref; documents stay untouched.
1816
+ """
1817
+ rotateSecret(ref: String!, value: String!): SecretRecord!
1818
+ """
1819
+ Tombstones a secret; its value becomes unrecoverable.
1820
+ """
1821
+ deleteSecret(ref: String!): Boolean!
1822
+ """
1823
+ Runs the piece's auth.validate against the connection's credentials,
1824
+ then auth.getConnectionIdentifier for the account label, and records the
1825
+ outcome on the connection document.
1826
+ """
1827
+ checkConnection(connectionId: String!): ConnectionCheckResult!
1828
+ """
1829
+ Opens an OAuth2 sign-in with the connection's own app (client_id in its
1830
+ config, client_secret in its secretRefs). redirectUri is needed only when
1831
+ oauthRedirectUri is a bare path. With returnUrl, which must be on the
1832
+ caller's origin, the callback sends the browser back there with
1833
+ ?ph_oauth=<state> instead of closing the window.
1834
+ """
1835
+ startOAuth(
1836
+ connectionId: String!
1837
+ redirectUri: String
1838
+ returnUrl: String
1839
+ ): OAuthStart!
1840
+ }
1841
+
1842
+ type Mutation {
1843
+ workflowRuntime: WorkflowRuntimeMutations!
1844
+ }
1845
+ `;
1846
+ //#endregion
1847
+ //#region src/workflow/subgraph.ts
1848
+ /** The runtime's read/write surface. The runtime itself is composed by the
1849
+ * host, so the subgraph only serves what it is handed. */
1850
+ function createWorkflowRuntimeSubgraph(runtime, oauth) {
1851
+ return class WorkflowRuntimeSubgraph extends BaseSubgraph {
1852
+ name = "workflow-runtime";
1853
+ typeDefs = schema;
1854
+ resolvers = getResolvers(runtime, this.authorizationService, oauth);
1855
+ additionalContextFields = {};
1856
+ };
1857
+ }
1858
+ //#endregion
1859
+ //#region src/workflow-runtime.mts
1860
+ /** The npm name the workflow package owns: its HTTP namespace and its models. */
1861
+ const WORKFLOW_PACKAGE_NAME = "@powerhousedao/workflow";
1862
+ /** The env var and OpenFeature flag key that turns workflows on. */
1863
+ const PH_WORKFLOWS_ENABLED = "PH_WORKFLOWS_ENABLED";
1864
+ /** Precedence, unchanged from the reactor-api resolver this replaces: the
1865
+ * host's option, then PH_WORKFLOWS_ENABLED, then the config file, then off. */
1866
+ async function resolveWorkflowsEnabled({ featureFlags, override, configEnabled = false, env = process.env }) {
1867
+ if (override !== void 0) return override;
1868
+ const raw = env[PH_WORKFLOWS_ENABLED]?.trim();
1869
+ if (raw === "1") return true;
1870
+ if (raw === "0") return false;
1871
+ return featureFlags.getBooleanValue(PH_WORKFLOWS_ENABLED, configEnabled);
1872
+ }
1873
+ async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao/workflow/document-models")) {
1874
+ try {
1875
+ await load();
1876
+ } catch (error) {
1877
+ throw new Error(`Workflows are enabled but ${WORKFLOW_PACKAGE_NAME} could not be loaded`, { cause: error });
1878
+ }
1879
+ }
1880
+ function readAssertion(authorizationService, reactorClient) {
1881
+ const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
1882
+ const hostCanRead = async (identifier, ctx) => {
1883
+ if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
1884
+ if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
1885
+ let documentId;
1886
+ try {
1887
+ documentId = await resolveCanonical(identifier);
1888
+ } catch {
1889
+ throw new ForbiddenError();
1890
+ }
1891
+ if (!await authorizationService.canRead(documentId, ctx.user?.address)) throw new ForbiddenError("to read this document");
1892
+ };
1893
+ return async (identifier, caller) => {
1894
+ const ctx = caller;
1895
+ await hostCanRead(identifier, ctx);
1896
+ let served;
1897
+ try {
1898
+ served = await reactorClient.isServed(identifier, { subject: callerSubject(ctx.user) });
1899
+ } catch {
1900
+ throw new ForbiddenError();
1901
+ }
1902
+ if (!served) throw new ForbiddenError("to read this document");
1903
+ };
1904
+ }
1905
+ /** The same, for a design-time call that writes what it names. */
1906
+ function writeAssertion(authorizationService, reactorClient) {
1907
+ const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
1908
+ return async (identifier, caller) => {
1909
+ const ctx = caller;
1910
+ if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
1911
+ if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
1912
+ let documentId;
1913
+ try {
1914
+ documentId = await resolveCanonical(identifier);
1915
+ } catch {
1916
+ throw new ForbiddenError();
1917
+ }
1918
+ if (!await authorizationService.canWrite(documentId, ctx.user?.address)) throw new ForbiddenError("to write this document");
1919
+ };
1920
+ }
1921
+ /** Whether the workflow document really references the attachment. A step
1922
+ * carries no caller, so the relationship is the whole check. */
1923
+ function attachmentRefCheck(deps) {
1924
+ const resolveCanonical = createCanonicalDocumentIdResolver(deps.reactorClient);
1925
+ const references = deps.attachmentReferences;
1926
+ const projection = deps.attachmentReferenceProjection;
1927
+ return async (documentId, ref) => {
1928
+ if (!references || projection?.status !== "available") return false;
1929
+ let parsed;
1930
+ try {
1931
+ parsed = parseRef(ref);
1932
+ } catch {
1933
+ return false;
1934
+ }
1935
+ if (parsed.version !== 1) return false;
1936
+ const canonicalRef = createRef(parsed.hash.toLowerCase());
1937
+ try {
1938
+ return await references.hasReference(await resolveCanonical(documentId), canonicalRef);
1939
+ } catch {
1940
+ return false;
1941
+ }
1942
+ };
1943
+ }
1944
+ async function registerWorkflowTriggersReadModel(engine, runtime, clientModule) {
1945
+ const reactorModule = clientModule?.reactorModule;
1946
+ if (!reactorModule) return {
1947
+ status: "unavailable",
1948
+ reason: "in-process-reactor-module-unavailable"
1949
+ };
1950
+ const coordinator = reactorModule.readModelCoordinator;
1951
+ if (!supportsLiveReadModelRegistration(coordinator)) return {
1952
+ status: "unavailable",
1953
+ reason: "live-read-model-registration-unsupported"
1954
+ };
1955
+ const readModel = new engine.WorkflowTriggersReadModel(reactorModule.database.withSchema(REACTOR_SCHEMA), reactorModule.operationIndex, reactorModule.writeCache, reactorModule.processorManagerConsistencyTracker, runtime);
1956
+ await readModel.init();
1957
+ coordinator.addReadModel(readModel, engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE);
1958
+ return { status: "available" };
1959
+ }
1960
+ function bindPackagePieces(registry, source) {
1961
+ const apply = (byPackage) => {
1962
+ registry.setPieces([...byPackage.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).flatMap(([, pieces]) => pieces));
1963
+ };
1964
+ apply(source.getPieces());
1965
+ source.onPiecesChange(apply);
1966
+ }
1967
+ async function composeWorkflowRuntime(deps) {
1968
+ const load = deps.load ?? (() => import("@powerhousedao/reactor-workflow"));
1969
+ let engine;
1970
+ try {
1971
+ engine = await load();
1972
+ } catch (error) {
1973
+ throw new Error("Workflows are enabled but @powerhousedao/reactor-workflow could not be loaded", { cause: error });
1974
+ }
1975
+ engine.setPieceRegistryUrl(deps.pieceRegistryUrl);
1976
+ if (deps.pieces) bindPackagePieces(engine.packagePieces, deps.pieces);
1977
+ const runtime = engine.createWorkflowRuntime({
1978
+ relationalDb: deps.relationalDb,
1979
+ secretsKeyFile: deps.secretsKeyFile,
1980
+ reactorClient: deps.reactorClient,
1981
+ assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
1982
+ subjectOf: (caller) => callerSubject(caller.user),
1983
+ assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
1984
+ webhooks: deps.webhooks,
1985
+ attachments: deps.attachments,
1986
+ canReadAttachmentRef: attachmentRefCheck(deps),
1987
+ logger: deps.logger
1988
+ });
1989
+ const triggers = await registerWorkflowTriggersReadModel(engine, runtime, deps.clientModule);
1990
+ if (triggers.status === "available") deps.logger.info(`Workflow trigger read model registered (${engine.WORKFLOW_TRIGGERS_READ_MODEL}, ${engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE})`);
1991
+ 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);
1992
+ let stopped = false;
1993
+ const oauthCallback = deps.http ? registerOAuthCallback(deps.http, runtime) : void 0;
1994
+ return {
1995
+ subgraph: createWorkflowRuntimeSubgraph(runtime, deps.http ? { callbackUrl: callbackUrlOf(deps.http) } : void 0),
1996
+ triggers,
1997
+ async start() {
1998
+ await runtime.registerWebhookEndpoint();
1999
+ runtime.startTriggerSupervisor();
2000
+ },
2001
+ stop() {
2002
+ if (stopped) return Promise.resolve();
2003
+ stopped = true;
2004
+ oauthCallback?.dispose();
2005
+ runtime.shutdown();
2006
+ return Promise.resolve();
2007
+ }
2008
+ };
2009
+ }
2010
+ //#endregion
868
2011
  //#region src/pglite-dialect.ts
869
2012
  var ClosablePGliteDialect = class extends PGliteDialect {
870
2013
  #pglite;
@@ -1003,6 +2146,26 @@ function resolveReactorFeatureFlags(env) {
1003
2146
  };
1004
2147
  }
1005
2148
  //#endregion
2149
+ //#region src/create-signature-policy.mts
2150
+ /**
2151
+ * What new documents are created as, from CREATE_SIGNATURE_POLICY. Without a
2152
+ * signer this switchboard cannot write to a v2-required document, so it falls
2153
+ * back to legacy and says so.
2154
+ */
2155
+ function resolveCreateSignaturePolicy(env, options) {
2156
+ const raw = env.CREATE_SIGNATURE_POLICY?.trim();
2157
+ let policy = DEFAULT_SIGNATURE_POLICY;
2158
+ if (raw) {
2159
+ if (!isSignaturePolicy(raw)) throw new Error(`CREATE_SIGNATURE_POLICY must be "legacy" or "v2-required", got "${raw}"`);
2160
+ policy = raw;
2161
+ }
2162
+ if (policy === "v2-required" && !options.hasSigner) {
2163
+ 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.");
2164
+ return "legacy";
2165
+ }
2166
+ return policy;
2167
+ }
2168
+ //#endregion
1006
2169
  //#region src/renown.ts
1007
2170
  const logger = childLogger(["switchboard", "renown"]);
1008
2171
  /**
@@ -1025,12 +2188,94 @@ async function initRenown(options = {}) {
1025
2188
  * Get the signer config for the given renown instance.
1026
2189
  *
1027
2190
  * @param renown - The renown instance
1028
- * @param requireSignature - If true, unsigned actions are rejected
2191
+ * @param keypairPath - Where `initRenown` loaded the key from
1029
2192
  */
1030
- function getRenownSignerConfig(renown, requireSignature) {
2193
+ function getRenownSignerConfig(renown, keypairPath) {
1031
2194
  return {
1032
2195
  signer: renown.signer,
1033
- verifier: createSignatureVerifier(requireSignature)
2196
+ workerSigner: getRenownWorkerSignerSpec(renown, keypairPath)
2197
+ };
2198
+ }
2199
+ /**
2200
+ * What a pooled executor worker imports to sign as this switchboard: it
2201
+ * reloads the key `initRenown` stored, and takes the user known at boot.
2202
+ */
2203
+ function getRenownWorkerSignerSpec(renown, keypairPath) {
2204
+ const { signer } = renown;
2205
+ const user = signer.user;
2206
+ return {
2207
+ module: {
2208
+ filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
2209
+ exportName: "createNodeRenownSigner"
2210
+ },
2211
+ initArgs: {
2212
+ appName: signer.app?.name ?? "switchboard",
2213
+ keypairPath: resolve(keypairPath ?? DEFAULT_KEYPAIR_PATH),
2214
+ ...signer.app?.key ? { did: signer.app.key } : {},
2215
+ ...user ? { user: {
2216
+ address: user.address,
2217
+ networkId: user.networkId,
2218
+ chainId: user.chainId
2219
+ } } : {}
2220
+ }
2221
+ };
2222
+ }
2223
+ /**
2224
+ * Admits a key as a signer for an address a Renown credential binds it to; a
2225
+ * `self` source has no worker spec. In process, the switchboard's own key is
2226
+ * matched against its current user; the worker spec carries the user known now.
2227
+ */
2228
+ async function getRenownTrustPolicyConfig(trustSource, renown) {
2229
+ const ownSigner = renown?.signer;
2230
+ if (trustSource.source === "self") return { trustPolicy: createRenownTrustPolicy({
2231
+ switchboard: trustSource.request,
2232
+ ownSigner
2233
+ }) };
2234
+ const switchboardUrl = await resolveSwitchboardEndpoint({
2235
+ switchboardUrl: trustSource.switchboardUrl,
2236
+ baseUrl: trustSource.renownUrl
2237
+ });
2238
+ const options = {
2239
+ ...switchboardUrl ? { switchboard: switchboardUrl } : {},
2240
+ ...trustSource.renownUrl ? { renownUrl: trustSource.renownUrl } : {}
2241
+ };
2242
+ const self = ownIdentity(renown);
2243
+ return {
2244
+ trustPolicy: createRenownTrustPolicy({
2245
+ ...options,
2246
+ ownSigner
2247
+ }),
2248
+ workerTrustPolicy: {
2249
+ module: {
2250
+ filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
2251
+ exportName: "createRenownTrustPolicy"
2252
+ },
2253
+ initArgs: {
2254
+ ...options,
2255
+ ...self ? { self } : {}
2256
+ }
2257
+ }
2258
+ };
2259
+ }
2260
+ /**
2261
+ * Fails the boot when pooled workers would refuse every signed write the
2262
+ * in-process executor accepts: a `self` Renown source gives workers no trust
2263
+ * policy spec, so under authEnforcement they apply the default.
2264
+ */
2265
+ function assertWorkerTrustPolicy(config) {
2266
+ 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).");
2267
+ }
2268
+ function ownIdentity(renown) {
2269
+ const key = renown?.signer.app?.key;
2270
+ if (!key) return;
2271
+ const user = renown.signer.user;
2272
+ return {
2273
+ key,
2274
+ user: {
2275
+ address: user?.address ?? "",
2276
+ networkId: user?.networkId ?? "",
2277
+ chainId: user?.chainId ?? 0
2278
+ }
1034
2279
  };
1035
2280
  }
1036
2281
  //#endregion
@@ -1041,8 +2286,6 @@ setLogLevel(LogLevel);
1041
2286
  dotenv.config();
1042
2287
  const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
1043
2288
  const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
1044
- const REQUIRE_SIGNATURES = "REQUIRE_SIGNATURES";
1045
- const REQUIRE_SIGNATURES_DEFAULT = false;
1046
2289
  const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
1047
2290
  const PORT_FALLBACK_ATTEMPTS = 20;
1048
2291
  const PGLITE_FLUSH_INTERVAL_MS = (() => {
@@ -1077,6 +2320,13 @@ function isPortAvailable(port) {
1077
2320
  function resolveConfigPath(configFile) {
1078
2321
  return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
1079
2322
  }
2323
+ function readConfigWorkflowsEnabled(configPath) {
2324
+ try {
2325
+ return getConfig(configPath).workflows?.enabled ?? false;
2326
+ } catch {
2327
+ return false;
2328
+ }
2329
+ }
1080
2330
  async function resolveServerPort(requested, strictPort, logger) {
1081
2331
  if (strictPort) return requested;
1082
2332
  for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
@@ -1189,6 +2439,11 @@ async function initServer(serverPort, options, renown, renownConfig) {
1189
2439
  }
1190
2440
  if (workerPool) {
1191
2441
  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");
2442
+ assertWorkerTrustPolicy({
2443
+ workers: workerPool.numWorkers,
2444
+ authEnforcement: resolveReactorFeatureFlags(process.env).flags.authEnforcement === true,
2445
+ renownSource: renownConfig.source
2446
+ });
1192
2447
  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
2448
  }
1194
2449
  let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
@@ -1224,7 +2479,13 @@ async function initServer(serverPort, options, renown, renownConfig) {
1224
2479
  reactorLogger.error(`Projection worker ${shardId} died; shutting down so the supervisor restarts a healthy process`, reason);
1225
2480
  process.kill(process.pid, "SIGTERM");
1226
2481
  };
2482
+ const workflowsEnabled = options.workflows?.enabled === true;
2483
+ if (workflowsEnabled) {
2484
+ await assertWorkflowPackageLoadable();
2485
+ if (!packages.includes("@powerhousedao/workflow")) packages.push(WORKFLOW_PACKAGE_NAME);
2486
+ }
1227
2487
  let ownedReactorModule;
2488
+ let localRenownRequest;
1228
2489
  const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
1229
2490
  if (options.reactor) {
1230
2491
  const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
@@ -1264,7 +2525,19 @@ async function initServer(serverPort, options, renown, renownConfig) {
1264
2525
  } : void 0,
1265
2526
  documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
1266
2527
  logger: reactorLogger,
1267
- signer: renown ? getRenownSignerConfig(renown, options.identity?.requireSignatures) : void 0
2528
+ signer: renown ? getRenownSignerConfig(renown, options.identity?.keypairPath) : void 0,
2529
+ createSignaturePolicy: resolveCreateSignaturePolicy(process.env, {
2530
+ hasSigner: renown !== null,
2531
+ logger
2532
+ }),
2533
+ trustPolicy: reactorFeatureFlags.authEnforcement ? await getRenownTrustPolicyConfig(renownConfig.source === "self" ? {
2534
+ source: "self",
2535
+ request: (query, variables) => localRenownRequest ? localRenownRequest(query, variables) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet"))
2536
+ } : {
2537
+ source: "remote",
2538
+ renownUrl: renownConfig.url,
2539
+ switchboardUrl: renownConfig.switchboardUrl
2540
+ }, renown) : void 0
1268
2541
  });
1269
2542
  if (workerPool) {
1270
2543
  if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
@@ -1313,12 +2586,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
1313
2586
  new ReactorInstrumentation(module.reactorModule).start();
1314
2587
  reactorLogger.info("Reactor metrics instrumentation started");
1315
2588
  }
1316
- driveNodeView = new DriveNodeView(baseKysely.withSchema(REACTOR_SCHEMA));
2589
+ const reactorDriveSchemaDb = baseKysely.withSchema(REACTOR_SCHEMA);
2590
+ driveNodeView = new DriveNodeView(reactorDriveSchemaDb);
1317
2591
  const reactorDriveClient = new ReactorDriveClient({
1318
2592
  reactor: module.client,
1319
2593
  readModel: driveNodeView
1320
2594
  });
1321
2595
  ownedReactorModule = module;
2596
+ if (options.fatalErrorShutdown) installFatalErrorShutdown(logger);
1322
2597
  return {
1323
2598
  module,
1324
2599
  reactorDriveClient,
@@ -1401,7 +2676,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
1401
2676
  await abortBoot(api);
1402
2677
  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
2678
  }
1404
- localCredentialCheck = createLocalCredentialVerifier((query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables), { onError: (error) => logger.error("Renown read model query failed: @error", error) });
2679
+ localRenownRequest = (query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables);
2680
+ localCredentialCheck = createLocalCredentialVerifier(localRenownRequest, { onError: (error) => logger.error("Renown read model query failed: @error", error) });
1405
2681
  logger.info("Renown credentials will be verified against this switchboard's own renown read model");
1406
2682
  }
1407
2683
  registerAttachmentRoutes(api);
@@ -1409,6 +2685,45 @@ async function initServer(serverPort, options, renown, renownConfig) {
1409
2685
  if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
1410
2686
  const { client, graphqlManager, documentModelRegistry } = api;
1411
2687
  const lateSubgraphs = [];
2688
+ let workflows;
2689
+ if (workflowsEnabled) {
2690
+ workflows = await composeWorkflowRuntime({
2691
+ reactorClient: client,
2692
+ clientModule: options.reactor ?? ownedReactorModule,
2693
+ relationalDb: api.relationalDb,
2694
+ secretsKeyFile: readModelPgliteDir === null ? false : void 0,
2695
+ attachments: createAttachmentClient(api.attachments.service),
2696
+ attachmentReferences: api.attachmentReferenceIndex.store,
2697
+ attachmentReferenceProjection: api.attachmentReferenceProjection,
2698
+ webhooks: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME).webhooks,
2699
+ http: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME),
2700
+ authorizationService: api.authorizationService,
2701
+ pieces: api.packageManager,
2702
+ pieceRegistryUrl: registryUrl,
2703
+ logger: logger.child(["workflow-runtime"])
2704
+ });
2705
+ const WorkflowRuntimeSubgraph = workflows.subgraph;
2706
+ const workflowSubgraph = new WorkflowRuntimeSubgraph({
2707
+ reactorClient: client,
2708
+ http: graphqlManager.scopeForPackage(WORKFLOW_PACKAGE_NAME),
2709
+ relationalDb: api.relationalDb,
2710
+ analyticsStore: void 0,
2711
+ graphqlManager,
2712
+ syncManager: api.syncManager,
2713
+ authorizationService: graphqlManager.getAuthorizationService(),
2714
+ path: graphqlManager.getBasePath()
2715
+ });
2716
+ lateSubgraphs.push(graphqlManager.registerSubgraphInstance(workflowSubgraph, "graphql", false).catch((error) => {
2717
+ logger.error("Failed to register workflow-runtime subgraph: @error", error);
2718
+ }));
2719
+ await workflows.start();
2720
+ logger.info("Workflow runtime started");
2721
+ }
2722
+ const shutdown = async () => {
2723
+ await workflows?.stop();
2724
+ await api.dispose();
2725
+ };
2726
+ apiRef.current = { dispose: shutdown };
1412
2727
  if (httpLoader) {
1413
2728
  const packageManagementService = new PackageManagementService({
1414
2729
  defaultRegistryUrl: registryUrl,
@@ -1417,7 +2732,9 @@ async function initServer(serverPort, options, renown, renownConfig) {
1417
2732
  packageManager: api.packageManager
1418
2733
  });
1419
2734
  packageManagementService.setOnModelsChanged(() => {
1420
- graphqlManager.regenerateDocumentModelSubgraphs().catch(logger.error);
2735
+ graphqlManager.regenerateDocumentModelSubgraphs().catch((error) => {
2736
+ logger.error("Failed to regenerate document model subgraphs: @error", error);
2737
+ });
1421
2738
  });
1422
2739
  const packagesSubgraph = new PackagesSubgraph({
1423
2740
  relationalDb: void 0,
@@ -1436,10 +2753,15 @@ async function initServer(serverPort, options, renown, renownConfig) {
1436
2753
  }
1437
2754
  if (driveNodeView) {
1438
2755
  graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
2756
+ const authorizationService = graphqlManager.getAuthorizationService();
1439
2757
  const reactorDriveSubgraph = {
1440
2758
  name: "reactor-drive",
1441
2759
  path: graphqlManager.getBasePath(),
1442
- resolvers: createReactorDriveResolvers(),
2760
+ resolvers: createReactorDriveResolvers({
2761
+ reactorClient: client,
2762
+ readModel: driveNodeView,
2763
+ hostCanRead: (documentId, address) => authorizationService.canRead(documentId, address)
2764
+ }),
1443
2765
  typeDefs: reactorDriveSubgraphTypeDefs,
1444
2766
  reactorClient: client,
1445
2767
  relationalDb: void 0
@@ -1490,9 +2812,11 @@ async function initServer(serverPort, options, renown, renownConfig) {
1490
2812
  reactor: client,
1491
2813
  attachmentService,
1492
2814
  attachmentReferenceProjection: api.attachmentReferenceProjection,
2815
+ workflowTriggers: workflows?.triggers,
2816
+ workflowsEnabled,
1493
2817
  renown,
1494
2818
  port: serverPort,
1495
- shutdown: () => api.dispose()
2819
+ shutdown
1496
2820
  };
1497
2821
  }
1498
2822
  /**
@@ -1519,16 +2843,21 @@ const startSwitchboard = async (options = {}) => {
1519
2843
  const featureFlags = await initFeatureFlags();
1520
2844
  const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
1521
2845
  options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
1522
- const requireSignatures = options.identity?.requireSignatures ?? await featureFlags.getBooleanValue(REQUIRE_SIGNATURES, REQUIRE_SIGNATURES_DEFAULT);
2846
+ const configPathForFlags = resolveConfigPath(options.configFile);
2847
+ const workflowsEnabled = await resolveWorkflowsEnabled({
2848
+ featureFlags,
2849
+ override: options.workflows?.enabled,
2850
+ configEnabled: readConfigWorkflowsEnabled(configPathForFlags)
2851
+ });
2852
+ options.workflows = { enabled: workflowsEnabled };
1523
2853
  const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
1524
2854
  options.identity = {
1525
2855
  ...options.identity,
1526
- requireSignatures,
1527
2856
  baseUrl: options.identity?.baseUrl ?? renownConfig.url
1528
2857
  };
1529
2858
  logger.info("Feature flags: @flags", JSON.stringify({
1530
2859
  DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
1531
- REQUIRE_SIGNATURES: requireSignatures
2860
+ PH_WORKFLOWS_ENABLED: workflowsEnabled
1532
2861
  }, null, 2));
1533
2862
  let renown = null;
1534
2863
  try {
@@ -1545,9 +2874,9 @@ const startSwitchboard = async (options = {}) => {
1545
2874
  throw e;
1546
2875
  }
1547
2876
  };
1548
- if (import.meta.main) await startSwitchboard();
2877
+ if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
1549
2878
  //#endregion
1550
2879
  export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
1551
2880
 
1552
- //# sourceMappingURL=server-B2r6AQ4Q.mjs.map
1553
- //# debugId=0a17ca6d-016f-53aa-9705-cef103da6f09
2881
+ //# sourceMappingURL=server-CKKn0aXV.mjs.map
2882
+ //# debugId=ec5120f6-92b3-53b4-bab1-94d88478784d