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

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 (90) 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 +45 -0
  24. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  25. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  26. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  27. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  28. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  29. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  30. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  31. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  32. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  33. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  34. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  35. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  36. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  37. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  38. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  39. package/.tsbuild/vitest.config.d.ts.map +1 -1
  40. package/Auth.md +94 -0
  41. package/CHANGELOG.md +272 -0
  42. package/README.md +14 -0
  43. package/dist/index.d.mts +2 -1
  44. package/dist/index.mjs +6 -6
  45. package/dist/index.mjs.map +1 -1
  46. package/dist/install-packages.d.mts +2 -1
  47. package/dist/install-packages.mjs +2 -2
  48. package/dist/install-packages.mjs.map +1 -1
  49. package/dist/migrate.d.mts +2 -1
  50. package/dist/migrate.mjs +4 -3
  51. package/dist/migrate.mjs.map +1 -1
  52. package/dist/{server-B2r6AQ4Q.mjs → server-Cf0DB8sW.mjs} +1010 -65
  53. package/dist/server-Cf0DB8sW.mjs.map +1 -0
  54. package/dist/server.d.mts +74 -28
  55. package/dist/server.d.mts.map +1 -1
  56. package/dist/server.mjs +3 -4
  57. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  58. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  59. package/dist/utils.d.mts +4 -5
  60. package/dist/utils.d.mts.map +1 -1
  61. package/dist/utils.mjs +3 -3
  62. package/package.json +21 -17
  63. package/test/attachments/auth.test.ts +85 -0
  64. package/test/attachments/byte-grants.test.ts +287 -0
  65. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  66. package/test/attachments/download-target.test.ts +194 -31
  67. package/test/attachments/index.test.ts +34 -3
  68. package/test/attachments/routes-integration.test.ts +59 -4
  69. package/test/attachments/routes.test.ts +165 -23
  70. package/test/attachments/service-config.test.ts +13 -3
  71. package/test/attachments/url-signer.test.ts +105 -0
  72. package/test/boot-create-signature-policy.test.ts +108 -0
  73. package/test/boot-without-renown.test.ts +81 -0
  74. package/test/boot-without-require-signatures.test.ts +74 -0
  75. package/test/boot-worker-trust-policy.test.ts +90 -0
  76. package/test/builder-defaults.test.ts +80 -0
  77. package/test/create-signature-policy.test.ts +69 -0
  78. package/test/fatal-shutdown.test.ts +102 -0
  79. package/test/renown-trust-policy.test.ts +315 -0
  80. package/test/workflow/harness.ts +237 -0
  81. package/test/workflow/resolvers.test.ts +105 -0
  82. package/test/workflow/webhook-dedupe.test.ts +295 -0
  83. package/test/workflow/webhook-signed.test.ts +509 -0
  84. package/test/workflow/webhook-unsigned.test.ts +332 -0
  85. package/test/workflow-read-gate.test.ts +145 -0
  86. package/test/workflow-runtime.test.ts +472 -0
  87. package/tsconfig.json +6 -0
  88. package/tsdown.config.ts +3 -1
  89. package/dist/server-B2r6AQ4Q.mjs.map +0 -1
  90. 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]="49b28650-fe00-57f4-844b-978a35d80e91")}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 } 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,520 @@ async function initFeatureFlags() {
865
1112
  return OpenFeature.getClient();
866
1113
  }
867
1114
  //#endregion
