@powerhousedao/switchboard 6.2.3-dev.9 → 6.2.3
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-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.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/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.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/unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/unsupported-stored-documents.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 +302 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +10 -8
- 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-CiKkVE6g.mjs → server-sQoWp21G.mjs} +1454 -70
- package/dist/server-sQoWp21G.mjs.map +1 -0
- package/dist/server.d.mts +103 -29
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +4 -5
- 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-mcp-enabled.test.ts +121 -0
- package/test/boot-unsupported-stored-documents.test.ts +210 -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/mcp-flag.test.ts +56 -0
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/unsupported-stored-documents.test.ts +72 -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-CiKkVE6g.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]="66287c02-682f-57d0-8260-a1005691b2bc")}catch(e){}}();
|
|
3
|
+
import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-uaBlzyjS.mjs";
|
|
4
|
+
import { createRequire, register } from "node:module";
|
|
5
5
|
import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
|
|
6
6
|
import * as Sentry from "@sentry/node";
|
|
7
7
|
import { childLogger, documentModelDocumentModelModule, setLogLevel } from "document-model";
|
|
8
8
|
import dotenv from "dotenv";
|
|
9
9
|
import { getConfig } from "@powerhousedao/config/node";
|
|
10
10
|
import { existsSync, promises, realpathSync } from "node:fs";
|
|
11
|
-
import path from "node:path";
|
|
11
|
+
import path, { resolve } from "node:path";
|
|
12
12
|
import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
|
|
13
|
-
import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
|
|
14
|
-
import { HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
|
|
13
|
+
import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, UnsupportedStoredProtocolError, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
|
|
14
|
+
import { AuthorizationPolicy, BaseSubgraph, ForbiddenError, HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, callerSubject, createCanonicalDocumentIdResolver, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
|
|
15
15
|
import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
|
|
16
|
-
import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget } from "@powerhousedao/reactor-attachments";
|
|
16
|
+
import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget, parseRef } from "@powerhousedao/reactor-attachments";
|
|
17
|
+
import { createAttachmentClient } from "@powerhousedao/reactor-attachments/client";
|
|
17
18
|
import { DriveNodeView, NodeProcessor, ReactorDriveClient, createReactorDriveResolvers, reactorDriveDocumentModelModule, reactorDriveSubgraphTypeDefs } from "@powerhousedao/reactor-drive";
|
|
18
|
-
import { DEFAULT_RENOWN_URL, NodeKeyStorage, RENOWN_READ_MODEL_SUBGRAPH, RenownBuilder, RenownCryptoBuilder, createLocalCredentialVerifier,
|
|
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,914 @@ async function initFeatureFlags() {
|
|
|
865
1112
|
return OpenFeature.getClient();
|
|
866
1113
|
}
|
|
867
1114
|
//#endregion
|
|
1115
|
+
//#region src/mcp-flag.mts
|
|
1116
|
+
/** The env var and OpenFeature flag key that can keep the MCP server unmounted. */
|
|
1117
|
+
const MCP_ENABLED = "MCP_ENABLED";
|
|
1118
|
+
/** Whether to mount the MCP server at `/mcp`. On unless the host passes
|
|
1119
|
+
* `mcp: false` or MCP_ENABLED is "false": the env var can turn a host's MCP
|
|
1120
|
+
* off, never on, so a deployment that serves no MCP client can close the
|
|
1121
|
+
* endpoint without the bin growing a flag of its own. */
|
|
1122
|
+
async function resolveMcpEnabled({ featureFlags, option }) {
|
|
1123
|
+
if (option === false) return false;
|
|
1124
|
+
return featureFlags.getBooleanValue(MCP_ENABLED, true);
|
|
1125
|
+
}
|
|
1126
|
+
//#endregion
|
|
1127
|
+
//#region src/workflow/oauth-callback.ts
|
|
1128
|
+
const OAUTH_CALLBACK_PATH = "oauth/callback";
|
|
1129
|
+
function escapeHtml(text) {
|
|
1130
|
+
return text.replace(/[&<>"']/g, (char) => ({
|
|
1131
|
+
"&": "&",
|
|
1132
|
+
"<": "<",
|
|
1133
|
+
">": ">",
|
|
1134
|
+
"\"": """,
|
|
1135
|
+
"'": "'"
|
|
1136
|
+
})[char]);
|
|
1137
|
+
}
|
|
1138
|
+
function resultPage(ok, detail) {
|
|
1139
|
+
const title = ok ? "Connected" : "Sign-in failed";
|
|
1140
|
+
return `<!doctype html>
|
|
1141
|
+
<html><head><meta charset="utf-8"><title>${title}</title>
|
|
1142
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1143
|
+
<style>body{font:15px system-ui,sans-serif;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#222}main{max-width:28rem;padding:1.5rem;text-align:center}h1{font-size:1.1rem}</style>
|
|
1144
|
+
</head><body><main><h1>${title}</h1><p>${ok ? "You can close this window." : escapeHtml(detail ?? "The sign-in did not finish.")}</p></main>
|
|
1145
|
+
${ok ? "<script>setTimeout(function(){window.close()},600)<\/script>" : ""}
|
|
1146
|
+
</body></html>`;
|
|
1147
|
+
}
|
|
1148
|
+
function registerOAuthCallback(http, runtime) {
|
|
1149
|
+
return http.get(OAUTH_CALLBACK_PATH, { auth: "public" }, async (request) => {
|
|
1150
|
+
const params = new URL(request.url).searchParams;
|
|
1151
|
+
const state = params.get("state");
|
|
1152
|
+
const result = state ? await runtime.completeOAuth({
|
|
1153
|
+
state,
|
|
1154
|
+
code: params.get("code") ?? void 0,
|
|
1155
|
+
error: params.get("error") ?? void 0,
|
|
1156
|
+
errorDescription: params.get("error_description") ?? void 0
|
|
1157
|
+
}) : {
|
|
1158
|
+
ok: false,
|
|
1159
|
+
detail: "The provider sent no state",
|
|
1160
|
+
returnUrl: null
|
|
1161
|
+
};
|
|
1162
|
+
if (result.returnUrl && state) {
|
|
1163
|
+
const back = new URL(result.returnUrl);
|
|
1164
|
+
back.searchParams.set("ph_oauth", state);
|
|
1165
|
+
return new Response(null, {
|
|
1166
|
+
status: 303,
|
|
1167
|
+
headers: {
|
|
1168
|
+
location: back.href,
|
|
1169
|
+
"cache-control": "no-store"
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
return new Response(resultPage(result.ok, result.detail), {
|
|
1174
|
+
status: result.ok ? 200 : 400,
|
|
1175
|
+
headers: {
|
|
1176
|
+
"content-type": "text/html; charset=utf-8",
|
|
1177
|
+
"cache-control": "no-store",
|
|
1178
|
+
"referrer-policy": "no-referrer"
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
function callbackUrlOf(http) {
|
|
1184
|
+
return `${http.baseUrl.replace(/\/+$/, "")}/${OAUTH_CALLBACK_PATH}`;
|
|
1185
|
+
}
|
|
1186
|
+
//#endregion
|
|
1187
|
+
//#region src/workflow/resolvers.ts
|
|
1188
|
+
function blockRef(input) {
|
|
1189
|
+
if (input.kind !== "action" && input.kind !== "trigger") throw new GraphQLError(`A block's kind is "action" or "trigger", got "${input.kind}"`);
|
|
1190
|
+
return {
|
|
1191
|
+
pieceName: input.pieceName,
|
|
1192
|
+
pieceVersion: input.pieceVersion,
|
|
1193
|
+
name: input.name,
|
|
1194
|
+
kind: input.kind
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
async function cursorAware(call) {
|
|
1198
|
+
try {
|
|
1199
|
+
return await call();
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
if (error instanceof Error && error.name === "InvalidRunCursorError") throw new GraphQLError(error.message, { extensions: { code: "BAD_USER_INPUT" } });
|
|
1202
|
+
throw error;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
function selectsStepData(selections, path) {
|
|
1206
|
+
let level = selections;
|
|
1207
|
+
for (const name of [...path, "steps"]) {
|
|
1208
|
+
const next = [];
|
|
1209
|
+
for (const set of level) for (const selection of set.selections) {
|
|
1210
|
+
if (selection.kind !== Kind.FIELD) return true;
|
|
1211
|
+
if (selection.name.value === name && selection.selectionSet) next.push(selection.selectionSet);
|
|
1212
|
+
}
|
|
1213
|
+
if (next.length === 0) return false;
|
|
1214
|
+
level = next;
|
|
1215
|
+
}
|
|
1216
|
+
return level.some((set) => set.selections.some((selection) => selection.kind !== Kind.FIELD || selection.name.value === "input" || selection.name.value === "output"));
|
|
1217
|
+
}
|
|
1218
|
+
function readsStepData(info, path) {
|
|
1219
|
+
if (!info) return true;
|
|
1220
|
+
return selectsStepData(info.fieldNodes.flatMap((node) => node.selectionSet ? [node.selectionSet] : []), path);
|
|
1221
|
+
}
|
|
1222
|
+
function requireAdmin(authorizationService, ctx) {
|
|
1223
|
+
if (!authorizationService.isSupremeAdmin(ctx.user?.address)) throw new GraphQLError("Admin access required");
|
|
1224
|
+
}
|
|
1225
|
+
async function syncAware(call) {
|
|
1226
|
+
try {
|
|
1227
|
+
return await call();
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
if (error instanceof Error && error.name === "WorkflowSyncingError") throw new GraphQLError(error.message, { extensions: {
|
|
1230
|
+
code: "WORKFLOW_SYNCING",
|
|
1231
|
+
retryable: true
|
|
1232
|
+
} });
|
|
1233
|
+
throw error;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
function parseJson(value) {
|
|
1237
|
+
if (value === null) return null;
|
|
1238
|
+
try {
|
|
1239
|
+
return JSON.parse(value);
|
|
1240
|
+
} catch {
|
|
1241
|
+
return value;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
function toStepRecord(row) {
|
|
1245
|
+
return {
|
|
1246
|
+
stepId: row.step_id,
|
|
1247
|
+
stepKey: row.step_key,
|
|
1248
|
+
pieceName: row.piece_name,
|
|
1249
|
+
blockName: row.block_name,
|
|
1250
|
+
status: row.status,
|
|
1251
|
+
input: parseJson(row.input),
|
|
1252
|
+
output: parseJson(row.output),
|
|
1253
|
+
port: row.port,
|
|
1254
|
+
error: row.error,
|
|
1255
|
+
startedAt: row.started_at,
|
|
1256
|
+
endedAt: row.ended_at,
|
|
1257
|
+
pieceVersion: row.piece_version,
|
|
1258
|
+
pieceSource: row.piece_source,
|
|
1259
|
+
versionMatch: row.version_match,
|
|
1260
|
+
versionNote: row.version_note
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
function toRunRecord(row, steps) {
|
|
1264
|
+
return {
|
|
1265
|
+
id: row.id,
|
|
1266
|
+
workflowId: row.workflow_id,
|
|
1267
|
+
workflowName: row.workflow_name,
|
|
1268
|
+
workflowVersion: row.workflow_version,
|
|
1269
|
+
triggerKind: row.trigger_kind,
|
|
1270
|
+
triggerPayload: parseJson(row.trigger_payload),
|
|
1271
|
+
status: row.status,
|
|
1272
|
+
error: row.error,
|
|
1273
|
+
startedAt: row.started_at,
|
|
1274
|
+
endedAt: row.ended_at,
|
|
1275
|
+
rerunOf: row.rerun_of,
|
|
1276
|
+
warnings: row.warnings,
|
|
1277
|
+
warningNotes: parseJson(row.warning_notes) ?? [],
|
|
1278
|
+
steps: steps.map(toStepRecord)
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
function isAbsolute(url) {
|
|
1282
|
+
return /^https?:\/\//i.test(url);
|
|
1283
|
+
}
|
|
1284
|
+
function redirectUriFor(routing, requested) {
|
|
1285
|
+
if (!routing) throw new GraphQLError("This host serves no OAuth2 callback");
|
|
1286
|
+
if (isAbsolute(routing.callbackUrl)) return routing.callbackUrl;
|
|
1287
|
+
if (!requested) throw new GraphQLError("This host does not know its public URL; pass redirectUri");
|
|
1288
|
+
let url;
|
|
1289
|
+
try {
|
|
1290
|
+
url = new URL(requested);
|
|
1291
|
+
} catch {
|
|
1292
|
+
throw new GraphQLError(`"${requested}" is not a URL`);
|
|
1293
|
+
}
|
|
1294
|
+
if (url.protocol !== "https:" && url.protocol !== "http:" || url.pathname !== routing.callbackUrl || url.search !== "" || url.hash !== "") throw new GraphQLError(`redirectUri must be this host's ${routing.callbackUrl}`);
|
|
1295
|
+
return url.href;
|
|
1296
|
+
}
|
|
1297
|
+
function returnUrlFor(requested, ctx) {
|
|
1298
|
+
if (!requested) return void 0;
|
|
1299
|
+
const origin = ctx.headers.origin;
|
|
1300
|
+
let url;
|
|
1301
|
+
try {
|
|
1302
|
+
url = new URL(requested);
|
|
1303
|
+
} catch {
|
|
1304
|
+
url = void 0;
|
|
1305
|
+
}
|
|
1306
|
+
if (!url || !origin || url.origin !== origin) throw new GraphQLError("returnUrl must be on the requesting page's origin");
|
|
1307
|
+
return url.href;
|
|
1308
|
+
}
|
|
1309
|
+
const getResolvers = (runtime, authorizationService, oauth) => {
|
|
1310
|
+
return {
|
|
1311
|
+
Query: { workflowRuntime: () => ({}) },
|
|
1312
|
+
WorkflowRuntimeQueries: {
|
|
1313
|
+
health: () => "ok",
|
|
1314
|
+
blockDescriptor: (_parent, args) => runtime.blockDescriptor(blockRef(args.block)),
|
|
1315
|
+
blockOptions: (_parent, args, ctx) => runtime.blockOptions(blockRef(args.block), args.propName, args.input, args.connectionId ?? void 0, ctx, args.searchValue ?? void 0),
|
|
1316
|
+
pieceCatalog: () => runtime.pieceCatalog(),
|
|
1317
|
+
pieceActions: (_parent, args) => runtime.pieceActions(args.packageName, args.version ?? void 0),
|
|
1318
|
+
pieceTriggers: (_parent, args) => runtime.pieceTriggers(args.packageName, args.version ?? void 0),
|
|
1319
|
+
blockOutputTree: (_parent, args) => runtime.blockOutputTree(blockRef(args.block), args.config),
|
|
1320
|
+
stepOutputTree: (_parent, args, ctx) => runtime.stepOutputTree(args.workflowId, args.stepId, ctx),
|
|
1321
|
+
blockResolutions: (_parent, args, ctx) => runtime.blockResolutions(args.workflowId, ctx),
|
|
1322
|
+
pieceDetail: (_parent, args) => runtime.pieceDetail(args.packageName, args.version ?? void 0),
|
|
1323
|
+
searchBlocks: (_parent, args) => runtime.searchBlocks(args.query, args.limit ?? void 0),
|
|
1324
|
+
connections: (_parent, _args, ctx) => runtime.connections(ctx),
|
|
1325
|
+
webhookEndpoint: (_parent, args, ctx) => syncAware(() => runtime.webhookEndpoint(args.workflowId, ctx, { driveId: args.driveId ?? void 0 })),
|
|
1326
|
+
secret: async (_parent, args) => {
|
|
1327
|
+
try {
|
|
1328
|
+
return await (await runtime.secrets()).stat(args.ref);
|
|
1329
|
+
} catch {
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
},
|
|
1333
|
+
secrets: async () => (await runtime.secrets()).list(),
|
|
1334
|
+
oauthRedirectUri: () => oauth?.callbackUrl ?? null,
|
|
1335
|
+
oauthAttempt: (_parent, args, ctx) => runtime.oauthAttempt(args.state, ctx),
|
|
1336
|
+
triggerStates: async (_parent, _args, ctx) => (await runtime.triggerStates(ctx)).map((row) => ({
|
|
1337
|
+
workflowId: row.workflow_id,
|
|
1338
|
+
pieceName: row.piece_name,
|
|
1339
|
+
triggerName: row.trigger_name,
|
|
1340
|
+
status: row.status,
|
|
1341
|
+
intervalMs: row.interval_ms,
|
|
1342
|
+
nextPollAt: row.next_poll_at,
|
|
1343
|
+
lastPollAt: row.last_poll_at,
|
|
1344
|
+
lastError: row.last_error,
|
|
1345
|
+
consecutiveFailures: row.consecutive_failures,
|
|
1346
|
+
nextRenewAt: row.next_renew_at,
|
|
1347
|
+
renewError: row.renew_error,
|
|
1348
|
+
renewFailures: row.renew_failures,
|
|
1349
|
+
pieceVersion: row.piece_version,
|
|
1350
|
+
pieceSource: row.piece_source,
|
|
1351
|
+
versionMatch: row.version_match,
|
|
1352
|
+
versionNote: row.version_note
|
|
1353
|
+
})),
|
|
1354
|
+
runs: async (_parent, args, ctx, info) => (await runtime.runs({
|
|
1355
|
+
...args,
|
|
1356
|
+
withStepData: readsStepData(info, [])
|
|
1357
|
+
}, ctx)).map((record) => toRunRecord(record.row, record.steps)),
|
|
1358
|
+
runsPage: async (_parent, args, ctx, info) => {
|
|
1359
|
+
const { paging, ...scope } = args;
|
|
1360
|
+
const page = await cursorAware(() => runtime.runsPage({
|
|
1361
|
+
...scope,
|
|
1362
|
+
limit: paging?.limit ?? void 0,
|
|
1363
|
+
cursor: paging?.cursor ?? null,
|
|
1364
|
+
withStepData: readsStepData(info, ["items"])
|
|
1365
|
+
}, ctx));
|
|
1366
|
+
return {
|
|
1367
|
+
items: page.records.map((record) => toRunRecord(record.row, record.steps)),
|
|
1368
|
+
hasNextPage: page.hasNextPage,
|
|
1369
|
+
hasPreviousPage: Boolean(paging?.cursor),
|
|
1370
|
+
cursor: page.cursor
|
|
1371
|
+
};
|
|
1372
|
+
},
|
|
1373
|
+
run: async (_parent, args, ctx) => {
|
|
1374
|
+
const record = await runtime.run(args.id, ctx);
|
|
1375
|
+
return record ? toRunRecord(record.row, record.steps) : null;
|
|
1376
|
+
}
|
|
1377
|
+
},
|
|
1378
|
+
Mutation: { workflowRuntime: () => ({}) },
|
|
1379
|
+
WorkflowRuntimeMutations: {
|
|
1380
|
+
fire: (_parent, args, ctx) => runtime.fire(args.workflowId, args.payload, "manual", void 0, ctx),
|
|
1381
|
+
testTrigger: (_parent, args, ctx) => syncAware(() => runtime.testTrigger(args.workflowId, ctx, {
|
|
1382
|
+
...args.payload !== void 0 ? { payload: args.payload } : {},
|
|
1383
|
+
...typeof args.timeoutSeconds === "number" ? { timeoutMs: args.timeoutSeconds * 1e3 } : {},
|
|
1384
|
+
driveId: args.driveId ?? void 0
|
|
1385
|
+
})),
|
|
1386
|
+
cancelTriggerTest: (_parent, args, ctx) => runtime.cancelTriggerTestFor(args.workflowId, ctx),
|
|
1387
|
+
testStep: (_parent, args, ctx) => syncAware(() => runtime.testStep(args.workflowId, args.stepId, ctx, { driveId: args.driveId ?? void 0 })),
|
|
1388
|
+
rerun: (_parent, args, ctx) => runtime.rerun(args.runId, ctx),
|
|
1389
|
+
createSecret: async (_parent, args, ctx) => {
|
|
1390
|
+
requireAdmin(authorizationService, ctx);
|
|
1391
|
+
return (await runtime.secrets()).create({
|
|
1392
|
+
value: args.value,
|
|
1393
|
+
label: args.label ?? void 0
|
|
1394
|
+
});
|
|
1395
|
+
},
|
|
1396
|
+
rotateSecret: async (_parent, args, ctx) => {
|
|
1397
|
+
requireAdmin(authorizationService, ctx);
|
|
1398
|
+
return (await runtime.secrets()).rotate(args.ref, args.value);
|
|
1399
|
+
},
|
|
1400
|
+
deleteSecret: async (_parent, args, ctx) => {
|
|
1401
|
+
requireAdmin(authorizationService, ctx);
|
|
1402
|
+
await (await runtime.secrets()).delete(args.ref);
|
|
1403
|
+
return true;
|
|
1404
|
+
},
|
|
1405
|
+
checkConnection: (_parent, args, ctx) => runtime.checkConnection(args.connectionId, ctx),
|
|
1406
|
+
startOAuth: (_parent, args, ctx) => {
|
|
1407
|
+
requireAdmin(authorizationService, ctx);
|
|
1408
|
+
const returnUrl = returnUrlFor(args.returnUrl, ctx);
|
|
1409
|
+
return runtime.startOAuth(args.connectionId, ctx, {
|
|
1410
|
+
redirectUri: redirectUriFor(oauth, args.redirectUri),
|
|
1411
|
+
...returnUrl ? { returnUrl } : {}
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
};
|
|
1417
|
+
//#endregion
|
|
1418
|
+
//#region src/workflow/schema.ts
|
|
1419
|
+
const schema = gql`
|
|
1420
|
+
"""
|
|
1421
|
+
WorkflowRuntime Queries
|
|
1422
|
+
"""
|
|
1423
|
+
type WorkflowRuntimeQueries {
|
|
1424
|
+
health: String!
|
|
1425
|
+
"""
|
|
1426
|
+
Persisted runs, newest first. Scope them to one workflow, or to every
|
|
1427
|
+
workflow a drive holds; workflowId wins when both are given.
|
|
1428
|
+
"""
|
|
1429
|
+
runs(
|
|
1430
|
+
workflowId: String
|
|
1431
|
+
driveId: String
|
|
1432
|
+
limit: Int
|
|
1433
|
+
"Trigger kinds to leave out, e.g. test."
|
|
1434
|
+
excludeTriggerKinds: [String!]
|
|
1435
|
+
): [WorkflowRunRecord!]!
|
|
1436
|
+
"""
|
|
1437
|
+
The same listing a page at a time, newest journaled first. Pass a page's
|
|
1438
|
+
cursor back in paging to read the next; a page is short only when
|
|
1439
|
+
hasNextPage is false, or when the caller could read none of a long
|
|
1440
|
+
stretch of runs. A run that starts between pages keeps its place.
|
|
1441
|
+
"""
|
|
1442
|
+
runsPage(
|
|
1443
|
+
workflowId: String
|
|
1444
|
+
driveId: String
|
|
1445
|
+
excludeTriggerKinds: [String!]
|
|
1446
|
+
paging: WorkflowRunsPagingInput
|
|
1447
|
+
): WorkflowRunResultPage!
|
|
1448
|
+
run(id: String!): WorkflowRunRecord
|
|
1449
|
+
"""
|
|
1450
|
+
The descriptor (props, auth, ports) of one action or trigger, as the
|
|
1451
|
+
version that runs it describes it; triggers come back under "trigger".
|
|
1452
|
+
"""
|
|
1453
|
+
blockDescriptor(block: BlockInput!): Unknown
|
|
1454
|
+
"""
|
|
1455
|
+
Resolves a dynamic prop against the current config values: a DROPDOWN
|
|
1456
|
+
yields { options, placeholder, disabled }, a DYNAMIC prop yields the
|
|
1457
|
+
resolved sub-property descriptor list.
|
|
1458
|
+
"""
|
|
1459
|
+
blockOptions(
|
|
1460
|
+
block: BlockInput!
|
|
1461
|
+
propName: String!
|
|
1462
|
+
input: Unknown
|
|
1463
|
+
connectionId: String
|
|
1464
|
+
"What the author typed, for a DROPDOWN declared with refreshOnSearch."
|
|
1465
|
+
searchValue: String
|
|
1466
|
+
): Unknown
|
|
1467
|
+
"""
|
|
1468
|
+
Every piece this reactor offers: the core piece, its package pieces, and
|
|
1469
|
+
the pieces the registry and Activepieces publish.
|
|
1470
|
+
"""
|
|
1471
|
+
pieceCatalog: Unknown
|
|
1472
|
+
"""
|
|
1473
|
+
A piece's actions at one version; pin a step to that version.
|
|
1474
|
+
"""
|
|
1475
|
+
pieceActions(
|
|
1476
|
+
packageName: String!
|
|
1477
|
+
"That version; the installed or latest one when omitted."
|
|
1478
|
+
version: String
|
|
1479
|
+
): Unknown
|
|
1480
|
+
"""
|
|
1481
|
+
A piece's triggers at one version; pin a trigger to that version.
|
|
1482
|
+
"""
|
|
1483
|
+
pieceTriggers(
|
|
1484
|
+
packageName: String!
|
|
1485
|
+
"That version; the installed or latest one when omitted."
|
|
1486
|
+
version: String
|
|
1487
|
+
): Unknown
|
|
1488
|
+
"""
|
|
1489
|
+
Full piece detail (PieceMetadataModel-shaped), verbatim from the cloud API.
|
|
1490
|
+
"""
|
|
1491
|
+
pieceDetail(
|
|
1492
|
+
packageName: String!
|
|
1493
|
+
"That version; the installed or latest one when omitted."
|
|
1494
|
+
version: String
|
|
1495
|
+
): Unknown
|
|
1496
|
+
"""
|
|
1497
|
+
Action/trigger name search across the catalog. The index builds lazily on
|
|
1498
|
+
first use; poll while status is "indexing".
|
|
1499
|
+
"""
|
|
1500
|
+
searchBlocks(query: String!, limit: Int): BlockSearchResult!
|
|
1501
|
+
"""
|
|
1502
|
+
Health of every registered piece trigger (poll schedule, errors).
|
|
1503
|
+
"""
|
|
1504
|
+
triggerStates: [TriggerStateRecord!]!
|
|
1505
|
+
"""
|
|
1506
|
+
Authored output shape of a block (SDL / outputSchema / sampleData).
|
|
1507
|
+
"""
|
|
1508
|
+
blockOutputTree(block: BlockInput!, config: Unknown): Unknown
|
|
1509
|
+
"""
|
|
1510
|
+
Output shape of a draft step or trigger: its latest test output
|
|
1511
|
+
(source "test", with sample and testedAt) when tested, else as blockOutputTree.
|
|
1512
|
+
"""
|
|
1513
|
+
stepOutputTree(workflowId: String!, stepId: String!): Unknown
|
|
1514
|
+
"""
|
|
1515
|
+
Every block of the draft (trigger first) as this reactor resolves it:
|
|
1516
|
+
which piece version runs, from where, and how it matches the pin.
|
|
1517
|
+
"""
|
|
1518
|
+
blockResolutions(workflowId: String!): [BlockResolutionRecord!]!
|
|
1519
|
+
"""
|
|
1520
|
+
Every powerhouse/connection document, for connection pickers.
|
|
1521
|
+
"""
|
|
1522
|
+
connections: [ConnectionRecord!]!
|
|
1523
|
+
"""
|
|
1524
|
+
The webhook endpoint for a workflow: the URL to hand the provider. Minted
|
|
1525
|
+
on first ask, whether or not the workflow is armed — an author needs the
|
|
1526
|
+
URL before enabling, and the armed field carries that difference. Null
|
|
1527
|
+
only when the host has no webhook service. With driveId, a workflow not
|
|
1528
|
+
synced here yet is waited for briefly, then fails as WORKFLOW_SYNCING.
|
|
1529
|
+
"""
|
|
1530
|
+
webhookEndpoint(workflowId: String!, driveId: String): WebhookEndpointRecord
|
|
1531
|
+
"""
|
|
1532
|
+
Secret metadata (label, version, status). Never the value.
|
|
1533
|
+
"""
|
|
1534
|
+
secret(ref: String!): SecretRecord
|
|
1535
|
+
secrets: [SecretRecord!]!
|
|
1536
|
+
"""
|
|
1537
|
+
The URL an OAuth2 app must register as its redirect. A bare path when
|
|
1538
|
+
this host does not know its public origin; resolve it against the URL
|
|
1539
|
+
this API is reached at. Null when the host serves no HTTP routes.
|
|
1540
|
+
"""
|
|
1541
|
+
oauthRedirectUri: String
|
|
1542
|
+
"""
|
|
1543
|
+
How an OAuth2 sign-in opened by startOAuth stands; null when unknown.
|
|
1544
|
+
"""
|
|
1545
|
+
oauthAttempt(state: String!): OAuthAttempt
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
type OAuthAttempt {
|
|
1549
|
+
connectionId: String!
|
|
1550
|
+
"PENDING | EXCHANGING | OK | ERROR"
|
|
1551
|
+
status: String!
|
|
1552
|
+
error: String
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
type OAuthStart {
|
|
1556
|
+
state: String!
|
|
1557
|
+
"Where to send the user to sign in."
|
|
1558
|
+
authorizationUrl: String!
|
|
1559
|
+
redirectUri: String!
|
|
1560
|
+
expiresAt: String!
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
"""
|
|
1564
|
+
One action or trigger of a piece, as a workflow pins it. The engine's own
|
|
1565
|
+
blocks are the piece @powerhousedao/piece-core.
|
|
1566
|
+
"""
|
|
1567
|
+
input BlockInput {
|
|
1568
|
+
pieceName: String!
|
|
1569
|
+
"Exact semver."
|
|
1570
|
+
pieceVersion: String!
|
|
1571
|
+
"The action or trigger name."
|
|
1572
|
+
name: String!
|
|
1573
|
+
"action | trigger"
|
|
1574
|
+
kind: String!
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
type BlockSearchHit {
|
|
1578
|
+
pieceName: String!
|
|
1579
|
+
"The version the hit is listed at; pin to it when picking the block."
|
|
1580
|
+
pieceVersion: String!
|
|
1581
|
+
"The action or trigger name."
|
|
1582
|
+
name: String!
|
|
1583
|
+
pieceDisplayName: String!
|
|
1584
|
+
logoUrl: String!
|
|
1585
|
+
displayName: String!
|
|
1586
|
+
description: String!
|
|
1587
|
+
"action | trigger"
|
|
1588
|
+
kind: String!
|
|
1589
|
+
"Triggers only: POLLING | WEBHOOK | APP_WEBHOOK | MANUAL"
|
|
1590
|
+
strategy: String
|
|
1591
|
+
"Why this block cannot run on this reactor; null when it can."
|
|
1592
|
+
unsupported: String
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
type BlockResolutionRecord {
|
|
1596
|
+
"The trigger's or step's id."
|
|
1597
|
+
stepId: String!
|
|
1598
|
+
pieceName: String!
|
|
1599
|
+
"The version the block pins."
|
|
1600
|
+
pieceVersion: String!
|
|
1601
|
+
"The action or trigger name."
|
|
1602
|
+
name: String!
|
|
1603
|
+
"action | trigger"
|
|
1604
|
+
kind: String!
|
|
1605
|
+
"The version that runs; null when nothing resolves."
|
|
1606
|
+
resolvedVersion: String
|
|
1607
|
+
"local | registry | activepieces | npm; null when nothing resolves."
|
|
1608
|
+
source: String
|
|
1609
|
+
"exact | compatible | fallback | installed | missing"
|
|
1610
|
+
match: String!
|
|
1611
|
+
"Why the version that runs differs from the pin, or why nothing resolves."
|
|
1612
|
+
note: String
|
|
1613
|
+
"The newest version any source offers."
|
|
1614
|
+
latestVersion: String
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
type BlockSearchResult {
|
|
1618
|
+
"ready | indexing | error"
|
|
1619
|
+
status: String!
|
|
1620
|
+
hits: [BlockSearchHit!]!
|
|
1621
|
+
indexedPieces: Int!
|
|
1622
|
+
error: String
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
type SecretRecord {
|
|
1626
|
+
ref: String!
|
|
1627
|
+
label: String
|
|
1628
|
+
version: Int!
|
|
1629
|
+
status: String!
|
|
1630
|
+
createdAt: String!
|
|
1631
|
+
updatedAt: String!
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
type ConnectionRecord {
|
|
1635
|
+
id: String!
|
|
1636
|
+
name: String!
|
|
1637
|
+
connectorId: String!
|
|
1638
|
+
authType: String!
|
|
1639
|
+
status: String!
|
|
1640
|
+
accountLabel: String
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
type ConnectionCheckResult {
|
|
1644
|
+
ok: Boolean!
|
|
1645
|
+
detail: String
|
|
1646
|
+
"""
|
|
1647
|
+
From auth.getConnectionIdentifier when the check passes; otherwise the
|
|
1648
|
+
label the connection already holds.
|
|
1649
|
+
"""
|
|
1650
|
+
accountLabel: String
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
type WebhookEndpointRecord {
|
|
1654
|
+
workflowId: String!
|
|
1655
|
+
url: String!
|
|
1656
|
+
"""
|
|
1657
|
+
False when the host gave the webhook service no public origin, so the url
|
|
1658
|
+
above is a bare path. Switchboard always gives one, falling back to
|
|
1659
|
+
http://localhost:<port> when PUBLIC_URL is unset.
|
|
1660
|
+
"""
|
|
1661
|
+
absoluteUrl: Boolean!
|
|
1662
|
+
"True while the workflow is ENABLED with a valid webhook trigger"
|
|
1663
|
+
armed: Boolean!
|
|
1664
|
+
createdAt: String!
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
type TriggerStateRecord {
|
|
1668
|
+
workflowId: String!
|
|
1669
|
+
pieceName: String!
|
|
1670
|
+
triggerName: String!
|
|
1671
|
+
status: String!
|
|
1672
|
+
intervalMs: Int!
|
|
1673
|
+
nextPollAt: String
|
|
1674
|
+
lastPollAt: String
|
|
1675
|
+
lastError: String
|
|
1676
|
+
consecutiveFailures: Int!
|
|
1677
|
+
"When a webhook trigger next renews its subscription; null if it never does."
|
|
1678
|
+
nextRenewAt: String
|
|
1679
|
+
"The last failed renewal, kept apart from lastError, the poll's."
|
|
1680
|
+
renewError: String
|
|
1681
|
+
renewFailures: Int!
|
|
1682
|
+
"The piece version the trigger armed with; null for a host-fed trigger."
|
|
1683
|
+
pieceVersion: String
|
|
1684
|
+
pieceSource: String
|
|
1685
|
+
versionMatch: String
|
|
1686
|
+
versionNote: String
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
type Query {
|
|
1690
|
+
workflowRuntime: WorkflowRuntimeQueries!
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
type WorkflowStepRunRecord {
|
|
1694
|
+
stepId: String!
|
|
1695
|
+
stepKey: String!
|
|
1696
|
+
pieceName: String!
|
|
1697
|
+
"The action the step ran, or the trigger a trigger test sampled."
|
|
1698
|
+
blockName: String!
|
|
1699
|
+
status: String!
|
|
1700
|
+
input: Unknown
|
|
1701
|
+
output: Unknown
|
|
1702
|
+
port: String
|
|
1703
|
+
error: String
|
|
1704
|
+
startedAt: String
|
|
1705
|
+
endedAt: String
|
|
1706
|
+
"The piece version that ran; null for an unresolved block."
|
|
1707
|
+
pieceVersion: String
|
|
1708
|
+
"local | registry | activepieces | npm"
|
|
1709
|
+
pieceSource: String
|
|
1710
|
+
"exact | compatible | fallback | installed | missing"
|
|
1711
|
+
versionMatch: String
|
|
1712
|
+
"Why the version that ran differs from the pin."
|
|
1713
|
+
versionNote: String
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
type WorkflowRunRecord {
|
|
1717
|
+
id: String!
|
|
1718
|
+
workflowId: String!
|
|
1719
|
+
workflowName: String!
|
|
1720
|
+
workflowVersion: Int!
|
|
1721
|
+
triggerKind: String!
|
|
1722
|
+
triggerPayload: Unknown
|
|
1723
|
+
status: String!
|
|
1724
|
+
error: String
|
|
1725
|
+
"When the run began executing; a PENDING run's is when it was journaled."
|
|
1726
|
+
startedAt: String!
|
|
1727
|
+
endedAt: String
|
|
1728
|
+
rerunOf: String
|
|
1729
|
+
"How many warningNotes the run has; a run with any is not a plain success."
|
|
1730
|
+
warnings: Int!
|
|
1731
|
+
"Steps that ran a fallback piece version, edges on ports nothing emits."
|
|
1732
|
+
warningNotes: [String!]!
|
|
1733
|
+
steps: [WorkflowStepRunRecord!]!
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
input WorkflowRunsPagingInput {
|
|
1737
|
+
"Page size; default 25, at most 100."
|
|
1738
|
+
limit: Int
|
|
1739
|
+
"The cursor of the previous page."
|
|
1740
|
+
cursor: String
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
type WorkflowRunResultPage {
|
|
1744
|
+
items: [WorkflowRunRecord!]!
|
|
1745
|
+
hasNextPage: Boolean!
|
|
1746
|
+
"True when the page was read after a cursor."
|
|
1747
|
+
hasPreviousPage: Boolean!
|
|
1748
|
+
"Resumes after this page's last run; null for an empty page."
|
|
1749
|
+
cursor: String
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
type WorkflowStepRun {
|
|
1753
|
+
stepId: String!
|
|
1754
|
+
key: String!
|
|
1755
|
+
pieceName: String!
|
|
1756
|
+
blockName: String!
|
|
1757
|
+
status: String!
|
|
1758
|
+
input: Unknown
|
|
1759
|
+
output: Unknown
|
|
1760
|
+
port: String
|
|
1761
|
+
error: String
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
type WorkflowRunPayload {
|
|
1765
|
+
runId: String
|
|
1766
|
+
status: String!
|
|
1767
|
+
error: String
|
|
1768
|
+
steps: [WorkflowStepRun!]!
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
type WorkflowStepTestResult {
|
|
1772
|
+
"Null when the test never started, e.g. an upstream block is untested."
|
|
1773
|
+
runId: String
|
|
1774
|
+
"SUCCEEDED | FAILED"
|
|
1775
|
+
status: String!
|
|
1776
|
+
output: Unknown
|
|
1777
|
+
error: String
|
|
1778
|
+
durationMs: Int!
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
"""
|
|
1782
|
+
WorkflowRuntime Mutations
|
|
1783
|
+
"""
|
|
1784
|
+
type WorkflowRuntimeMutations {
|
|
1785
|
+
"""
|
|
1786
|
+
Fires a workflow's manual trigger (the core piece's) and runs it to completion.
|
|
1787
|
+
"""
|
|
1788
|
+
fire(workflowId: String!, payload: Unknown): WorkflowRunPayload!
|
|
1789
|
+
"""
|
|
1790
|
+
Samples the draft trigger and saves it as the trigger's last test. A piece
|
|
1791
|
+
trigger runs its test hook (no cursor changes). Of the core piece's
|
|
1792
|
+
triggers, manual takes payload, schedule samples a fire now, and webhook
|
|
1793
|
+
waits for the next delivery to the workflow's endpoint, up to
|
|
1794
|
+
timeoutSeconds (default and cap 300), and that delivery runs nothing.
|
|
1795
|
+
"""
|
|
1796
|
+
testTrigger(
|
|
1797
|
+
workflowId: String!
|
|
1798
|
+
payload: Unknown
|
|
1799
|
+
timeoutSeconds: Int
|
|
1800
|
+
driveId: String
|
|
1801
|
+
): Unknown
|
|
1802
|
+
"""
|
|
1803
|
+
Stops a webhook trigger test that is waiting; false when none was.
|
|
1804
|
+
"""
|
|
1805
|
+
cancelTriggerTest(workflowId: String!): Boolean!
|
|
1806
|
+
"""
|
|
1807
|
+
Runs one draft step against the latest test outputs of the blocks it
|
|
1808
|
+
reads, journaled as a "test" run and noted as the step's lastTest.
|
|
1809
|
+
driveId waits for an unsynced workflow, as for webhookEndpoint.
|
|
1810
|
+
"""
|
|
1811
|
+
testStep(
|
|
1812
|
+
workflowId: String!
|
|
1813
|
+
stepId: String!
|
|
1814
|
+
driveId: String
|
|
1815
|
+
): WorkflowStepTestResult!
|
|
1816
|
+
"""
|
|
1817
|
+
Resumes a FAILED run: succeeded steps replay from the journal,
|
|
1818
|
+
execution restarts at the failure. Produces a new run.
|
|
1819
|
+
"""
|
|
1820
|
+
rerun(runId: String!): WorkflowRunPayload!
|
|
1821
|
+
"""
|
|
1822
|
+
Mints a managed secret and returns its ref; the value is stored
|
|
1823
|
+
encrypted and is never readable back over any API.
|
|
1824
|
+
"""
|
|
1825
|
+
createSecret(value: String!, label: String): SecretRecord!
|
|
1826
|
+
"""
|
|
1827
|
+
Replaces the value behind an existing ref; documents stay untouched.
|
|
1828
|
+
"""
|
|
1829
|
+
rotateSecret(ref: String!, value: String!): SecretRecord!
|
|
1830
|
+
"""
|
|
1831
|
+
Tombstones a secret; its value becomes unrecoverable.
|
|
1832
|
+
"""
|
|
1833
|
+
deleteSecret(ref: String!): Boolean!
|
|
1834
|
+
"""
|
|
1835
|
+
Runs the piece's auth.validate against the connection's credentials,
|
|
1836
|
+
then auth.getConnectionIdentifier for the account label, and records the
|
|
1837
|
+
outcome on the connection document.
|
|
1838
|
+
"""
|
|
1839
|
+
checkConnection(connectionId: String!): ConnectionCheckResult!
|
|
1840
|
+
"""
|
|
1841
|
+
Opens an OAuth2 sign-in with the connection's own app (client_id in its
|
|
1842
|
+
config, client_secret in its secretRefs). redirectUri is needed only when
|
|
1843
|
+
oauthRedirectUri is a bare path. With returnUrl, which must be on the
|
|
1844
|
+
caller's origin, the callback sends the browser back there with
|
|
1845
|
+
?ph_oauth=<state> instead of closing the window.
|
|
1846
|
+
"""
|
|
1847
|
+
startOAuth(
|
|
1848
|
+
connectionId: String!
|
|
1849
|
+
redirectUri: String
|
|
1850
|
+
returnUrl: String
|
|
1851
|
+
): OAuthStart!
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
type Mutation {
|
|
1855
|
+
workflowRuntime: WorkflowRuntimeMutations!
|
|
1856
|
+
}
|
|
1857
|
+
`;
|
|
1858
|
+
//#endregion
|
|
1859
|
+
//#region src/workflow/subgraph.ts
|
|
1860
|
+
/** The runtime's read/write surface. The runtime itself is composed by the
|
|
1861
|
+
* host, so the subgraph only serves what it is handed. */
|
|
1862
|
+
function createWorkflowRuntimeSubgraph(runtime, oauth) {
|
|
1863
|
+
return class WorkflowRuntimeSubgraph extends BaseSubgraph {
|
|
1864
|
+
name = "workflow-runtime";
|
|
1865
|
+
typeDefs = schema;
|
|
1866
|
+
resolvers = getResolvers(runtime, this.authorizationService, oauth);
|
|
1867
|
+
additionalContextFields = {};
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
//#endregion
|
|
1871
|
+
//#region src/workflow-runtime.mts
|
|
1872
|
+
/** The npm name the workflow package owns: its HTTP namespace and its models. */
|
|
1873
|
+
const WORKFLOW_PACKAGE_NAME = "@powerhousedao/workflow";
|
|
1874
|
+
/** The env var and OpenFeature flag key that turns workflows on. */
|
|
1875
|
+
const PH_WORKFLOWS_ENABLED = "PH_WORKFLOWS_ENABLED";
|
|
1876
|
+
/** Precedence, unchanged from the reactor-api resolver this replaces: the
|
|
1877
|
+
* host's option, then PH_WORKFLOWS_ENABLED, then the config file, then off. */
|
|
1878
|
+
async function resolveWorkflowsEnabled({ featureFlags, override, configEnabled = false, env = process.env }) {
|
|
1879
|
+
if (override !== void 0) return override;
|
|
1880
|
+
const raw = env[PH_WORKFLOWS_ENABLED]?.trim();
|
|
1881
|
+
if (raw === "1") return true;
|
|
1882
|
+
if (raw === "0") return false;
|
|
1883
|
+
return featureFlags.getBooleanValue(PH_WORKFLOWS_ENABLED, configEnabled);
|
|
1884
|
+
}
|
|
1885
|
+
async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao/workflow/document-models")) {
|
|
1886
|
+
try {
|
|
1887
|
+
await load();
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
throw new Error(`Workflows are enabled but ${WORKFLOW_PACKAGE_NAME} could not be loaded`, { cause: error });
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
function readAssertion(authorizationService, reactorClient) {
|
|
1893
|
+
const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
|
|
1894
|
+
const hostCanRead = async (identifier, ctx) => {
|
|
1895
|
+
if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
|
|
1896
|
+
if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
|
|
1897
|
+
let documentId;
|
|
1898
|
+
try {
|
|
1899
|
+
documentId = await resolveCanonical(identifier);
|
|
1900
|
+
} catch {
|
|
1901
|
+
throw new ForbiddenError();
|
|
1902
|
+
}
|
|
1903
|
+
if (!await authorizationService.canRead(documentId, ctx.user?.address)) throw new ForbiddenError("to read this document");
|
|
1904
|
+
};
|
|
1905
|
+
return async (identifier, caller) => {
|
|
1906
|
+
const ctx = caller;
|
|
1907
|
+
await hostCanRead(identifier, ctx);
|
|
1908
|
+
let served;
|
|
1909
|
+
try {
|
|
1910
|
+
served = await reactorClient.isServed(identifier, { subject: callerSubject(ctx.user) });
|
|
1911
|
+
} catch {
|
|
1912
|
+
throw new ForbiddenError();
|
|
1913
|
+
}
|
|
1914
|
+
if (!served) throw new ForbiddenError("to read this document");
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
/** The same, for a design-time call that writes what it names. */
|
|
1918
|
+
function writeAssertion(authorizationService, reactorClient) {
|
|
1919
|
+
const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
|
|
1920
|
+
return async (identifier, caller) => {
|
|
1921
|
+
const ctx = caller;
|
|
1922
|
+
if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
|
|
1923
|
+
if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
|
|
1924
|
+
let documentId;
|
|
1925
|
+
try {
|
|
1926
|
+
documentId = await resolveCanonical(identifier);
|
|
1927
|
+
} catch {
|
|
1928
|
+
throw new ForbiddenError();
|
|
1929
|
+
}
|
|
1930
|
+
if (!await authorizationService.canWrite(documentId, ctx.user?.address)) throw new ForbiddenError("to write this document");
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
/** Whether the workflow document really references the attachment. A step
|
|
1934
|
+
* carries no caller, so the relationship is the whole check. */
|
|
1935
|
+
function attachmentRefCheck(deps) {
|
|
1936
|
+
const resolveCanonical = createCanonicalDocumentIdResolver(deps.reactorClient);
|
|
1937
|
+
const references = deps.attachmentReferences;
|
|
1938
|
+
const projection = deps.attachmentReferenceProjection;
|
|
1939
|
+
return async (documentId, ref) => {
|
|
1940
|
+
if (!references || projection?.status !== "available") return false;
|
|
1941
|
+
let parsed;
|
|
1942
|
+
try {
|
|
1943
|
+
parsed = parseRef(ref);
|
|
1944
|
+
} catch {
|
|
1945
|
+
return false;
|
|
1946
|
+
}
|
|
1947
|
+
if (parsed.version !== 1) return false;
|
|
1948
|
+
const canonicalRef = createRef(parsed.hash.toLowerCase());
|
|
1949
|
+
try {
|
|
1950
|
+
return await references.hasReference(await resolveCanonical(documentId), canonicalRef);
|
|
1951
|
+
} catch {
|
|
1952
|
+
return false;
|
|
1953
|
+
}
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
async function registerWorkflowTriggersReadModel(engine, runtime, clientModule) {
|
|
1957
|
+
const reactorModule = clientModule?.reactorModule;
|
|
1958
|
+
if (!reactorModule) return {
|
|
1959
|
+
status: "unavailable",
|
|
1960
|
+
reason: "in-process-reactor-module-unavailable"
|
|
1961
|
+
};
|
|
1962
|
+
const coordinator = reactorModule.readModelCoordinator;
|
|
1963
|
+
if (!supportsLiveReadModelRegistration(coordinator)) return {
|
|
1964
|
+
status: "unavailable",
|
|
1965
|
+
reason: "live-read-model-registration-unsupported"
|
|
1966
|
+
};
|
|
1967
|
+
const readModel = new engine.WorkflowTriggersReadModel(reactorModule.database.withSchema(REACTOR_SCHEMA), reactorModule.operationIndex, reactorModule.writeCache, reactorModule.processorManagerConsistencyTracker, runtime);
|
|
1968
|
+
await readModel.init();
|
|
1969
|
+
coordinator.addReadModel(readModel, engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE);
|
|
1970
|
+
return { status: "available" };
|
|
1971
|
+
}
|
|
1972
|
+
function bindPackagePieces(registry, source) {
|
|
1973
|
+
const apply = (byPackage) => {
|
|
1974
|
+
registry.setPieces([...byPackage.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).flatMap(([, pieces]) => pieces));
|
|
1975
|
+
};
|
|
1976
|
+
apply(source.getPieces());
|
|
1977
|
+
source.onPiecesChange(apply);
|
|
1978
|
+
}
|
|
1979
|
+
async function composeWorkflowRuntime(deps) {
|
|
1980
|
+
const load = deps.load ?? (() => import("@powerhousedao/reactor-workflow"));
|
|
1981
|
+
let engine;
|
|
1982
|
+
try {
|
|
1983
|
+
engine = await load();
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
throw new Error("Workflows are enabled but @powerhousedao/reactor-workflow could not be loaded", { cause: error });
|
|
1986
|
+
}
|
|
1987
|
+
engine.setPieceRegistryUrl(deps.pieceRegistryUrl);
|
|
1988
|
+
if (deps.pieces) bindPackagePieces(engine.packagePieces, deps.pieces);
|
|
1989
|
+
const runtime = engine.createWorkflowRuntime({
|
|
1990
|
+
relationalDb: deps.relationalDb,
|
|
1991
|
+
secretsKeyFile: deps.secretsKeyFile,
|
|
1992
|
+
reactorClient: deps.reactorClient,
|
|
1993
|
+
assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
|
|
1994
|
+
subjectOf: (caller) => callerSubject(caller.user),
|
|
1995
|
+
assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
|
|
1996
|
+
webhooks: deps.webhooks,
|
|
1997
|
+
attachments: deps.attachments,
|
|
1998
|
+
canReadAttachmentRef: attachmentRefCheck(deps),
|
|
1999
|
+
logger: deps.logger
|
|
2000
|
+
});
|
|
2001
|
+
const triggers = await registerWorkflowTriggersReadModel(engine, runtime, deps.clientModule);
|
|
2002
|
+
if (triggers.status === "available") deps.logger.info(`Workflow trigger read model registered (${engine.WORKFLOW_TRIGGERS_READ_MODEL}, ${engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE})`);
|
|
2003
|
+
else deps.logger.error("Workflow document triggers are NOT armed (@reason): this reactor's read-model coordinator takes no live registration, so no document operation reaches the runtime. Webhook and schedule triggers are unaffected.", triggers.reason);
|
|
2004
|
+
let stopped = false;
|
|
2005
|
+
const oauthCallback = deps.http ? registerOAuthCallback(deps.http, runtime) : void 0;
|
|
2006
|
+
return {
|
|
2007
|
+
subgraph: createWorkflowRuntimeSubgraph(runtime, deps.http ? { callbackUrl: callbackUrlOf(deps.http) } : void 0),
|
|
2008
|
+
triggers,
|
|
2009
|
+
async start() {
|
|
2010
|
+
await runtime.registerWebhookEndpoint();
|
|
2011
|
+
runtime.startTriggerSupervisor();
|
|
2012
|
+
},
|
|
2013
|
+
stop() {
|
|
2014
|
+
if (stopped) return Promise.resolve();
|
|
2015
|
+
stopped = true;
|
|
2016
|
+
oauthCallback?.dispose();
|
|
2017
|
+
runtime.shutdown();
|
|
2018
|
+
return Promise.resolve();
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
//#endregion
|
|
868
2023
|
//#region src/pglite-dialect.ts
|
|
869
2024
|
var ClosablePGliteDialect = class extends PGliteDialect {
|
|
870
2025
|
#pglite;
|
|
@@ -1003,6 +2158,26 @@ function resolveReactorFeatureFlags(env) {
|
|
|
1003
2158
|
};
|
|
1004
2159
|
}
|
|
1005
2160
|
//#endregion
|
|
2161
|
+
//#region src/create-signature-policy.mts
|
|
2162
|
+
/**
|
|
2163
|
+
* What new documents are created as, from CREATE_SIGNATURE_POLICY. Without a
|
|
2164
|
+
* signer this switchboard cannot write to a v2-required document, so it falls
|
|
2165
|
+
* back to legacy and says so.
|
|
2166
|
+
*/
|
|
2167
|
+
function resolveCreateSignaturePolicy(env, options) {
|
|
2168
|
+
const raw = env.CREATE_SIGNATURE_POLICY?.trim();
|
|
2169
|
+
let policy = DEFAULT_SIGNATURE_POLICY;
|
|
2170
|
+
if (raw) {
|
|
2171
|
+
if (!isSignaturePolicy(raw)) throw new Error(`CREATE_SIGNATURE_POLICY must be "legacy" or "v2-required", got "${raw}"`);
|
|
2172
|
+
policy = raw;
|
|
2173
|
+
}
|
|
2174
|
+
if (policy === "v2-required" && !options.hasSigner) {
|
|
2175
|
+
options.logger?.warn("No signer is configured, so new documents are created legacy: a v2-required document refuses the unsigned operations this switchboard would write. Set up an identity (ph login) to create v2-required documents.");
|
|
2176
|
+
return "legacy";
|
|
2177
|
+
}
|
|
2178
|
+
return policy;
|
|
2179
|
+
}
|
|
2180
|
+
//#endregion
|
|
1006
2181
|
//#region src/renown.ts
|
|
1007
2182
|
const logger = childLogger(["switchboard", "renown"]);
|
|
1008
2183
|
/**
|
|
@@ -1025,14 +2200,122 @@ async function initRenown(options = {}) {
|
|
|
1025
2200
|
* Get the signer config for the given renown instance.
|
|
1026
2201
|
*
|
|
1027
2202
|
* @param renown - The renown instance
|
|
1028
|
-
* @param
|
|
2203
|
+
* @param keypairPath - Where `initRenown` loaded the key from
|
|
1029
2204
|
*/
|
|
1030
|
-
function getRenownSignerConfig(renown,
|
|
2205
|
+
function getRenownSignerConfig(renown, keypairPath) {
|
|
1031
2206
|
return {
|
|
1032
2207
|
signer: renown.signer,
|
|
1033
|
-
|
|
2208
|
+
workerSigner: getRenownWorkerSignerSpec(renown, keypairPath)
|
|
1034
2209
|
};
|
|
1035
2210
|
}
|
|
2211
|
+
/**
|
|
2212
|
+
* What a pooled executor worker imports to sign as this switchboard: it
|
|
2213
|
+
* reloads the key `initRenown` stored, and takes the user known at boot.
|
|
2214
|
+
*/
|
|
2215
|
+
function getRenownWorkerSignerSpec(renown, keypairPath) {
|
|
2216
|
+
const { signer } = renown;
|
|
2217
|
+
const user = signer.user;
|
|
2218
|
+
return {
|
|
2219
|
+
module: {
|
|
2220
|
+
filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
|
|
2221
|
+
exportName: "createNodeRenownSigner"
|
|
2222
|
+
},
|
|
2223
|
+
initArgs: {
|
|
2224
|
+
appName: signer.app?.name ?? "switchboard",
|
|
2225
|
+
keypairPath: resolve(keypairPath ?? DEFAULT_KEYPAIR_PATH),
|
|
2226
|
+
...signer.app?.key ? { did: signer.app.key } : {},
|
|
2227
|
+
...user ? { user: {
|
|
2228
|
+
address: user.address,
|
|
2229
|
+
networkId: user.networkId,
|
|
2230
|
+
chainId: user.chainId
|
|
2231
|
+
} } : {}
|
|
2232
|
+
}
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Admits a key as a signer for an address a Renown credential binds it to; a
|
|
2237
|
+
* `self` source has no worker spec. In process, the switchboard's own key is
|
|
2238
|
+
* matched against its current user; the worker spec carries the user known now.
|
|
2239
|
+
*/
|
|
2240
|
+
async function getRenownTrustPolicyConfig(trustSource, renown) {
|
|
2241
|
+
const ownSigner = renown?.signer;
|
|
2242
|
+
if (trustSource.source === "self") return { trustPolicy: createRenownTrustPolicy({
|
|
2243
|
+
switchboard: trustSource.request,
|
|
2244
|
+
ownSigner
|
|
2245
|
+
}) };
|
|
2246
|
+
const switchboardUrl = await resolveSwitchboardEndpoint({
|
|
2247
|
+
switchboardUrl: trustSource.switchboardUrl,
|
|
2248
|
+
baseUrl: trustSource.renownUrl
|
|
2249
|
+
});
|
|
2250
|
+
const options = {
|
|
2251
|
+
...switchboardUrl ? { switchboard: switchboardUrl } : {},
|
|
2252
|
+
...trustSource.renownUrl ? { renownUrl: trustSource.renownUrl } : {}
|
|
2253
|
+
};
|
|
2254
|
+
const self = ownIdentity(renown);
|
|
2255
|
+
return {
|
|
2256
|
+
trustPolicy: createRenownTrustPolicy({
|
|
2257
|
+
...options,
|
|
2258
|
+
ownSigner
|
|
2259
|
+
}),
|
|
2260
|
+
workerTrustPolicy: {
|
|
2261
|
+
module: {
|
|
2262
|
+
filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
|
|
2263
|
+
exportName: "createRenownTrustPolicy"
|
|
2264
|
+
},
|
|
2265
|
+
initArgs: {
|
|
2266
|
+
...options,
|
|
2267
|
+
...self ? { self } : {}
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Fails the boot when pooled workers would refuse every signed write the
|
|
2274
|
+
* in-process executor accepts: a `self` Renown source gives workers no trust
|
|
2275
|
+
* policy spec, so under authEnforcement they apply the default.
|
|
2276
|
+
*/
|
|
2277
|
+
function assertWorkerTrustPolicy(config) {
|
|
2278
|
+
if (config.workers > 0 && config.authEnforcement && config.renownSource === "self") throw new Error("The executor worker pool (REACTOR_WORKERS) cannot verify signers when REACTOR_AUTH_ENFORCEMENT is on and Renown credentials are read from this switchboard (\"self\" via auth.renown.source or RENOWN_SOURCE): pooled workers have no trust policy and would refuse every signed write. Set RENOWN_SOURCE=remote, or disable the worker pool (REACTOR_WORKERS=0).");
|
|
2279
|
+
}
|
|
2280
|
+
function ownIdentity(renown) {
|
|
2281
|
+
const key = renown?.signer.app?.key;
|
|
2282
|
+
if (!key) return;
|
|
2283
|
+
const user = renown.signer.user;
|
|
2284
|
+
return {
|
|
2285
|
+
key,
|
|
2286
|
+
user: {
|
|
2287
|
+
address: user?.address ?? "",
|
|
2288
|
+
networkId: user?.networkId ?? "",
|
|
2289
|
+
chainId: user?.chainId ?? 0
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
//#endregion
|
|
2294
|
+
//#region src/unsupported-stored-documents.mts
|
|
2295
|
+
const MODES = ["refuse", "read-only"];
|
|
2296
|
+
/** From REACTOR_UNSUPPORTED_STORED_DOCUMENTS; `input` wins when defined. */
|
|
2297
|
+
function resolveUnsupportedStoredDocuments(input, env) {
|
|
2298
|
+
if (input !== void 0) return input;
|
|
2299
|
+
const raw = env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS?.trim();
|
|
2300
|
+
if (!raw) return "refuse";
|
|
2301
|
+
if (!MODES.includes(raw)) throw new Error(`REACTOR_UNSUPPORTED_STORED_DOCUMENTS must be "refuse" or "read-only", got "${raw}"`);
|
|
2302
|
+
return raw;
|
|
2303
|
+
}
|
|
2304
|
+
/** The reactor refused to start over documents this build does not run. */
|
|
2305
|
+
var StoredDocumentsRefusedError = class extends Error {
|
|
2306
|
+
versions;
|
|
2307
|
+
documents;
|
|
2308
|
+
constructor(cause) {
|
|
2309
|
+
const named = cause.versions.map(({ protocol, version }) => `${protocol} ${version}`).join(", ");
|
|
2310
|
+
super(`Refusing to start: ${cause.documents} stored document(s) require ${named}, which this switchboard does not run. Either start a switchboard build that runs ${named}, or set REACTOR_UNSUPPORTED_STORED_DOCUMENTS=read-only to start with those documents read-only.`, { cause });
|
|
2311
|
+
this.name = "StoredDocumentsRefusedError";
|
|
2312
|
+
this.versions = cause.versions;
|
|
2313
|
+
this.documents = cause.documents;
|
|
2314
|
+
}
|
|
2315
|
+
static isError(error) {
|
|
2316
|
+
return Error.isError(error) && error.name === "StoredDocumentsRefusedError";
|
|
2317
|
+
}
|
|
2318
|
+
};
|
|
1036
2319
|
//#endregion
|
|
1037
2320
|
//#region src/server.mts
|
|
1038
2321
|
const defaultLogger = childLogger(["switchboard"]);
|
|
@@ -1041,8 +2324,6 @@ setLogLevel(LogLevel);
|
|
|
1041
2324
|
dotenv.config();
|
|
1042
2325
|
const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
|
|
1043
2326
|
const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
|
|
1044
|
-
const REQUIRE_SIGNATURES = "REQUIRE_SIGNATURES";
|
|
1045
|
-
const REQUIRE_SIGNATURES_DEFAULT = false;
|
|
1046
2327
|
const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
|
|
1047
2328
|
const PORT_FALLBACK_ATTEMPTS = 20;
|
|
1048
2329
|
const PGLITE_FLUSH_INTERVAL_MS = (() => {
|
|
@@ -1077,6 +2358,13 @@ function isPortAvailable(port) {
|
|
|
1077
2358
|
function resolveConfigPath(configFile) {
|
|
1078
2359
|
return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
|
|
1079
2360
|
}
|
|
2361
|
+
function readConfigWorkflowsEnabled(configPath) {
|
|
2362
|
+
try {
|
|
2363
|
+
return getConfig(configPath).workflows?.enabled ?? false;
|
|
2364
|
+
} catch {
|
|
2365
|
+
return false;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
1080
2368
|
async function resolveServerPort(requested, strictPort, logger) {
|
|
1081
2369
|
if (strictPort) return requested;
|
|
1082
2370
|
for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
|
|
@@ -1189,8 +2477,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1189
2477
|
}
|
|
1190
2478
|
if (workerPool) {
|
|
1191
2479
|
if (dev) throw new Error("The executor worker pool (REACTOR_WORKERS) is not supported in dev mode: Vite-loaded document models cannot cross a worker-thread boundary");
|
|
2480
|
+
assertWorkerTrustPolicy({
|
|
2481
|
+
workers: workerPool.numWorkers,
|
|
2482
|
+
authEnforcement: resolveReactorFeatureFlags(process.env).flags.authEnforcement === true,
|
|
2483
|
+
renownSource: renownConfig.source
|
|
2484
|
+
});
|
|
1192
2485
|
if (!reactorDbUrl || !isPostgresUrl(reactorDbUrl)) throw new Error("The executor worker pool (REACTOR_WORKERS) requires a Postgres reactor database — set PH_REACTOR_DATABASE_URL or PH_SWITCHBOARD_DATABASE_URL. PGlite cannot be shared across worker threads.");
|
|
1193
2486
|
}
|
|
2487
|
+
const unsupportedStoredDocuments = resolveUnsupportedStoredDocuments(options.unsupportedStoredDocuments, process.env);
|
|
1194
2488
|
let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
|
|
1195
2489
|
if (projectionWorker && options.reactor) {
|
|
1196
2490
|
logger.warn("Projection worker configuration ignored: the caller-provided reactor owns its own read-model coordinator");
|
|
@@ -1224,7 +2518,13 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1224
2518
|
reactorLogger.error(`Projection worker ${shardId} died; shutting down so the supervisor restarts a healthy process`, reason);
|
|
1225
2519
|
process.kill(process.pid, "SIGTERM");
|
|
1226
2520
|
};
|
|
2521
|
+
const workflowsEnabled = options.workflows?.enabled === true;
|
|
2522
|
+
if (workflowsEnabled) {
|
|
2523
|
+
await assertWorkflowPackageLoadable();
|
|
2524
|
+
if (!packages.includes("@powerhousedao/workflow")) packages.push(WORKFLOW_PACKAGE_NAME);
|
|
2525
|
+
}
|
|
1227
2526
|
let ownedReactorModule;
|
|
2527
|
+
let localRenownRequest;
|
|
1228
2528
|
const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
|
|
1229
2529
|
if (options.reactor) {
|
|
1230
2530
|
const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
|
|
@@ -1251,7 +2551,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1251
2551
|
if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
|
|
1252
2552
|
const { flags: reactorFeatureFlags, enabled: enabledFeatureFlags } = resolveReactorFeatureFlags(process.env);
|
|
1253
2553
|
if (enabledFeatureFlags.length > 0) logger.info(`Reactor feature flags enabled: ${enabledFeatureFlags.join(", ")}`);
|
|
1254
|
-
const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" });
|
|
2554
|
+
const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" }).withUnsupportedStoredDocuments(unsupportedStoredDocuments);
|
|
1255
2555
|
if (poolInstrumentation) reactorBuilder.withInstrumentedPool(poolInstrumentation);
|
|
1256
2556
|
const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
|
|
1257
2557
|
const vetraDocumentModels = dev ? Object.values(await import("@powerhousedao/vetra/document-models")).filter((m) => typeof m === "object" && m !== null && "documentModel" in m && "reducer" in m) : [];
|
|
@@ -1264,7 +2564,19 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1264
2564
|
} : void 0,
|
|
1265
2565
|
documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
|
|
1266
2566
|
logger: reactorLogger,
|
|
1267
|
-
signer: renown ? getRenownSignerConfig(renown, options.identity?.
|
|
2567
|
+
signer: renown ? getRenownSignerConfig(renown, options.identity?.keypairPath) : void 0,
|
|
2568
|
+
createSignaturePolicy: resolveCreateSignaturePolicy(process.env, {
|
|
2569
|
+
hasSigner: renown !== null,
|
|
2570
|
+
logger
|
|
2571
|
+
}),
|
|
2572
|
+
trustPolicy: reactorFeatureFlags.authEnforcement ? await getRenownTrustPolicyConfig(renownConfig.source === "self" ? {
|
|
2573
|
+
source: "self",
|
|
2574
|
+
request: (query, variables) => localRenownRequest ? localRenownRequest(query, variables) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet"))
|
|
2575
|
+
} : {
|
|
2576
|
+
source: "remote",
|
|
2577
|
+
renownUrl: renownConfig.url,
|
|
2578
|
+
switchboardUrl: renownConfig.switchboardUrl
|
|
2579
|
+
}, renown) : void 0
|
|
1268
2580
|
});
|
|
1269
2581
|
if (workerPool) {
|
|
1270
2582
|
if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
|
|
@@ -1308,17 +2620,30 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1308
2620
|
reactorBuilder.withShutdownHook(async () => {
|
|
1309
2621
|
if (apiRef.current) await apiRef.current.dispose();
|
|
1310
2622
|
});
|
|
1311
|
-
|
|
2623
|
+
let module;
|
|
2624
|
+
try {
|
|
2625
|
+
module = await clientBuilder.buildModule();
|
|
2626
|
+
} catch (error) {
|
|
2627
|
+
try {
|
|
2628
|
+
await baseKysely.destroy();
|
|
2629
|
+
} catch (destroyError) {
|
|
2630
|
+
logger.error("Aborting boot: reactor database destroy failed: @error", destroyError);
|
|
2631
|
+
}
|
|
2632
|
+
if (UnsupportedStoredProtocolError.isError(error)) throw new StoredDocumentsRefusedError(error);
|
|
2633
|
+
throw error;
|
|
2634
|
+
}
|
|
1312
2635
|
if (module.reactorModule) {
|
|
1313
2636
|
new ReactorInstrumentation(module.reactorModule).start();
|
|
1314
2637
|
reactorLogger.info("Reactor metrics instrumentation started");
|
|
1315
2638
|
}
|
|
1316
|
-
|
|
2639
|
+
const reactorDriveSchemaDb = baseKysely.withSchema(REACTOR_SCHEMA);
|
|
2640
|
+
driveNodeView = new DriveNodeView(reactorDriveSchemaDb);
|
|
1317
2641
|
const reactorDriveClient = new ReactorDriveClient({
|
|
1318
2642
|
reactor: module.client,
|
|
1319
2643
|
readModel: driveNodeView
|
|
1320
2644
|
});
|
|
1321
2645
|
ownedReactorModule = module;
|
|
2646
|
+
if (options.fatalErrorShutdown) installFatalErrorShutdown(logger);
|
|
1322
2647
|
return {
|
|
1323
2648
|
module,
|
|
1324
2649
|
reactorDriveClient,
|
|
@@ -1352,7 +2677,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1352
2677
|
if (dev) {
|
|
1353
2678
|
const { VitePackageLoader, createViteLogger, startViteServer } = await import("@powerhousedao/reactor-api/vite");
|
|
1354
2679
|
vite = await startViteServer(process.cwd(), createViteLogger(logger));
|
|
1355
|
-
viteLoader = VitePackageLoader.build(vite);
|
|
2680
|
+
viteLoader = VitePackageLoader.build(vite, { resolveFrom: [import.meta.url] });
|
|
1356
2681
|
}
|
|
1357
2682
|
const vetraProcessorFactory = dev ? (await import("@powerhousedao/vetra/processors")).processorFactory : void 0;
|
|
1358
2683
|
if (!options.disableLocalPackages) packages.push(basePath);
|
|
@@ -1401,7 +2726,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1401
2726
|
await abortBoot(api);
|
|
1402
2727
|
throw new Error(`Renown credential verification is set to "self" (auth.renown.source or RENOWN_SOURCE) but no loaded package serves the "${RENOWN_READ_MODEL_SUBGRAPH}" subgraph. Install one (@powerhousedao/renown-package) or set RENOWN_SOURCE=remote.`);
|
|
1403
2728
|
}
|
|
1404
|
-
|
|
2729
|
+
localRenownRequest = (query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables);
|
|
2730
|
+
localCredentialCheck = createLocalCredentialVerifier(localRenownRequest, { onError: (error) => logger.error("Renown read model query failed: @error", error) });
|
|
1405
2731
|
logger.info("Renown credentials will be verified against this switchboard's own renown read model");
|
|
1406
2732
|
}
|
|
1407
2733
|
registerAttachmentRoutes(api);
|
|
@@ -1409,6 +2735,45 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1409
2735
|
if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
|
|
1410
2736
|
const { client, graphqlManager, documentModelRegistry } = api;
|
|
1411
2737
|
const lateSubgraphs = [];
|
|
2738
|
+
let workflows;
|
|
2739
|
+
if (workflowsEnabled) {
|
|
2740
|
+
workflows = await composeWorkflowRuntime({
|
|
2741
|
+
reactorClient: client,
|
|
2742
|
+
clientModule: options.reactor ?? ownedReactorModule,
|
|
2743
|
+
relationalDb: api.relationalDb,
|
|
2744
|
+
secretsKeyFile: readModelPgliteDir === null ? false : void 0,
|
|
2745
|
+
attachments: createAttachmentClient(api.attachments.service),
|
|
2746
|
+
attachmentReferences: api.attachmentReferenceIndex.store,
|
|
2747
|
+
attachmentReferenceProjection: api.attachmentReferenceProjection,
|
|
2748
|
+
webhooks: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME).webhooks,
|
|
2749
|
+
http: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME),
|
|
2750
|
+
authorizationService: api.authorizationService,
|
|
2751
|
+
pieces: api.packageManager,
|
|
2752
|
+
pieceRegistryUrl: registryUrl,
|
|
2753
|
+
logger: logger.child(["workflow-runtime"])
|
|
2754
|
+
});
|
|
2755
|
+
const WorkflowRuntimeSubgraph = workflows.subgraph;
|
|
2756
|
+
const workflowSubgraph = new WorkflowRuntimeSubgraph({
|
|
2757
|
+
reactorClient: client,
|
|
2758
|
+
http: graphqlManager.scopeForPackage(WORKFLOW_PACKAGE_NAME),
|
|
2759
|
+
relationalDb: api.relationalDb,
|
|
2760
|
+
analyticsStore: void 0,
|
|
2761
|
+
graphqlManager,
|
|
2762
|
+
syncManager: api.syncManager,
|
|
2763
|
+
authorizationService: graphqlManager.getAuthorizationService(),
|
|
2764
|
+
path: graphqlManager.getBasePath()
|
|
2765
|
+
});
|
|
2766
|
+
lateSubgraphs.push(graphqlManager.registerSubgraphInstance(workflowSubgraph, "graphql", false).catch((error) => {
|
|
2767
|
+
logger.error("Failed to register workflow-runtime subgraph: @error", error);
|
|
2768
|
+
}));
|
|
2769
|
+
await workflows.start();
|
|
2770
|
+
logger.info("Workflow runtime started");
|
|
2771
|
+
}
|
|
2772
|
+
const shutdown = async () => {
|
|
2773
|
+
await workflows?.stop();
|
|
2774
|
+
await api.dispose();
|
|
2775
|
+
};
|
|
2776
|
+
apiRef.current = { dispose: shutdown };
|
|
1412
2777
|
if (httpLoader) {
|
|
1413
2778
|
const packageManagementService = new PackageManagementService({
|
|
1414
2779
|
defaultRegistryUrl: registryUrl,
|
|
@@ -1438,10 +2803,15 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1438
2803
|
}
|
|
1439
2804
|
if (driveNodeView) {
|
|
1440
2805
|
graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
|
|
2806
|
+
const authorizationService = graphqlManager.getAuthorizationService();
|
|
1441
2807
|
const reactorDriveSubgraph = {
|
|
1442
2808
|
name: "reactor-drive",
|
|
1443
2809
|
path: graphqlManager.getBasePath(),
|
|
1444
|
-
resolvers: createReactorDriveResolvers(
|
|
2810
|
+
resolvers: createReactorDriveResolvers({
|
|
2811
|
+
reactorClient: client,
|
|
2812
|
+
readModel: driveNodeView,
|
|
2813
|
+
hostCanRead: (documentId, address) => authorizationService.canRead(documentId, address)
|
|
2814
|
+
}),
|
|
1445
2815
|
typeDefs: reactorDriveSubgraphTypeDefs,
|
|
1446
2816
|
reactorClient: client,
|
|
1447
2817
|
relationalDb: void 0
|
|
@@ -1492,9 +2862,12 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1492
2862
|
reactor: client,
|
|
1493
2863
|
attachmentService,
|
|
1494
2864
|
attachmentReferenceProjection: api.attachmentReferenceProjection,
|
|
2865
|
+
workflowTriggers: workflows?.triggers,
|
|
2866
|
+
workflowsEnabled,
|
|
2867
|
+
mcpEnabled: options.mcp !== false,
|
|
1495
2868
|
renown,
|
|
1496
2869
|
port: serverPort,
|
|
1497
|
-
shutdown
|
|
2870
|
+
shutdown
|
|
1498
2871
|
};
|
|
1499
2872
|
}
|
|
1500
2873
|
/**
|
|
@@ -1521,16 +2894,26 @@ const startSwitchboard = async (options = {}) => {
|
|
|
1521
2894
|
const featureFlags = await initFeatureFlags();
|
|
1522
2895
|
const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
|
|
1523
2896
|
options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
|
|
1524
|
-
|
|
2897
|
+
options.mcp = await resolveMcpEnabled({
|
|
2898
|
+
featureFlags,
|
|
2899
|
+
option: options.mcp
|
|
2900
|
+
});
|
|
2901
|
+
const configPathForFlags = resolveConfigPath(options.configFile);
|
|
2902
|
+
const workflowsEnabled = await resolveWorkflowsEnabled({
|
|
2903
|
+
featureFlags,
|
|
2904
|
+
override: options.workflows?.enabled,
|
|
2905
|
+
configEnabled: readConfigWorkflowsEnabled(configPathForFlags)
|
|
2906
|
+
});
|
|
2907
|
+
options.workflows = { enabled: workflowsEnabled };
|
|
1525
2908
|
const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
|
|
1526
2909
|
options.identity = {
|
|
1527
2910
|
...options.identity,
|
|
1528
|
-
requireSignatures,
|
|
1529
2911
|
baseUrl: options.identity?.baseUrl ?? renownConfig.url
|
|
1530
2912
|
};
|
|
1531
2913
|
logger.info("Feature flags: @flags", JSON.stringify({
|
|
1532
2914
|
DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
|
|
1533
|
-
|
|
2915
|
+
MCP_ENABLED: options.mcp,
|
|
2916
|
+
PH_WORKFLOWS_ENABLED: workflowsEnabled
|
|
1534
2917
|
}, null, 2));
|
|
1535
2918
|
let renown = null;
|
|
1536
2919
|
try {
|
|
@@ -1543,13 +2926,14 @@ const startSwitchboard = async (options = {}) => {
|
|
|
1543
2926
|
return await initServer(serverPort, options, renown, renownConfig);
|
|
1544
2927
|
} catch (e) {
|
|
1545
2928
|
Sentry.captureException(e);
|
|
1546
|
-
logger.error(
|
|
2929
|
+
if (StoredDocumentsRefusedError.isError(e)) logger.error(e.message);
|
|
2930
|
+
else logger.error("App crashed: @error", e);
|
|
1547
2931
|
throw e;
|
|
1548
2932
|
}
|
|
1549
2933
|
};
|
|
1550
|
-
if (import.meta.main) await startSwitchboard();
|
|
2934
|
+
if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
|
|
1551
2935
|
//#endregion
|
|
1552
|
-
export {
|
|
2936
|
+
export { resolveUnsupportedStoredDocuments as a, StoredDocumentsRefusedError as i, isPortAvailable as n, applySwitchboardReactorDefaults as o, startSwitchboard as r, parseForcePgVersion as s, deriveAttachmentServiceConfig as t };
|
|
1553
2937
|
|
|
1554
|
-
//# sourceMappingURL=server-
|
|
1555
|
-
//# debugId=
|
|
2938
|
+
//# sourceMappingURL=server-sQoWp21G.mjs.map
|
|
2939
|
+
//# debugId=66287c02-682f-57d0-8260-a1005691b2bc
|