@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.
- package/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
- package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +45 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +94 -0
- package/CHANGELOG.md +272 -0
- package/README.md +14 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +6 -6
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/{server-B2r6AQ4Q.mjs → server-Cf0DB8sW.mjs} +1010 -65
- package/dist/server-Cf0DB8sW.mjs.map +1 -0
- package/dist/server.d.mts +74 -28
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -4
- package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
- package/dist/utils-uaBlzyjS.mjs.map +1 -0
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +21 -17
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +287 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +194 -31
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/routes-integration.test.ts +59 -4
- package/test/attachments/routes.test.ts +165 -23
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/boot-create-signature-policy.test.ts +108 -0
- package/test/boot-without-renown.test.ts +81 -0
- package/test/boot-without-require-signatures.test.ts +74 -0
- package/test/boot-worker-trust-policy.test.ts +90 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/fatal-shutdown.test.ts +102 -0
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/workflow/harness.ts +237 -0
- package/test/workflow/resolvers.test.ts +105 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-unsigned.test.ts +332 -0
- package/test/workflow-read-gate.test.ts +145 -0
- package/test/workflow-runtime.test.ts +472 -0
- package/tsconfig.json +6 -0
- package/tsdown.config.ts +3 -1
- package/dist/server-B2r6AQ4Q.mjs.map +0 -1
- 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]="
|
|
3
|
-
import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-
|
|
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,
|
|
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 (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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,
|
|
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$
|
|
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$
|
|
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",
|
|
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
|
|
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",
|
|
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 =
|
|
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
|
|
420
|
-
*
|
|
421
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
441
|
-
*
|
|
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$
|
|
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$
|
|
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
|
-
|
|
537
|
-
|
|
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,
|
|
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)
|
|
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
|
|
1809
|
+
* @param keypairPath - Where `initRenown` loaded the key from
|
|
1029
1810
|
*/
|
|
1030
|
-
function getRenownSignerConfig(renown,
|
|
1811
|
+
function getRenownSignerConfig(renown, keypairPath) {
|
|
1031
1812
|
return {
|
|
1032
1813
|
signer: renown.signer,
|
|
1033
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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-
|
|
1553
|
-
//# debugId=
|
|
2497
|
+
//# sourceMappingURL=server-Cf0DB8sW.mjs.map
|
|
2498
|
+
//# debugId=49b28650-fe00-57f4-844b-978a35d80e91
|