1115
+ //#region src/workflow/resolvers.ts
1116
+ function requireAdmin(authorizationService, ctx) {
1117
+ if (!authorizationService.isSupremeAdmin(ctx.user?.address)) throw new GraphQLError("Admin access required");
1118
+ }
1119
+ function parseJson(value) {
1120
+ if (value === null) return null;
1121
+ try {
1122
+ return JSON.parse(value);
1123
+ } catch {
1124
+ return value;
1125
+ }
1126
+ }
1127
+ function toStepRecord(row) {
1128
+ return {
1129
+ stepId: row.step_id,
1130
+ stepKey: row.step_key,
1131
+ blockType: row.block_type,
1132
+ status: row.status,
1133
+ input: parseJson(row.input),
1134
+ output: parseJson(row.output),
1135
+ port: row.port,
1136
+ error: row.error,
1137
+ startedAt: row.started_at,
1138
+ endedAt: row.ended_at
1139
+ };
1140
+ }
1141
+ function toRunRecord(row, steps) {
1142
+ return {
1143
+ id: row.id,
1144
+ workflowId: row.workflow_id,
1145
+ workflowName: row.workflow_name,
1146
+ workflowVersion: row.workflow_version,
1147
+ triggerKind: row.trigger_kind,
1148
+ triggerPayload: parseJson(row.trigger_payload),
1149
+ status: row.status,
1150
+ error: row.error,
1151
+ startedAt: row.started_at,
1152
+ endedAt: row.ended_at,
1153
+ rerunOf: row.rerun_of,
1154
+ steps: steps.map(toStepRecord)
1155
+ };
1156
+ }
1157
+ const getResolvers = (runtime, authorizationService) => {
1158
+ return {
1159
+ Query: { workflowRuntime: () => ({}) },
1160
+ WorkflowRuntimeQueries: {
1161
+ health: () => "ok",
1162
+ blockDescriptor: (_parent, args) => runtime.blockDescriptor(args.blockType),
1163
+ blockOptions: (_parent, args, ctx) => runtime.blockOptions(args.blockType, args.propName, args.input, args.connectionId ?? void 0, ctx, args.searchValue ?? void 0),
1164
+ pieceCatalog: () => runtime.pieceCatalog(),
1165
+ pieceActions: (_parent, args) => runtime.pieceActions(args.packageName),
1166
+ pieceTriggers: (_parent, args) => runtime.pieceTriggers(args.packageName),
1167
+ blockOutputTree: (_parent, args) => runtime.blockOutputTree(args.blockType, args.config),
1168
+ pieceDetail: (_parent, args) => runtime.pieceDetail(args.packageName),
1169
+ searchBlocks: (_parent, args) => runtime.searchBlocks(args.query, args.limit ?? void 0),
1170
+ connections: (_parent, _args, ctx) => runtime.connections(ctx),
1171
+ webhookEndpoint: (_parent, args, ctx) => runtime.webhookEndpoint(args.workflowId, ctx),
1172
+ secret: async (_parent, args) => {
1173
+ try {
1174
+ return await (await runtime.secrets()).stat(args.ref);
1175
+ } catch {
1176
+ return null;
1177
+ }
1178
+ },
1179
+ secrets: async () => (await runtime.secrets()).list(),
1180
+ triggerStates: async (_parent, _args, ctx) => (await runtime.triggerStates(ctx)).map((row) => ({
1181
+ workflowId: row.workflow_id,
1182
+ blockType: row.block_type,
1183
+ status: row.status,
1184
+ intervalMs: row.interval_ms,
1185
+ nextPollAt: row.next_poll_at,
1186
+ lastPollAt: row.last_poll_at,
1187
+ lastError: row.last_error,
1188
+ consecutiveFailures: row.consecutive_failures
1189
+ })),
1190
+ runs: async (_parent, args, ctx) => (await runtime.runs(args, ctx)).map((record) => toRunRecord(record.row, record.steps)),
1191
+ run: async (_parent, args, ctx) => {
1192
+ const record = await runtime.run(args.id, ctx);
1193
+ return record ? toRunRecord(record.row, record.steps) : null;
1194
+ }
1195
+ },
1196
+ Mutation: { workflowRuntime: () => ({}) },
1197
+ WorkflowRuntimeMutations: {
1198
+ fire: (_parent, args, ctx) => runtime.fire(args.workflowId, args.payload, "manual", void 0, ctx),
1199
+ testTrigger: (_parent, args, ctx) => runtime.testTrigger(args.workflowId, ctx),
1200
+ rerun: (_parent, args, ctx) => runtime.rerun(args.runId, ctx),
1201
+ createSecret: async (_parent, args, ctx) => {
1202
+ requireAdmin(authorizationService, ctx);
1203
+ return (await runtime.secrets()).create({
1204
+ value: args.value,
1205
+ label: args.label ?? void 0
1206
+ });
1207
+ },
1208
+ rotateSecret: async (_parent, args, ctx) => {
1209
+ requireAdmin(authorizationService, ctx);
1210
+ return (await runtime.secrets()).rotate(args.ref, args.value);
1211
+ },
1212
+ deleteSecret: async (_parent, args, ctx) => {
1213
+ requireAdmin(authorizationService, ctx);
1214
+ await (await runtime.secrets()).delete(args.ref);
1215
+ return true;
1216
+ },
1217
+ checkConnection: (_parent, args, ctx) => runtime.checkConnection(args.connectionId, ctx)
1218
+ }
1219
+ };
1220
+ };
1221
+ //#endregion
1222
+ //#region src/workflow/schema.ts
1223
+ const schema = gql`
1224
+ """
1225
+ WorkflowRuntime Queries
1226
+ """
1227
+ type WorkflowRuntimeQueries {
1228
+ health: String!
1229
+ """
1230
+ Persisted runs, newest first. Scope them to one workflow, or to every
1231
+ workflow a drive holds; workflowId wins when both are given.
1232
+ """
1233
+ runs(workflowId: String, driveId: String, limit: Int): [WorkflowRunRecord!]!
1234
+ run(id: String!): WorkflowRunRecord
1235
+ """
1236
+ Action descriptor (props, auth) for a piece block type; null for core blocks.
1237
+ """
1238
+ blockDescriptor(blockType: String!): Unknown
1239
+ """
1240
+ Resolves a dynamic prop against the current config values: a DROPDOWN
1241
+ yields { options, placeholder, disabled }, a DYNAMIC prop yields the
1242
+ resolved sub-property descriptor list.
1243
+ """
1244
+ blockOptions(
1245
+ blockType: String!
1246
+ propName: String!
1247
+ input: Unknown
1248
+ connectionId: String
1249
+ "What the author typed, for a DROPDOWN declared with refreshOnSearch."
1250
+ searchValue: String
1251
+ ): Unknown
1252
+ """
1253
+ All published Activepieces pieces with at least one action.
1254
+ """
1255
+ pieceCatalog: Unknown
1256
+ """
1257
+ A piece's actions, each with a ready-to-use blockType.
1258
+ """
1259
+ pieceActions(packageName: String!): Unknown
1260
+ """
1261
+ A piece's triggers, each with a ready-to-use "#trigger:" blockType.
1262
+ """
1263
+ pieceTriggers(packageName: String!): Unknown
1264
+ """
1265
+ Full piece detail (PieceMetadataModel-shaped), verbatim from the cloud API.
1266
+ """
1267
+ pieceDetail(packageName: String!): Unknown
1268
+ """
1269
+ Action/trigger name search across the catalog. The index builds lazily on
1270
+ first use; poll while status is "indexing".
1271
+ """
1272
+ searchBlocks(query: String!, limit: Int): BlockSearchResult!
1273
+ """
1274
+ Health of every registered piece trigger (poll schedule, errors).
1275
+ """
1276
+ triggerStates: [TriggerStateRecord!]!
1277
+ """
1278
+ Authored output shape of a block (SDL / outputSchema / sampleData).
1279
+ """
1280
+ blockOutputTree(blockType: String!, config: Unknown): Unknown
1281
+ """
1282
+ Every powerhouse/connection document, for connection pickers.
1283
+ """
1284
+ connections: [ConnectionRecord!]!
1285
+ """
1286
+ The webhook endpoint for a workflow: the URL to hand the provider. Minted
1287
+ on first ask, whether or not the workflow is armed — an author needs the
1288
+ URL before enabling, and the armed field carries that difference. Null
1289
+ only when the host has no webhook service.
1290
+ """
1291
+ webhookEndpoint(workflowId: String!): WebhookEndpointRecord
1292
+ """
1293
+ Secret metadata (label, version, status). Never the value.
1294
+ """
1295
+ secret(ref: String!): SecretRecord
1296
+ secrets: [SecretRecord!]!
1297
+ }
1298
+
1299
+ type BlockSearchHit {
1300
+ blockType: String!
1301
+ pieceName: String!
1302
+ pieceDisplayName: String!
1303
+ logoUrl: String!
1304
+ displayName: String!
1305
+ description: String!
1306
+ "action | trigger"
1307
+ kind: String!
1308
+ "Triggers only: POLLING | WEBHOOK | APP_WEBHOOK | MANUAL"
1309
+ strategy: String
1310
+ "Why this block cannot run on this reactor; null when it can."
1311
+ unsupported: String
1312
+ }
1313
+
1314
+ type BlockSearchResult {
1315
+ "ready | indexing | error"
1316
+ status: String!
1317
+ hits: [BlockSearchHit!]!
1318
+ indexedPieces: Int!
1319
+ error: String
1320
+ }
1321
+
1322
+ type SecretRecord {
1323
+ ref: String!
1324
+ label: String
1325
+ version: Int!
1326
+ status: String!
1327
+ createdAt: String!
1328
+ updatedAt: String!
1329
+ }
1330
+
1331
+ type ConnectionRecord {
1332
+ id: String!
1333
+ name: String!
1334
+ connectorId: String!
1335
+ authType: String!
1336
+ status: String!
1337
+ accountLabel: String
1338
+ }
1339
+
1340
+ type ConnectionCheckResult {
1341
+ ok: Boolean!
1342
+ detail: String
1343
+ """
1344
+ From auth.getConnectionIdentifier when the check passes; otherwise the
1345
+ label the connection already holds.
1346
+ """
1347
+ accountLabel: String
1348
+ }
1349
+
1350
+ type WebhookEndpointRecord {
1351
+ workflowId: String!
1352
+ url: String!
1353
+ """
1354
+ False when the reactor does not know its own public origin, so the url
1355
+ above is a bare path no provider can call: the author supplies the origin.
1356
+ """
1357
+ absoluteUrl: Boolean!
1358
+ "True while the workflow is ENABLED with a valid webhook trigger"
1359
+ armed: Boolean!
1360
+ createdAt: String!
1361
+ }
1362
+
1363
+ type TriggerStateRecord {
1364
+ workflowId: String!
1365
+ blockType: String!
1366
+ status: String!
1367
+ intervalMs: Int!
1368
+ nextPollAt: String
1369
+ lastPollAt: String
1370
+ lastError: String
1371
+ consecutiveFailures: Int!
1372
+ }
1373
+
1374
+ type Query {
1375
+ workflowRuntime: WorkflowRuntimeQueries!
1376
+ }
1377
+
1378
+ type WorkflowStepRunRecord {
1379
+ stepId: String!
1380
+ stepKey: String!
1381
+ blockType: String!
1382
+ status: String!
1383
+ input: Unknown
1384
+ output: Unknown
1385
+ port: String
1386
+ error: String
1387
+ startedAt: String
1388
+ endedAt: String
1389
+ }
1390
+
1391
+ type WorkflowRunRecord {
1392
+ id: String!
1393
+ workflowId: String!
1394
+ workflowName: String!
1395
+ workflowVersion: Int!
1396
+ triggerKind: String!
1397
+ triggerPayload: Unknown
1398
+ status: String!
1399
+ error: String
1400
+ startedAt: String!
1401
+ endedAt: String
1402
+ rerunOf: String
1403
+ steps: [WorkflowStepRunRecord!]!
1404
+ }
1405
+
1406
+ type WorkflowStepRun {
1407
+ stepId: String!
1408
+ key: String!
1409
+ blockType: String!
1410
+ status: String!
1411
+ input: Unknown
1412
+ output: Unknown
1413
+ port: String
1414
+ error: String
1415
+ }
1416
+
1417
+ type WorkflowRunPayload {
1418
+ runId: String
1419
+ status: String!
1420
+ error: String
1421
+ steps: [WorkflowStepRun!]!
1422
+ }
1423
+
1424
+ """
1425
+ WorkflowRuntime Mutations
1426
+ """
1427
+ type WorkflowRuntimeMutations {
1428
+ """
1429
+ Fires a workflow's core#manual trigger and runs it to completion.
1430
+ """
1431
+ fire(workflowId: String!, payload: Unknown): WorkflowRunPayload!
1432
+ """
1433
+ Runs a piece trigger's test hook; sample items, no cursor changes.
1434
+ """
1435
+ testTrigger(workflowId: String!): Unknown
1436
+ """
1437
+ Resumes a FAILED run: succeeded steps replay from the journal,
1438
+ execution restarts at the failure. Produces a new run.
1439
+ """
1440
+ rerun(runId: String!): WorkflowRunPayload!
1441
+ """
1442
+ Mints a managed secret and returns its ref; the value is stored
1443
+ encrypted and is never readable back over any API.
1444
+ """
1445
+ createSecret(value: String!, label: String): SecretRecord!
1446
+ """
1447
+ Replaces the value behind an existing ref; documents stay untouched.
1448
+ """
1449
+ rotateSecret(ref: String!, value: String!): SecretRecord!
1450
+ """
1451
+ Tombstones a secret; its value becomes unrecoverable.
1452
+ """
1453
+ deleteSecret(ref: String!): Boolean!
1454
+ """
1455
+ Runs the piece's auth.validate against the connection's credentials,
1456
+ then auth.getConnectionIdentifier for the account label, and records the
1457
+ outcome on the connection document.
1458
+ """
1459
+ checkConnection(connectionId: String!): ConnectionCheckResult!
1460
+ }
1461
+
1462
+ type Mutation {
1463
+ workflowRuntime: WorkflowRuntimeMutations!
1464
+ }
1465
+ `;
1466
+ //#endregion
1467
+ //#region src/workflow/subgraph.ts
1468
+ /** The runtime's read/write surface. The runtime itself is composed by the
1469
+ * host, so the subgraph only serves what it is handed. */
1470
+ function createWorkflowRuntimeSubgraph(runtime) {
1471
+ return class WorkflowRuntimeSubgraph extends BaseSubgraph {
1472
+ name = "workflow-runtime";
1473
+ typeDefs = schema;
1474
+ resolvers = getResolvers(runtime, this.authorizationService);
1475
+ additionalContextFields = {};
1476
+ };
1477
+ }
1478
+ //#endregion
1479
+ //#region src/workflow-runtime.mts
1480
+ /** The npm name the workflow package owns: its HTTP namespace and its models. */
1481
+ const WORKFLOW_PACKAGE_NAME = "@powerhousedao/workflow";
1482
+ /** The env var and OpenFeature flag key that turns workflows on. */
1483
+ const PH_WORKFLOWS_ENABLED = "PH_WORKFLOWS_ENABLED";
1484
+ /** Precedence, unchanged from the reactor-api resolver this replaces: the
1485
+ * host's option, then PH_WORKFLOWS_ENABLED, then the config file, then off. */
1486
+ async function resolveWorkflowsEnabled({ featureFlags, override, configEnabled = false, env = process.env }) {
1487
+ if (override !== void 0) return override;
1488
+ const raw = env[PH_WORKFLOWS_ENABLED]?.trim();
1489
+ if (raw === "1") return true;
1490
+ if (raw === "0") return false;
1491
+ return featureFlags.getBooleanValue(PH_WORKFLOWS_ENABLED, configEnabled);
1492
+ }
1493
+ async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao/workflow/document-models")) {
1494
+ try {
1495
+ await load();
1496
+ } catch (error) {
1497
+ throw new Error(`Workflows are enabled but ${WORKFLOW_PACKAGE_NAME} could not be loaded`, { cause: error });
1498
+ }
1499
+ }
1500
+ function readAssertion(authorizationService, reactorClient) {
1501
+ const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
1502
+ const hostCanRead = async (identifier, ctx) => {
1503
+ if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
1504
+ if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
1505
+ let documentId;
1506
+ try {
1507
+ documentId = await resolveCanonical(identifier);
1508
+ } catch {
1509
+ throw new ForbiddenError();
1510
+ }
1511
+ if (!await authorizationService.canRead(documentId, ctx.user?.address)) throw new ForbiddenError("to read this document");
1512
+ };
1513
+ return async (identifier, caller) => {
1514
+ const ctx = caller;
1515
+ await hostCanRead(identifier, ctx);
1516
+ let served;
1517
+ try {
1518
+ served = await reactorClient.isServed(identifier, { subject: callerSubject(ctx.user) });
1519
+ } catch {
1520
+ throw new ForbiddenError();
1521
+ }
1522
+ if (!served) throw new ForbiddenError("to read this document");
1523
+ };
1524
+ }
1525
+ /** The same, for a design-time call that writes what it names. */
1526
+ function writeAssertion(authorizationService, reactorClient) {
1527
+ const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
1528
+ return async (identifier, caller) => {
1529
+ const ctx = caller;
1530
+ if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
1531
+ if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
1532
+ let documentId;
1533
+ try {
1534
+ documentId = await resolveCanonical(identifier);
1535
+ } catch {
1536
+ throw new ForbiddenError();
1537
+ }
1538
+ if (!await authorizationService.canWrite(documentId, ctx.user?.address)) throw new ForbiddenError("to write this document");
1539
+ };
1540
+ }
1541
+ /** Whether the workflow document really references the attachment. A step
1542
+ * carries no caller, so the relationship is the whole check. */
1543
+ function attachmentRefCheck(deps) {
1544
+ const resolveCanonical = createCanonicalDocumentIdResolver(deps.reactorClient);
1545
+ const references = deps.attachmentReferences;
1546
+ const projection = deps.attachmentReferenceProjection;
1547
+ return async (documentId, ref) => {
1548
+ if (!references || projection?.status !== "available") return false;
1549
+ let parsed;
1550
+ try {
1551
+ parsed = parseRef(ref);
1552
+ } catch {
1553
+ return false;
1554
+ }
1555
+ if (parsed.version !== 1) return false;
1556
+ const canonicalRef = createRef(parsed.hash.toLowerCase());
1557
+ try {
1558
+ return await references.hasReference(await resolveCanonical(documentId), canonicalRef);
1559
+ } catch {
1560
+ return false;
1561
+ }
1562
+ };
1563
+ }
1564
+ async function registerWorkflowTriggersReadModel(engine, runtime, clientModule) {
1565
+ const reactorModule = clientModule?.reactorModule;
1566
+ if (!reactorModule) return {
1567
+ status: "unavailable",
1568
+ reason: "in-process-reactor-module-unavailable"
1569
+ };
1570
+ const coordinator = reactorModule.readModelCoordinator;
1571
+ if (!supportsLiveReadModelRegistration(coordinator)) return {
1572
+ status: "unavailable",
1573
+ reason: "live-read-model-registration-unsupported"
1574
+ };
1575
+ const readModel = new engine.WorkflowTriggersReadModel(reactorModule.database.withSchema(REACTOR_SCHEMA), reactorModule.operationIndex, reactorModule.writeCache, reactorModule.processorManagerConsistencyTracker, runtime);
1576
+ await readModel.init();
1577
+ coordinator.addReadModel(readModel, engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE);
1578
+ return { status: "available" };
1579
+ }
1580
+ function bindPackagePieces(registry, source) {
1581
+ const apply = (byPackage) => {
1582
+ registry.setPieces([...byPackage.values()].flat());
1583
+ };
1584
+ apply(source.getPieces());
1585
+ source.onPiecesChange(apply);
1586
+ }
1587
+ async function composeWorkflowRuntime(deps) {
1588
+ const load = deps.load ?? (() => import("@powerhousedao/reactor-workflow"));
1589
+ let engine;
1590
+ try {
1591
+ engine = await load();
1592
+ } catch (error) {
1593
+ throw new Error("Workflows are enabled but @powerhousedao/reactor-workflow could not be loaded", { cause: error });
1594
+ }
1595
+ engine.setPieceRegistryUrl(deps.pieceRegistryUrl);
1596
+ if (deps.pieces) bindPackagePieces(engine.packagePieces, deps.pieces);
1597
+ const runtime = engine.createWorkflowRuntime({
1598
+ relationalDb: deps.relationalDb,
1599
+ secretsKeyFile: deps.secretsKeyFile,
1600
+ reactorClient: deps.reactorClient,
1601
+ assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
1602
+ subjectOf: (caller) => callerSubject(caller.user),
1603
+ assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
1604
+ webhooks: deps.webhooks,
1605
+ attachments: deps.attachments,
1606
+ canReadAttachmentRef: attachmentRefCheck(deps),
1607
+ logger: deps.logger
1608
+ });
1609
+ const triggers = await registerWorkflowTriggersReadModel(engine, runtime, deps.clientModule);
1610
+ if (triggers.status === "available") deps.logger.info(`Workflow trigger read model registered (${engine.WORKFLOW_TRIGGERS_READ_MODEL}, ${engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE})`);
1611
+ 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);
1612
+ let stopped = false;
1613
+ return {
1614
+ subgraph: createWorkflowRuntimeSubgraph(runtime),
1615
+ triggers,
1616
+ async start() {
1617
+ await runtime.registerWebhookEndpoint();
1618
+ runtime.startTriggerSupervisor();
1619
+ },
1620
+ stop() {
1621
+ if (stopped) return Promise.resolve();
1622
+ stopped = true;
1623
+ runtime.shutdown();
1624
+ return Promise.resolve();
1625
+ }
1626
+ };
1627
+ }
1628
+ //#endregion
868
1629
  //#region src/pglite-dialect.ts
