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