869
1630
  var ClosablePGliteDialect = class extends PGliteDialect {
870
1631
  #pglite;
@@ -1003,6 +1764,26 @@ function resolveReactorFeatureFlags(env) {
1003
1764
  };
1004
1765
  }
1005
1766
  //#endregion
1767
+ //#region src/create-signature-policy.mts
1768
+ /**
1769
+ * What new documents are created as, from CREATE_SIGNATURE_POLICY. Without a
1770
+ * signer this switchboard cannot write to a v2-required document, so it falls
1771
+ * back to legacy and says so.
1772
+ */
1773
+ function resolveCreateSignaturePolicy(env, options) {
1774
+ const raw = env.CREATE_SIGNATURE_POLICY?.trim();
1775
+ let policy = DEFAULT_SIGNATURE_POLICY;
1776
+ if (raw) {
1777
+ if (!isSignaturePolicy(raw)) throw new Error(`CREATE_SIGNATURE_POLICY must be "legacy" or "v2-required", got "${raw}"`);
1778
+ policy = raw;
1779
+ }
1780
+ if (policy === "v2-required" && !options.hasSigner) {
1781
+ 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.");
1782
+ return "legacy";
1783
+ }
1784
+ return policy;
1785
+ }
1786
+ //#endregion
1006
1787
  //#region src/renown.ts
1007
1788
  const logger = childLogger(["switchboard", "renown"]);
1008
1789
  /**
@@ -1025,12 +1806,94 @@ async function initRenown(options = {}) {
1025
1806
  * Get the signer config for the given renown instance.
1026
1807
  *
1027
1808
  * @param renown - The renown instance
1028
- * @param requireSignature - If true, unsigned actions are rejected
1809
+ * @param keypairPath - Where `initRenown` loaded the key from
1029
1810
  */
1030
- function getRenownSignerConfig(renown, requireSignature) {
1811
+ function getRenownSignerConfig(renown, keypairPath) {
1031
1812
  return {
1032
1813
  signer: renown.signer,
1033
- verifier: createSignatureVerifier(requireSignature)
1814
+ workerSigner: getRenownWorkerSignerSpec(renown, keypairPath)
1815
+ };
1816
+ }
1817
+ /**
1818
+ * What a pooled executor worker imports to sign as this switchboard: it
1819
+ * reloads the key `initRenown` stored, and takes the user known at boot.
1820
+ */
1821
+ function getRenownWorkerSignerSpec(renown, keypairPath) {
1822
+ const { signer } = renown;
1823
+ const user = signer.user;
1824
+ return {
1825
+ module: {
1826
+ filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
1827
+ exportName: "createNodeRenownSigner"
1828
+ },
1829
+ initArgs: {
1830
+ appName: signer.app?.name ?? "switchboard",
1831
+ keypairPath: resolve(keypairPath ?? DEFAULT_KEYPAIR_PATH),
1832
+ ...signer.app?.key ? { did: signer.app.key } : {},
1833
+ ...user ? { user: {
1834
+ address: user.address,
1835
+ networkId: user.networkId,
1836
+ chainId: user.chainId
1837
+ } } : {}
1838
+ }
1839
+ };
1840
+ }
1841
+ /**
1842
+ * Admits a key as a signer for an address a Renown credential binds it to; a
1843
+ * `self` source has no worker spec. In process, the switchboard's own key is
1844
+ * matched against its current user; the worker spec carries the user known now.
1845
+ */
1846
+ async function getRenownTrustPolicyConfig(trustSource, renown) {
1847
+ const ownSigner = renown?.signer;
1848
+ if (trustSource.source === "self") return { trustPolicy: createRenownTrustPolicy({
1849
+ switchboard: trustSource.request,
1850
+ ownSigner
1851
+ }) };
1852
+ const switchboardUrl = await resolveSwitchboardEndpoint({
1853
+ switchboardUrl: trustSource.switchboardUrl,
1854
+ baseUrl: trustSource.renownUrl
1855
+ });
1856
+ const options = {
1857
+ ...switchboardUrl ? { switchboard: switchboardUrl } : {},
1858
+ ...trustSource.renownUrl ? { renownUrl: trustSource.renownUrl } : {}
1859
+ };
1860
+ const self = ownIdentity(renown);
1861
+ return {
1862
+ trustPolicy: createRenownTrustPolicy({
1863
+ ...options,
1864
+ ownSigner
1865
+ }),
1866
+ workerTrustPolicy: {
1867
+ module: {
1868
+ filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
1869
+ exportName: "createRenownTrustPolicy"
1870
+ },
1871
+ initArgs: {
1872
+ ...options,
1873
+ ...self ? { self } : {}
1874
+ }
1875
+ }
1876
+ };
1877
+ }
1878
+ /**
1879
+ * Fails the boot when pooled workers would refuse every signed write the
1880
+ * in-process executor accepts: a `self` Renown source gives workers no trust
1881
+ * policy spec, so under authEnforcement they apply the default.
1882
+ */
1883
+ function assertWorkerTrustPolicy(config) {
1884
+ 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).");
1885
+ }
1886
+ function ownIdentity(renown) {
1887
+ const key = renown?.signer.app?.key;
1888
+ if (!key) return;
1889
+ const user = renown.signer.user;
1890
+ return {
1891
+ key,
1892
+ user: {
1893
+ address: user?.address ?? "",
1894
+ networkId: user?.networkId ?? "",
1895
+ chainId: user?.chainId ?? 0
1896
+ }
1034
1897
  };
1035
1898
  }
1036
1899
  //#endregion
@@ -1041,8 +1904,6 @@ setLogLevel(LogLevel);
1041
1904
  dotenv.config();
1042
1905
  const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
1043
1906
  const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
1044
- const REQUIRE_SIGNATURES = "REQUIRE_SIGNATURES";
1045
- const REQUIRE_SIGNATURES_DEFAULT = false;
1046
1907
  const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
1047
1908
  const PORT_FALLBACK_ATTEMPTS = 20;
1048
1909
  const PGLITE_FLUSH_INTERVAL_MS = (() => {
@@ -1077,6 +1938,13 @@ function isPortAvailable(port) {
1077
1938
  function resolveConfigPath(configFile) {
1078
1939
  return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
1079
1940
  }
1941
+ function readConfigWorkflowsEnabled(configPath) {
1942
+ try {
1943
+ return getConfig(configPath).workflows?.enabled ?? false;
1944
+ } catch {
1945
+ return false;
1946
+ }
1947
+ }
1080
1948
  async function resolveServerPort(requested, strictPort, logger) {
1081
1949
  if (strictPort) return requested;
1082
1950
  for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
@@ -1189,6 +2057,11 @@ async function initServer(serverPort, options, renown, renownConfig) {
1189
2057
  }
1190
2058
  if (workerPool) {
1191
2059
  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");
2060
+ assertWorkerTrustPolicy({
2061
+ workers: workerPool.numWorkers,
2062
+ authEnforcement: resolveReactorFeatureFlags(process.env).flags.authEnforcement === true,
2063
+ renownSource: renownConfig.source
2064
+ });
1192
2065
  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
2066
  }
1194
2067
  let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
@@ -1224,7 +2097,13 @@ async function initServer(serverPort, options, renown, renownConfig) {
1224
2097
  reactorLogger.error(`Projection worker ${shardId} died; shutting down so the supervisor restarts a healthy process`, reason);
1225
2098
  process.kill(process.pid, "SIGTERM");
1226
2099
  };
2100
+ const workflowsEnabled = options.workflows?.enabled === true;
2101
+ if (workflowsEnabled) {
2102
+ await assertWorkflowPackageLoadable();
2103
+ if (!packages.includes("@powerhousedao/workflow")) packages.push(WORKFLOW_PACKAGE_NAME);
2104
+ }
1227
2105
  let ownedReactorModule;
2106
+ let localRenownRequest;
1228
2107
  const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
1229
2108
  if (options.reactor) {
1230
2109
  const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
@@ -1264,7 +2143,19 @@ async function initServer(serverPort, options, renown, renownConfig) {
1264
2143
  } : void 0,
1265
2144
  documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
1266
2145
  logger: reactorLogger,
1267
- signer: renown ? getRenownSignerConfig(renown, options.identity?.requireSignatures) : void 0
2146
+ signer: renown ? getRenownSignerConfig(renown, options.identity?.keypairPath) : void 0,
2147
+ createSignaturePolicy: resolveCreateSignaturePolicy(process.env, {
2148
+ hasSigner: renown !== null,
2149
+ logger
2150
+ }),
2151
+ trustPolicy: reactorFeatureFlags.authEnforcement ? await getRenownTrustPolicyConfig(renownConfig.source === "self" ? {
2152
+ source: "self",
2153
+ request: (query, variables) => localRenownRequest ? localRenownRequest(query, variables) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet"))
2154
+ } : {
2155
+ source: "remote",
2156
+ renownUrl: renownConfig.url,
2157
+ switchboardUrl: renownConfig.switchboardUrl
2158
+ }, renown) : void 0
1268
2159
  });
1269
2160
  if (workerPool) {
1270
2161
  if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
@@ -1313,12 +2204,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
1313
2204
  new ReactorInstrumentation(module.reactorModule).start();
1314
2205
  reactorLogger.info("Reactor metrics instrumentation started");
1315
2206
  }
1316
- driveNodeView = new DriveNodeView(baseKysely.withSchema(REACTOR_SCHEMA));
2207
+ const reactorDriveSchemaDb = baseKysely.withSchema(REACTOR_SCHEMA);
2208
+ driveNodeView = new DriveNodeView(reactorDriveSchemaDb);
1317
2209
  const reactorDriveClient = new ReactorDriveClient({
1318
2210
  reactor: module.client,
1319
2211
  readModel: driveNodeView
1320
2212
  });
1321
2213
  ownedReactorModule = module;
2214
+ if (options.fatalErrorShutdown) installFatalErrorShutdown(logger);
1322
2215
  return {
1323
2216
  module,
1324
2217
  reactorDriveClient,
@@ -1401,7 +2294,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
1401
2294
  await abortBoot(api);
1402
2295
  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
2296
  }
1404
- localCredentialCheck = createLocalCredentialVerifier((query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables), { onError: (error) => logger.error("Renown read model query failed: @error", error) });
2297
+ localRenownRequest = (query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables);
2298
+ localCredentialCheck = createLocalCredentialVerifier(localRenownRequest, { onError: (error) => logger.error("Renown read model query failed: @error", error) });
1405
2299
  logger.info("Renown credentials will be verified against this switchboard's own renown read model");
1406
2300
  }
1407
2301
  registerAttachmentRoutes(api);
@@ -1409,6 +2303,44 @@ async function initServer(serverPort, options, renown, renownConfig) {
1409
2303
  if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
1410
2304
  const { client, graphqlManager, documentModelRegistry } = api;
1411
2305
  const lateSubgraphs = [];
2306
+ let workflows;
2307
+ if (workflowsEnabled) {
2308
+ workflows = await composeWorkflowRuntime({
2309
+ reactorClient: client,
2310
+ clientModule: options.reactor ?? ownedReactorModule,
2311
+ relationalDb: api.relationalDb,
2312
+ secretsKeyFile: readModelPgliteDir === null ? false : void 0,
2313
+ attachments: createAttachmentClient(api.attachments.service),
2314
+ attachmentReferences: api.attachmentReferenceIndex.store,
2315
+ attachmentReferenceProjection: api.attachmentReferenceProjection,
2316
+ webhooks: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME).webhooks,
2317
+ authorizationService: api.authorizationService,
2318
+ pieces: api.packageManager,
2319
+ pieceRegistryUrl: registryUrl,
2320
+ logger: logger.child(["workflow-runtime"])
2321
+ });
2322
+ const WorkflowRuntimeSubgraph = workflows.subgraph;
2323
+ const workflowSubgraph = new WorkflowRuntimeSubgraph({
2324
+ reactorClient: client,
2325
+ http: graphqlManager.scopeForPackage(WORKFLOW_PACKAGE_NAME),
2326
+ relationalDb: api.relationalDb,
2327
+ analyticsStore: void 0,
2328
+ graphqlManager,
2329
+ syncManager: api.syncManager,
2330
+ authorizationService: graphqlManager.getAuthorizationService(),
2331
+ path: graphqlManager.getBasePath()
2332
+ });
2333
+ lateSubgraphs.push(graphqlManager.registerSubgraphInstance(workflowSubgraph, "graphql", false).catch((error) => {
2334
+ logger.error("Failed to register workflow-runtime subgraph: @error", error);
2335
+ }));
2336
+ await workflows.start();
2337
+ logger.info("Workflow runtime started");
2338
+ }
2339
+ const shutdown = async () => {
2340
+ await workflows?.stop();
2341
+ await api.dispose();
2342
+ };
2343
+ apiRef.current = { dispose: shutdown };
1412
2344
  if (httpLoader) {
1413
2345
  const packageManagementService = new PackageManagementService({
1414
2346
  defaultRegistryUrl: registryUrl,
@@ -1417,7 +2349,9 @@ async function initServer(serverPort, options, renown, renownConfig) {
1417
2349
  packageManager: api.packageManager
1418
2350
  });
1419
2351
  packageManagementService.setOnModelsChanged(() => {
1420
- graphqlManager.regenerateDocumentModelSubgraphs().catch(logger.error);
2352
+ graphqlManager.regenerateDocumentModelSubgraphs().catch((error) => {
2353
+ logger.error("Failed to regenerate document model subgraphs: @error", error);
2354
+ });
1421
2355
  });
1422
2356
  const packagesSubgraph = new PackagesSubgraph({
1423
2357
  relationalDb: void 0,
@@ -1436,10 +2370,15 @@ async function initServer(serverPort, options, renown, renownConfig) {
1436
2370
  }
1437
2371
  if (driveNodeView) {
1438
2372
  graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
2373
+ const authorizationService = graphqlManager.getAuthorizationService();
1439
2374
  const reactorDriveSubgraph = {
1440
2375
  name: "reactor-drive",
1441
2376
  path: graphqlManager.getBasePath(),
1442
- resolvers: createReactorDriveResolvers(),
2377
+ resolvers: createReactorDriveResolvers({
2378
+ reactorClient: client,
2379
+ readModel: driveNodeView,
2380
+ hostCanRead: (documentId, address) => authorizationService.canRead(documentId, address)
2381
+ }),
1443
2382
  typeDefs: reactorDriveSubgraphTypeDefs,
1444
2383
  reactorClient: client,
1445
2384
  relationalDb: void 0
@@ -1490,9 +2429,10 @@ async function initServer(serverPort, options, renown, renownConfig) {
1490
2429
  reactor: client,
1491
2430
  attachmentService,
1492
2431
  attachmentReferenceProjection: api.attachmentReferenceProjection,
2432
+ workflowTriggers: workflows?.triggers,
1493
2433
  renown,
1494
2434
  port: serverPort,
1495
- shutdown: () => api.dispose()
2435
+ shutdown
1496
2436
  };
1497
2437
  }
1498
2438
  /**
@@ -1519,16 +2459,21 @@ const startSwitchboard = async (options = {}) => {
1519
2459
  const featureFlags = await initFeatureFlags();
1520
2460
  const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
1521
2461
  options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
1522
- const requireSignatures = options.identity?.requireSignatures ?? await featureFlags.getBooleanValue(REQUIRE_SIGNATURES, REQUIRE_SIGNATURES_DEFAULT);
2462
+ const configPathForFlags = resolveConfigPath(options.configFile);
2463
+ const workflowsEnabled = await resolveWorkflowsEnabled({
2464
+ featureFlags,
2465
+ override: options.workflows?.enabled,
2466
+ configEnabled: readConfigWorkflowsEnabled(configPathForFlags)
2467
+ });
2468
+ options.workflows = { enabled: workflowsEnabled };
1523
2469
  const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
1524
2470
  options.identity = {
1525
2471
  ...options.identity,
1526
- requireSignatures,
1527
2472
  baseUrl: options.identity?.baseUrl ?? renownConfig.url
1528
2473
  };
1529
2474
  logger.info("Feature flags: @flags", JSON.stringify({
1530
2475
  DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
1531
- REQUIRE_SIGNATURES: requireSignatures
2476
+ PH_WORKFLOWS_ENABLED: workflowsEnabled
1532
2477
  }, null, 2));
1533
2478
  let renown = null;
1534
2479
  try {
@@ -1545,9 +2490,9 @@ const startSwitchboard = async (options = {}) => {
1545
2490
  throw e;
1546
2491
  }
1547
2492
  };
1548
- if (import.meta.main) await startSwitchboard();
2493
+ if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
1549
2494
  //#endregion
1550
2495
  export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
1551
2496
 
1552
- //# sourceMappingURL=server-B2r6AQ4Q.mjs.map
1553
- //# debugId=0a17ca6d-016f-53aa-9705-cef103da6f09
2497
+ //# sourceMappingURL=server-Cf0DB8sW.mjs.map
2498
+ //# debugId=49b28650-fe00-57f4-844b-978a35d80e91