@powerhousedao/switchboard 6.2.3-dev.4 → 6.2.3-dev.40
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/reserve-metadata-parity.test.d.ts +2 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.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-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.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 +400 -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-D3CYuEka.mjs} +1574 -88
- package/dist/server-D3CYuEka.mjs.map +1 -0
- package/dist/server.d.mts +120 -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 +22 -17
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +290 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +201 -32
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +60 -4
- package/test/attachments/routes.test.ts +166 -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-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +213 -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 +132 -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 +499 -0
- package/tsconfig.json +9 -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,39 @@
|
|
|
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]="98e9dab8-b145-5eb1-8457-7a9d38dda98e")}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, PrivacyPermissionAdapter, 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, InvalidAttachmentMetadata, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget, parseRef, validateReserveMetadata } 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";
|
|
26
|
+
import { DisclosureService, MIN_DEPLOYMENT_SECRET_BYTES, createModuleErasure, createPrivacySubgraph, registerSubjectDocumentsReadModel } from "@powerhousedao/reactor-privacy";
|
|
24
27
|
import { ReactorGroupV1 } from "@powerhousedao/reactor-group";
|
|
25
28
|
import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
|
|
29
|
+
import { DEFAULT_SIGNATURE_POLICY, isSignaturePolicy } from "@powerhousedao/shared/document-model";
|
|
26
30
|
import { readFile } from "node:fs/promises";
|
|
27
31
|
import os from "node:os";
|
|
28
32
|
import { fileURLToPath } from "node:url";
|
|
29
33
|
import { EnvVarProvider } from "@openfeature/env-var-provider";
|
|
30
34
|
import { OpenFeature } from "@openfeature/server-sdk";
|
|
35
|
+
import { GraphQLError, Kind } from "graphql";
|
|
36
|
+
import { gql } from "graphql-tag";
|
|
31
37
|
import { PGliteDialect } from "kysely-pglite-dialect";
|
|
32
38
|
//#region src/pglite-version.ts
|
|
33
39
|
const SUPPORTED_PG_MAJORS = [16, 17];
|
|
@@ -74,7 +80,15 @@ const ANONYMOUS_ACTOR = {
|
|
|
74
80
|
* enabled, the request must carry a verifiable Bearer token. The handler always
|
|
75
81
|
* receives an actor context: the verified bearer user when auth is enabled, or
|
|
76
82
|
* the anonymous context when it is disabled. With `allowAnonymous`, a missing
|
|
77
|
-
* bearer yields an anonymous actor with `authEnabled: true` instead of a 401
|
|
83
|
+
* bearer yields an anonymous actor with `authEnabled: true` instead of a 401 —
|
|
84
|
+
* unless `requireAuthenticatedCaller` says the deployment refuses anonymous
|
|
85
|
+
* callers outright, which overrides it.
|
|
86
|
+
*
|
|
87
|
+
* With no `authService` there is no bearer to read and every caller is
|
|
88
|
+
* anonymous, so nothing is refused here. That combination cannot carry the
|
|
89
|
+
* floor: `assertRequireAuthenticatedCallerAllowed` refuses to boot with
|
|
90
|
+
* `REQUIRE_AUTHENTICATED_CALLER` set and no identity resolution, and identity
|
|
91
|
+
* resolution is what builds the service.
|
|
78
92
|
*/
|
|
79
93
|
function requireAuth(authService, handler, options) {
|
|
80
94
|
if (!authService) return (req, res, body) => handler(req, res, body, ANONYMOUS_ACTOR);
|
|
@@ -96,11 +110,13 @@ function requireAuth(authService, handler, options) {
|
|
|
96
110
|
res.end(body);
|
|
97
111
|
return;
|
|
98
112
|
}
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
113
|
+
if (!result.user) {
|
|
114
|
+
if (options?.requireAuthenticatedCaller === true || result.auth_enabled && !options?.allowAnonymous) {
|
|
115
|
+
res.statusCode = 401;
|
|
116
|
+
res.setHeader("Content-Type", "application/json");
|
|
117
|
+
res.end(JSON.stringify({ error: "Authentication required" }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
104
120
|
}
|
|
105
121
|
await handler(req, res, body, {
|
|
106
122
|
user: result.user,
|
|
@@ -118,20 +134,24 @@ function requireAuth(authService, handler, options) {
|
|
|
118
134
|
* wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
|
|
119
135
|
*
|
|
120
136
|
* `allowAnonymous` is reserved for routes whose handlers make a per-document
|
|
121
|
-
* authorization decision themselves; identity-only routes must not use it.
|
|
137
|
+
* authorization decision themselves; identity-only routes must not use it. It
|
|
138
|
+
* is also not the last word: a deployment that refuses anonymous callers
|
|
139
|
+
* (`REQUIRE_AUTHENTICATED_CALLER`) refuses them here too, because these routes
|
|
140
|
+
* are mounted on the HTTP adapter and never reach the fetch chain that carries
|
|
141
|
+
* that floor for GraphQL. The flag is read from the API, so every route
|
|
142
|
+
* mounted through here inherits it without each one remembering to.
|
|
122
143
|
*/
|
|
123
144
|
function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
|
|
124
|
-
api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler,
|
|
145
|
+
api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, {
|
|
146
|
+
...options,
|
|
147
|
+
requireAuthenticatedCaller: api.requireAuthenticatedCaller
|
|
148
|
+
}));
|
|
125
149
|
}
|
|
126
150
|
//#endregion
|
|
127
151
|
//#region src/attachments/routes.ts
|
|
128
|
-
const logger$
|
|
152
|
+
const logger$2 = childLogger(["switchboard", "attachments"]);
|
|
129
153
|
const RETRY_AFTER_SECONDS = 5;
|
|
130
154
|
const HASH_PATTERN = /^[a-f0-9]{64}$/i;
|
|
131
|
-
const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
|
132
|
-
const MIME_TYPE_PATTERN = /^[!#$%&'*+\-.^_`|~\w]+\/[!#$%&'*+\-.^_`|~\w]+(?:\s*;\s*[!#$%&'*+\-.^_`|~\w]+=(?:[!#$%&'*+\-.^_`|~\w]+|"(?:[^"\\\r\n]|\\[^\r\n])*"))*$/;
|
|
133
|
-
const MAX_FILENAME_LEN = 255;
|
|
134
|
-
const MAX_MIMETYPE_LEN = 255;
|
|
135
155
|
function sendJson(res, status, body) {
|
|
136
156
|
res.statusCode = status;
|
|
137
157
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -150,7 +170,7 @@ function statusForError(err) {
|
|
|
150
170
|
function sendErrorFromException(res, err) {
|
|
151
171
|
const status = statusForError(err);
|
|
152
172
|
if (status >= 500) {
|
|
153
|
-
logger$
|
|
173
|
+
logger$2.error("Attachment route error: @error", err);
|
|
154
174
|
sendError(res, status, "Internal error");
|
|
155
175
|
return;
|
|
156
176
|
}
|
|
@@ -168,27 +188,34 @@ async function readJsonBody(req, body) {
|
|
|
168
188
|
function parseReserveOptions(input) {
|
|
169
189
|
if (input === null || typeof input !== "object") return null;
|
|
170
190
|
const obj = input;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
191
|
+
const { mimeType, fileName } = obj;
|
|
192
|
+
const extension = obj.extension ?? null;
|
|
193
|
+
try {
|
|
194
|
+
validateReserveMetadata({
|
|
195
|
+
mimeType,
|
|
196
|
+
fileName,
|
|
197
|
+
extension
|
|
198
|
+
});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (err instanceof InvalidAttachmentMetadata) return null;
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
if (typeof mimeType !== "string" || typeof fileName !== "string") return null;
|
|
204
|
+
if (extension !== null && typeof extension !== "string") return null;
|
|
178
205
|
if (obj.clientHash !== void 0) {
|
|
179
206
|
if (typeof obj.clientHash !== "string" || !HASH_PATTERN.test(obj.clientHash)) return null;
|
|
180
207
|
if (typeof obj.sizeBytes !== "number" || !Number.isInteger(obj.sizeBytes) || obj.sizeBytes <= 0 || !Number.isSafeInteger(obj.sizeBytes)) return null;
|
|
181
208
|
return {
|
|
182
|
-
mimeType
|
|
183
|
-
fileName
|
|
209
|
+
mimeType,
|
|
210
|
+
fileName,
|
|
184
211
|
extension,
|
|
185
212
|
clientHash: obj.clientHash.toLowerCase(),
|
|
186
213
|
sizeBytes: obj.sizeBytes
|
|
187
214
|
};
|
|
188
215
|
}
|
|
189
216
|
return {
|
|
190
|
-
mimeType
|
|
191
|
-
fileName
|
|
217
|
+
mimeType,
|
|
218
|
+
fileName,
|
|
192
219
|
extension
|
|
193
220
|
};
|
|
194
221
|
}
|
|
@@ -279,24 +306,27 @@ function makeUploadHandler(attachments) {
|
|
|
279
306
|
}
|
|
280
307
|
};
|
|
281
308
|
}
|
|
282
|
-
function makeDownloadHandler(attachments) {
|
|
283
|
-
return async (req, res) => {
|
|
309
|
+
function makeDownloadHandler(attachments, attachmentAccess, urlSigner) {
|
|
310
|
+
return async (req, res, _body, actor) => {
|
|
284
311
|
const hash = extractParam(req, "hash");
|
|
285
312
|
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
286
313
|
sendError(res, 400, "Invalid attachment hash");
|
|
287
314
|
return;
|
|
288
315
|
}
|
|
316
|
+
const canonicalHash = hash.toLowerCase();
|
|
317
|
+
const grant = await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner);
|
|
318
|
+
if (!admitGrant(res, grant)) return;
|
|
289
319
|
const controller = new AbortController();
|
|
290
320
|
req.once("close", () => controller.abort());
|
|
291
|
-
const canonicalHash = hash.toLowerCase();
|
|
292
321
|
let response;
|
|
293
322
|
try {
|
|
294
|
-
response = await attachments.store.get(canonicalHash, controller.signal);
|
|
323
|
+
response = await attachments.store.get(canonicalHash, controller.signal, grant.documentId);
|
|
295
324
|
} catch (err) {
|
|
296
325
|
if (err instanceof AttachmentPending) {
|
|
297
326
|
res.statusCode = 202;
|
|
327
|
+
res.setHeader("Cache-Control", "private");
|
|
298
328
|
res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
|
|
299
|
-
res.setHeader("Attachment-Pending",
|
|
329
|
+
res.setHeader("Attachment-Pending", buildPendingHeader({
|
|
300
330
|
expiresAtUtc: err.expiresAtUtc,
|
|
301
331
|
...err.metadata ?? {}
|
|
302
332
|
}));
|
|
@@ -308,6 +338,7 @@ function makeDownloadHandler(attachments) {
|
|
|
308
338
|
}
|
|
309
339
|
const { header, body } = response;
|
|
310
340
|
res.statusCode = 200;
|
|
341
|
+
res.setHeader("Cache-Control", "private");
|
|
311
342
|
res.setHeader("Content-Type", header.mimeType);
|
|
312
343
|
res.setHeader("Content-Length", String(header.sizeBytes));
|
|
313
344
|
res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
|
|
@@ -315,8 +346,14 @@ function makeDownloadHandler(attachments) {
|
|
|
315
346
|
Readable.fromWeb(body).pipe(res);
|
|
316
347
|
};
|
|
317
348
|
}
|
|
349
|
+
function toAsciiJson(value) {
|
|
350
|
+
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
351
|
+
}
|
|
352
|
+
function buildPendingHeader(pending) {
|
|
353
|
+
return toAsciiJson(pending);
|
|
354
|
+
}
|
|
318
355
|
function buildMetadataHeader(header) {
|
|
319
|
-
return
|
|
356
|
+
return toAsciiJson({
|
|
320
357
|
mimeType: header.mimeType,
|
|
321
358
|
fileName: header.fileName,
|
|
322
359
|
sizeBytes: header.sizeBytes,
|
|
@@ -325,14 +362,15 @@ function buildMetadataHeader(header) {
|
|
|
325
362
|
lastAccessedAtUtc: header.lastAccessedAtUtc
|
|
326
363
|
});
|
|
327
364
|
}
|
|
328
|
-
function makeStatHandler(attachments) {
|
|
329
|
-
return async (req, res) => {
|
|
365
|
+
function makeStatHandler(attachments, attachmentAccess, urlSigner) {
|
|
366
|
+
return async (req, res, _body, actor) => {
|
|
330
367
|
const hash = extractParam(req, "hash");
|
|
331
368
|
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
332
369
|
sendError(res, 400, "Invalid attachment hash");
|
|
333
370
|
return;
|
|
334
371
|
}
|
|
335
372
|
const canonicalHash = hash.toLowerCase();
|
|
373
|
+
if (!admitGrant(res, await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner))) return;
|
|
336
374
|
let header;
|
|
337
375
|
try {
|
|
338
376
|
header = await attachments.store.stat(canonicalHash);
|
|
@@ -340,10 +378,11 @@ function makeStatHandler(attachments) {
|
|
|
340
378
|
sendErrorFromException(res, err);
|
|
341
379
|
return;
|
|
342
380
|
}
|
|
381
|
+
res.setHeader("Cache-Control", "private");
|
|
343
382
|
if (header.status === "pending") {
|
|
344
383
|
res.statusCode = 202;
|
|
345
384
|
res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
|
|
346
|
-
res.setHeader("Attachment-Pending",
|
|
385
|
+
res.setHeader("Attachment-Pending", buildPendingHeader({
|
|
347
386
|
expiresAtUtc: header.expiresAtUtc,
|
|
348
387
|
mimeType: header.mimeType,
|
|
349
388
|
fileName: header.fileName,
|
|
@@ -395,7 +434,37 @@ function extractParam(req, name) {
|
|
|
395
434
|
}
|
|
396
435
|
const MAX_DOCUMENT_ID_LEN = 512;
|
|
397
436
|
const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
|
|
398
|
-
const MAX_DOWNLOAD_TARGET_TTL_SECONDS =
|
|
437
|
+
const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 604800;
|
|
438
|
+
/**
|
|
439
|
+
* How long a download target may live, by default.
|
|
440
|
+
*
|
|
441
|
+
* The authorization behind a presigned URL is decided once, when it is issued,
|
|
442
|
+
* and the URL keeps working until it expires however the document's policy
|
|
443
|
+
* changes in between. A week of that is a revocation that does not revoke: an
|
|
444
|
+
* account deactivated, a company reassigned or a group left, and the bytes stay
|
|
445
|
+
* fetchable by anyone holding the link. Minutes are enough to open a file;
|
|
446
|
+
* days are only enough to lose control of it.
|
|
447
|
+
*
|
|
448
|
+
* A deployment that needs longer sets ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS,
|
|
449
|
+
* still bounded by the signing ceiling above.
|
|
450
|
+
*/
|
|
451
|
+
const DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS = 300;
|
|
452
|
+
/**
|
|
453
|
+
* The ceiling in force, read per call so a deployment can set it without the
|
|
454
|
+
* value being frozen at import — which is also what lets a test cover it.
|
|
455
|
+
* Anything unparseable falls back to the default rather than to the maximum:
|
|
456
|
+
* a typo must not quietly widen the window it was meant to narrow.
|
|
457
|
+
*/
|
|
458
|
+
function downloadTargetTtlCapSeconds() {
|
|
459
|
+
const configured = process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
|
|
460
|
+
if (configured === void 0) return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
|
|
461
|
+
const parsed = Number(configured);
|
|
462
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
463
|
+
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}`);
|
|
464
|
+
return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
|
|
465
|
+
}
|
|
466
|
+
return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
|
|
467
|
+
}
|
|
399
468
|
/**
|
|
400
469
|
* Returns the single `documentId` query value, or null when it is missing,
|
|
401
470
|
* duplicated, blank, or oversized. Validation happens before authorization so
|
|
@@ -416,29 +485,95 @@ function extractSingleDocumentId(req) {
|
|
|
416
485
|
return value;
|
|
417
486
|
}
|
|
418
487
|
/**
|
|
419
|
-
* Returns the
|
|
420
|
-
*
|
|
421
|
-
*
|
|
488
|
+
* Returns the target TTL in seconds, or "invalid" when the caller's own value
|
|
489
|
+
* is malformed (duplicated, non-integer, or non-positive).
|
|
490
|
+
*
|
|
491
|
+
* The cap applies whether or not the caller asked for a lifetime: omitting
|
|
492
|
+
* `expiresIn` used to fall through to whatever the storage backend was
|
|
493
|
+
* configured with, which would make the ceiling something a client opts out of
|
|
494
|
+
* by saying nothing.
|
|
422
495
|
*/
|
|
423
496
|
function extractExpiresIn(req) {
|
|
424
|
-
|
|
497
|
+
const cap = downloadTargetTtlCapSeconds();
|
|
498
|
+
if (!req.url) return cap;
|
|
425
499
|
let url;
|
|
426
500
|
try {
|
|
427
501
|
url = new URL(req.url, "http://switchboard.invalid");
|
|
428
502
|
} catch {
|
|
429
|
-
return;
|
|
503
|
+
return cap;
|
|
430
504
|
}
|
|
431
505
|
const values = url.searchParams.getAll("expiresIn");
|
|
432
|
-
if (values.length === 0) return
|
|
506
|
+
if (values.length === 0) return cap;
|
|
433
507
|
if (values.length > 1) return "invalid";
|
|
434
508
|
const parsed = Number(values[0]);
|
|
435
509
|
if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
|
|
436
|
-
return Math.min(parsed,
|
|
510
|
+
return Math.min(parsed, cap);
|
|
511
|
+
}
|
|
512
|
+
function singleQueryValue(params, name) {
|
|
513
|
+
const values = params.getAll(name);
|
|
514
|
+
return values.length === 1 ? values[0] : null;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Byte routes serve only under a grant: a signed URL minted by the
|
|
518
|
+
* download-target route, or a documentId the caller may read the attachment
|
|
519
|
+
* through. A signed request is judged by its signature alone.
|
|
520
|
+
*/
|
|
521
|
+
async function resolveByteGrant(req, hash, actor, attachmentAccess, urlSigner) {
|
|
522
|
+
let params;
|
|
523
|
+
try {
|
|
524
|
+
params = new URL(req.url ?? "", "http://switchboard.invalid").searchParams;
|
|
525
|
+
} catch {
|
|
526
|
+
return { kind: "denied" };
|
|
527
|
+
}
|
|
528
|
+
const documentId = extractSingleDocumentId(req);
|
|
529
|
+
if (documentId === null) return { kind: "denied" };
|
|
530
|
+
if (params.has("signature") || params.has("expires")) {
|
|
531
|
+
const signature = singleQueryValue(params, "signature");
|
|
532
|
+
const expires = singleQueryValue(params, "expires");
|
|
533
|
+
if (!urlSigner || signature === null || expires === null) return { kind: "denied" };
|
|
534
|
+
return urlSigner.verify(hash, documentId, expires, signature) ? {
|
|
535
|
+
kind: "granted",
|
|
536
|
+
documentId
|
|
537
|
+
} : { kind: "denied" };
|
|
538
|
+
}
|
|
539
|
+
let decision;
|
|
540
|
+
try {
|
|
541
|
+
decision = await attachmentAccess.canReadAttachment({
|
|
542
|
+
documentId,
|
|
543
|
+
attachmentRef: createRef(hash),
|
|
544
|
+
userAddress: actor?.user?.address,
|
|
545
|
+
appKey: actor?.user?.appKey
|
|
546
|
+
});
|
|
547
|
+
} catch (err) {
|
|
548
|
+
logger$2.error("Attachment access decision failed: @error", err);
|
|
549
|
+
return { kind: "error" };
|
|
550
|
+
}
|
|
551
|
+
if (decision.kind === "projection-unavailable") return { kind: "unavailable" };
|
|
552
|
+
if (decision.kind === "denied") return { kind: "denied" };
|
|
553
|
+
return {
|
|
554
|
+
kind: "granted",
|
|
555
|
+
documentId: decision.documentId
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
/** Answers a refused grant; true when the request may proceed. */
|
|
559
|
+
function admitGrant(res, grant) {
|
|
560
|
+
switch (grant.kind) {
|
|
561
|
+
case "granted": return true;
|
|
562
|
+
case "denied":
|
|
563
|
+
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
564
|
+
return false;
|
|
565
|
+
case "unavailable":
|
|
566
|
+
sendError(res, 503, "Attachment downloads are temporarily unavailable");
|
|
567
|
+
return false;
|
|
568
|
+
case "error":
|
|
569
|
+
sendError(res, 500, "Internal error");
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
437
572
|
}
|
|
438
573
|
/**
|
|
439
574
|
* Base URL of this Switchboard as seen by the caller, used to build
|
|
440
|
-
* filesystem `switchboard` download targets that point back at the
|
|
441
|
-
*
|
|
575
|
+
* filesystem `switchboard` download targets that point back at the signed
|
|
576
|
+
* byte route.
|
|
442
577
|
*/
|
|
443
578
|
function requestBaseUrl(req) {
|
|
444
579
|
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
@@ -448,7 +583,7 @@ function requestBaseUrl(req) {
|
|
|
448
583
|
if (!host) return null;
|
|
449
584
|
return `${proto}://${host}`;
|
|
450
585
|
}
|
|
451
|
-
function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
586
|
+
function makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner) {
|
|
452
587
|
return async (req, res, _body, actor) => {
|
|
453
588
|
res.setHeader("Cache-Control", "no-store");
|
|
454
589
|
const hash = extractParam(req, "hash");
|
|
@@ -472,10 +607,11 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
472
607
|
decision = await attachmentAccess.canReadAttachment({
|
|
473
608
|
documentId,
|
|
474
609
|
attachmentRef: createRef(canonicalHash),
|
|
475
|
-
userAddress: actor?.user?.address
|
|
610
|
+
userAddress: actor?.user?.address,
|
|
611
|
+
appKey: actor?.user?.appKey
|
|
476
612
|
});
|
|
477
613
|
} catch (err) {
|
|
478
|
-
logger$
|
|
614
|
+
logger$2.error("Attachment access decision failed: @error", err);
|
|
479
615
|
sendError(res, 500, "Internal error");
|
|
480
616
|
return;
|
|
481
617
|
}
|
|
@@ -495,7 +631,7 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
495
631
|
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
496
632
|
return;
|
|
497
633
|
}
|
|
498
|
-
logger$
|
|
634
|
+
logger$2.error("Attachment metadata lookup failed: @error", err);
|
|
499
635
|
sendError(res, 500, "Internal error");
|
|
500
636
|
return;
|
|
501
637
|
}
|
|
@@ -511,17 +647,23 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
511
647
|
return;
|
|
512
648
|
}
|
|
513
649
|
else {
|
|
650
|
+
if (!urlSigner) {
|
|
651
|
+
sendError(res, 503, "Attachment download signing is not configured");
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
514
654
|
const base = requestBaseUrl(req);
|
|
515
655
|
if (!base) {
|
|
516
656
|
sendError(res, 500, "Internal error");
|
|
517
657
|
return;
|
|
518
658
|
}
|
|
659
|
+
const signed = urlSigner.sign(canonicalHash, decision.documentId, expiresIn);
|
|
519
660
|
try {
|
|
520
661
|
target = parseAttachmentDownloadTarget({
|
|
521
662
|
kind: "switchboard",
|
|
522
663
|
method: "GET",
|
|
523
|
-
url: `${base}/attachments/${canonicalHash}`,
|
|
524
|
-
headers: {}
|
|
664
|
+
url: `${base}/attachments/${canonicalHash}?${signed.query}`,
|
|
665
|
+
headers: {},
|
|
666
|
+
expiresAtUtc: signed.expiresAtUtc
|
|
525
667
|
});
|
|
526
668
|
} catch {
|
|
527
669
|
sendError(res, 500, "Internal error");
|
|
@@ -532,16 +674,82 @@ function makeDownloadTargetHandler(attachments, attachmentAccess) {
|
|
|
532
674
|
};
|
|
533
675
|
}
|
|
534
676
|
//#endregion
|
|
677
|
+
//#region src/attachments/url-signer.ts
|
|
678
|
+
const ATTACHMENT_URL_SIGNING_SECRET_ENV = "PH_ATTACHMENT_URL_SIGNING_SECRET";
|
|
679
|
+
const MIN_SECRET_LENGTH = 32;
|
|
680
|
+
const EXPIRES_PATTERN = /^[1-9]\d{0,15}$/;
|
|
681
|
+
const SIGNATURE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
682
|
+
var AttachmentUrlSigner = class {
|
|
683
|
+
secret;
|
|
684
|
+
now;
|
|
685
|
+
constructor(secret, now = Date.now) {
|
|
686
|
+
this.secret = secret;
|
|
687
|
+
this.now = now;
|
|
688
|
+
}
|
|
689
|
+
sign(hash, documentId, ttlSeconds) {
|
|
690
|
+
const expires = Math.floor(this.now() / 1e3) + ttlSeconds;
|
|
691
|
+
return {
|
|
692
|
+
query: new URLSearchParams({
|
|
693
|
+
documentId,
|
|
694
|
+
expires: String(expires),
|
|
695
|
+
signature: this.mac(hash, documentId, String(expires))
|
|
696
|
+
}).toString(),
|
|
697
|
+
expiresAtUtc: (/* @__PURE__ */ new Date(expires * 1e3)).toISOString()
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
verify(hash, documentId, expires, signature) {
|
|
701
|
+
if (!EXPIRES_PATTERN.test(expires) || !SIGNATURE_PATTERN.test(signature)) return false;
|
|
702
|
+
if (Number(expires) * 1e3 <= this.now()) return false;
|
|
703
|
+
const expected = Buffer.from(this.mac(hash, documentId, expires));
|
|
704
|
+
const presented = Buffer.from(signature);
|
|
705
|
+
return expected.length === presented.length && timingSafeEqual(expected, presented);
|
|
706
|
+
}
|
|
707
|
+
mac(hash, documentId, expires) {
|
|
708
|
+
return createHmac("sha256", this.secret).update(JSON.stringify([
|
|
709
|
+
"attachment-download/v1",
|
|
710
|
+
hash,
|
|
711
|
+
documentId,
|
|
712
|
+
expires
|
|
713
|
+
])).digest("base64url");
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
function resolveAttachmentUrlSigning(env = process.env) {
|
|
717
|
+
const secret = env[ATTACHMENT_URL_SIGNING_SECRET_ENV];
|
|
718
|
+
if (secret !== void 0) {
|
|
719
|
+
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`);
|
|
720
|
+
return {
|
|
721
|
+
status: "configured",
|
|
722
|
+
signer: new AttachmentUrlSigner(secret)
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
if (env.NODE_ENV === "production") return { status: "unconfigured" };
|
|
726
|
+
return {
|
|
727
|
+
status: "ephemeral",
|
|
728
|
+
signer: new AttachmentUrlSigner(randomBytes(32))
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
//#endregion
|
|
535
732
|
//#region src/attachments/index.ts
|
|
536
|
-
|
|
537
|
-
|
|
733
|
+
const logger$1 = childLogger(["switchboard", "attachments"]);
|
|
734
|
+
function registerAttachmentRoutes(api, options = {}) {
|
|
735
|
+
const { attachments, attachmentAccess } = api;
|
|
736
|
+
const urlSigner = options.urlSigner === void 0 ? signerFromEnvironment(attachments.backend?.kind ?? "filesystem") : options.urlSigner;
|
|
538
737
|
mountAuthenticatedNodeRoute(api, "POST", "/attachments/reservations", makeReserveHandler(attachments));
|
|
539
738
|
mountAuthenticatedNodeRoute(api, "GET", "/attachments/reservations/:reservationId", makeGetReservationHandler(attachments));
|
|
540
739
|
mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
|
|
541
740
|
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));
|
|
741
|
+
mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
|
|
742
|
+
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
|
|
743
|
+
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
|
|
744
|
+
}
|
|
745
|
+
function signerFromEnvironment(backend) {
|
|
746
|
+
const signing = resolveAttachmentUrlSigning();
|
|
747
|
+
if (signing.status === "unconfigured") {
|
|
748
|
+
if (backend === "filesystem") logger$1.error(`${ATTACHMENT_URL_SIGNING_SECRET_ENV} is not set: filesystem attachment download targets are refused until it is`);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
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`);
|
|
752
|
+
return signing.signer;
|
|
545
753
|
}
|
|
546
754
|
//#endregion
|
|
547
755
|
//#region src/attachment-reference-read-model.mts
|
|
@@ -584,6 +792,127 @@ async function registerAttachmentReferenceReadModelOnModule(clientModule, attach
|
|
|
584
792
|
return { status: "available" };
|
|
585
793
|
}
|
|
586
794
|
//#endregion
|
|
795
|
+
//#region src/fatal-shutdown.mts
|
|
796
|
+
const FORCED_EXIT_MS = 15e3;
|
|
797
|
+
const fatalHandlers = /* @__PURE__ */ new WeakMap();
|
|
798
|
+
/**
|
|
799
|
+
* Sends an uncaught exception or unhandled rejection through the SIGTERM
|
|
800
|
+
* shutdown that `withSignalHandlers()` installs, so the reactor and read-model
|
|
801
|
+
* PGlite stores write their snapshots before the process exits with code 1.
|
|
802
|
+
* Node's default is to exit at once, which drops every write AtomicNodeFs has
|
|
803
|
+
* not flushed yet. A shutdown that hangs is cut off after FORCED_EXIT_MS.
|
|
804
|
+
*
|
|
805
|
+
* A rejection is fatal only when this is its sole listener: Node exits on an
|
|
806
|
+
* unhandled rejection only when nothing listens for it, and Sentry's
|
|
807
|
+
* listener deliberately keeps the process running.
|
|
808
|
+
*/
|
|
809
|
+
function installFatalErrorShutdown(logger, proc = process) {
|
|
810
|
+
if (fatalHandlers.has(proc)) return;
|
|
811
|
+
const realExit = proc.exit.bind(proc);
|
|
812
|
+
let shuttingDown = false;
|
|
813
|
+
const onFatal = (kind, err) => {
|
|
814
|
+
logger.error(`${kind}: @error`, err);
|
|
815
|
+
if (shuttingDown) return;
|
|
816
|
+
shuttingDown = true;
|
|
817
|
+
proc.exitCode = 1;
|
|
818
|
+
setTimeout(() => {
|
|
819
|
+
logger.error("Shutdown after fatal error timed out; exiting");
|
|
820
|
+
realExit(1);
|
|
821
|
+
}, FORCED_EXIT_MS).unref();
|
|
822
|
+
proc.kill(proc.pid, "SIGTERM");
|
|
823
|
+
};
|
|
824
|
+
fatalHandlers.set(proc, onFatal);
|
|
825
|
+
proc.on("uncaughtException", (err) => onFatal("Uncaught exception", err));
|
|
826
|
+
proc.on("unhandledRejection", (reason) => {
|
|
827
|
+
if (proc.listenerCount("unhandledRejection") > 1) return;
|
|
828
|
+
onFatal("Unhandled rejection", reason);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Routes a fatal condition the caller detected through the same shutdown as an
|
|
833
|
+
* uncaught error. Returns false, and does nothing, when
|
|
834
|
+
* `installFatalErrorShutdown` has not run for `proc`.
|
|
835
|
+
*/
|
|
836
|
+
function triggerFatalShutdown(kind, err, proc = process) {
|
|
837
|
+
const onFatal = fatalHandlers.get(proc);
|
|
838
|
+
if (!onFatal) return false;
|
|
839
|
+
onFatal(kind, err);
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/privacy.mts
|
|
844
|
+
const DAY_MS = 864e5;
|
|
845
|
+
/** Enabling privacy on a switchboard that cannot run it safely. */
|
|
846
|
+
var PrivacyBootError = class extends Error {
|
|
847
|
+
constructor(message) {
|
|
848
|
+
super(message);
|
|
849
|
+
this.name = "PrivacyBootError";
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
function positive(env, name, unitMs) {
|
|
853
|
+
const raw = env[name];
|
|
854
|
+
if (raw === void 0 || raw.trim() === "") return void 0;
|
|
855
|
+
const value = Number(raw);
|
|
856
|
+
if (!Number.isFinite(value) || value <= 0) throw new PrivacyBootError(`${name} must be a positive number, got "${raw}"`);
|
|
857
|
+
return value * unitMs;
|
|
858
|
+
}
|
|
859
|
+
function resolvePrivacy(options, env) {
|
|
860
|
+
if (!(options?.enabled ?? env.PH_PRIVACY_ENABLED === "true")) return { enabled: false };
|
|
861
|
+
const deploymentSecret = options?.deploymentSecret ?? env.PH_PRIVACY_DEPLOYMENT_SECRET;
|
|
862
|
+
if (!deploymentSecret || Buffer.byteLength(deploymentSecret, "utf8") < MIN_DEPLOYMENT_SECRET_BYTES) throw new PrivacyBootError(`PH_PRIVACY_ENABLED needs PH_PRIVACY_DEPLOYMENT_SECRET of at least ${MIN_DEPLOYMENT_SECRET_BYTES} bytes`);
|
|
863
|
+
return {
|
|
864
|
+
enabled: true,
|
|
865
|
+
deploymentSecret,
|
|
866
|
+
intervalMs: positive(env, "PH_PRIVACY_INTERVAL_MS", 1),
|
|
867
|
+
deadlineMs: positive(env, "PH_PRIVACY_DEADLINE_DAYS", DAY_MS),
|
|
868
|
+
markerGraceMs: positive(env, "PH_PRIVACY_MARKER_GRACE_DAYS", DAY_MS),
|
|
869
|
+
purgeTimeoutMs: positive(env, "PH_PRIVACY_PURGE_TIMEOUT_MINUTES", 6e4)
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
/** Subject index, erasure scheduler and admin subgraph on a built reactor. */
|
|
873
|
+
async function startPrivacy(deps) {
|
|
874
|
+
const { config, reactorModule, authorizationService } = deps;
|
|
875
|
+
if (authorizationService.config.policy === AuthorizationPolicy.OPEN) throw new PrivacyBootError("PH_PRIVACY_ENABLED is refused under the OPEN authorization policy: every caller, anonymous included, would administer erasure. Enable authentication (AUTH_ENABLED=true with ADMINS) first.");
|
|
876
|
+
if (!reactorModule) throw new PrivacyBootError("PH_PRIVACY_ENABLED needs an in-process reactor module");
|
|
877
|
+
if (!deps.signer?.app?.key) throw new PrivacyBootError("PH_PRIVACY_ENABLED needs a reactor signer (a Renown identity): every receiver refuses an unsigned purge marker");
|
|
878
|
+
const permissions = deps.documentPermissionService ? new PrivacyPermissionAdapter(deps.documentPermissionService) : void 0;
|
|
879
|
+
const { service, scheduler } = createModuleErasure(reactorModule, {
|
|
880
|
+
deploymentSecret: config.deploymentSecret,
|
|
881
|
+
signer: deps.signer,
|
|
882
|
+
permissions,
|
|
883
|
+
intervalMs: config.intervalMs,
|
|
884
|
+
deadlineMs: config.deadlineMs,
|
|
885
|
+
markerGraceMs: config.markerGraceMs,
|
|
886
|
+
purgeTimeoutMs: config.purgeTimeoutMs,
|
|
887
|
+
logger: deps.logger
|
|
888
|
+
});
|
|
889
|
+
const disclosure = new DisclosureService(reactorModule.database, config.deploymentSecret, permissions);
|
|
890
|
+
let subgraph;
|
|
891
|
+
try {
|
|
892
|
+
subgraph = createPrivacySubgraph({
|
|
893
|
+
authorizationService,
|
|
894
|
+
erasure: service,
|
|
895
|
+
disclosure
|
|
896
|
+
});
|
|
897
|
+
} catch (error) {
|
|
898
|
+
throw new PrivacyBootError(error instanceof Error ? error.message : String(error));
|
|
899
|
+
}
|
|
900
|
+
await registerSubjectDocumentsReadModel(reactorModule, { deploymentSecret: config.deploymentSecret });
|
|
901
|
+
await deps.graphqlManager.registerSubgraphInstance({
|
|
902
|
+
...subgraph,
|
|
903
|
+
path: deps.graphqlManager.getBasePath(),
|
|
904
|
+
reactorClient: deps.reactorClient,
|
|
905
|
+
relationalDb: void 0
|
|
906
|
+
}, "graphql", false);
|
|
907
|
+
scheduler.start();
|
|
908
|
+
deps.logger.info("Privacy add-on started: subject index, erasure scheduler");
|
|
909
|
+
return {
|
|
910
|
+
erasure: service,
|
|
911
|
+
scheduler,
|
|
912
|
+
stop: () => scheduler.stop()
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
//#endregion
|
|
587
916
|
//#region src/builder-defaults.mts
|
|
588
917
|
/**
|
|
589
918
|
* Apply switchboard's standard configuration to a reactor + client builder
|
|
@@ -617,7 +946,13 @@ function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options
|
|
|
617
946
|
});
|
|
618
947
|
if (options.documentModelLoader) reactorBuilder.withDocumentModelLoader(options.documentModelLoader);
|
|
619
948
|
if (options.logger) reactorBuilder.withLogger(options.logger);
|
|
620
|
-
if (options.signer)
|
|
949
|
+
if (options.signer) {
|
|
950
|
+
clientBuilder.withSigner(options.signer);
|
|
951
|
+
reactorBuilder.withSigner(options.signer.signer, options.signer.workerSigner);
|
|
952
|
+
}
|
|
953
|
+
if (options.createSignaturePolicy) clientBuilder.withCreateSignaturePolicy(options.createSignaturePolicy);
|
|
954
|
+
const trust = options.trustPolicy ?? options.signer;
|
|
955
|
+
if (trust?.trustPolicy) reactorBuilder.withTrustPolicy(trust.trustPolicy, trust.workerTrustPolicy);
|
|
621
956
|
}
|
|
622
957
|
//#endregion
|
|
623
958
|
//#region src/worker-pool.mts
|
|
@@ -865,6 +1200,900 @@ async function initFeatureFlags() {
|
|
|
865
1200
|
return OpenFeature.getClient();
|
|
866
1201
|
}
|
|
867
1202
|
//#endregion
|
|
1203
|
+
//#region src/mcp-flag.mts
|
|
1204
|
+
/** The env var and OpenFeature flag key that can keep the MCP server unmounted. */
|
|
1205
|
+
const MCP_ENABLED = "MCP_ENABLED";
|
|
1206
|
+
/** Whether to mount the MCP server at `/mcp`. On unless the host passes
|
|
1207
|
+
* `mcp: false` or MCP_ENABLED is "false": the env var can turn a host's MCP
|
|
1208
|
+
* off, never on, so a deployment that serves no MCP client can close the
|
|
1209
|
+
* endpoint without the bin growing a flag of its own. */
|
|
1210
|
+
async function resolveMcpEnabled({ featureFlags, option }) {
|
|
1211
|
+
if (option === false) return false;
|
|
1212
|
+
return featureFlags.getBooleanValue(MCP_ENABLED, true);
|
|
1213
|
+
}
|
|
1214
|
+
//#endregion
|
|
1215
|
+
//#region src/workflow/oauth-callback.ts
|
|
1216
|
+
const OAUTH_CALLBACK_PATH = "oauth/callback";
|
|
1217
|
+
function escapeHtml(text) {
|
|
1218
|
+
return text.replace(/[&<>"']/g, (char) => ({
|
|
1219
|
+
"&": "&",
|
|
1220
|
+
"<": "<",
|
|
1221
|
+
">": ">",
|
|
1222
|
+
"\"": """,
|
|
1223
|
+
"'": "'"
|
|
1224
|
+
})[char]);
|
|
1225
|
+
}
|
|
1226
|
+
function resultPage(ok, detail) {
|
|
1227
|
+
const title = ok ? "Connected" : "Sign-in failed";
|
|
1228
|
+
return `<!doctype html>
|
|
1229
|
+
<html><head><meta charset="utf-8"><title>${title}</title>
|
|
1230
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1231
|
+
<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>
|
|
1232
|
+
</head><body><main><h1>${title}</h1><p>${ok ? "You can close this window." : escapeHtml(detail ?? "The sign-in did not finish.")}</p></main>
|
|
1233
|
+
${ok ? "<script>setTimeout(function(){window.close()},600)<\/script>" : ""}
|
|
1234
|
+
</body></html>`;
|
|
1235
|
+
}
|
|
1236
|
+
function registerOAuthCallback(http, runtime) {
|
|
1237
|
+
return http.get(OAUTH_CALLBACK_PATH, { auth: "public" }, async (request) => {
|
|
1238
|
+
const params = new URL(request.url).searchParams;
|
|
1239
|
+
const state = params.get("state");
|
|
1240
|
+
const result = state ? await runtime.completeOAuth({
|
|
1241
|
+
state,
|
|
1242
|
+
code: params.get("code") ?? void 0,
|
|
1243
|
+
error: params.get("error") ?? void 0,
|
|
1244
|
+
errorDescription: params.get("error_description") ?? void 0
|
|
1245
|
+
}) : {
|
|
1246
|
+
ok: false,
|
|
1247
|
+
detail: "The provider sent no state",
|
|
1248
|
+
returnUrl: null
|
|
1249
|
+
};
|
|
1250
|
+
if (result.returnUrl && state) {
|
|
1251
|
+
const back = new URL(result.returnUrl);
|
|
1252
|
+
back.searchParams.set("ph_oauth", state);
|
|
1253
|
+
return new Response(null, {
|
|
1254
|
+
status: 303,
|
|
1255
|
+
headers: {
|
|
1256
|
+
location: back.href,
|
|
1257
|
+
"cache-control": "no-store"
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
return new Response(resultPage(result.ok, result.detail), {
|
|
1262
|
+
status: result.ok ? 200 : 400,
|
|
1263
|
+
headers: {
|
|
1264
|
+
"content-type": "text/html; charset=utf-8",
|
|
1265
|
+
"cache-control": "no-store",
|
|
1266
|
+
"referrer-policy": "no-referrer"
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
function callbackUrlOf(http) {
|
|
1272
|
+
return `${http.baseUrl.replace(/\/+$/, "")}/${OAUTH_CALLBACK_PATH}`;
|
|
1273
|
+
}
|
|
1274
|
+
//#endregion
|
|
1275
|
+
//#region src/workflow/resolvers.ts
|
|
1276
|
+
function blockRef(input) {
|
|
1277
|
+
if (input.kind !== "action" && input.kind !== "trigger") throw new GraphQLError(`A block's kind is "action" or "trigger", got "${input.kind}"`);
|
|
1278
|
+
return {
|
|
1279
|
+
pieceName: input.pieceName,
|
|
1280
|
+
pieceVersion: input.pieceVersion,
|
|
1281
|
+
name: input.name,
|
|
1282
|
+
kind: input.kind
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
async function cursorAware(call) {
|
|
1286
|
+
try {
|
|
1287
|
+
return await call();
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
if (error instanceof Error && error.name === "InvalidRunCursorError") throw new GraphQLError(error.message, { extensions: { code: "BAD_USER_INPUT" } });
|
|
1290
|
+
throw error;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
function selectsStepData(selections, path) {
|
|
1294
|
+
let level = selections;
|
|
1295
|
+
for (const name of [...path, "steps"]) {
|
|
1296
|
+
const next = [];
|
|
1297
|
+
for (const set of level) for (const selection of set.selections) {
|
|
1298
|
+
if (selection.kind !== Kind.FIELD) return true;
|
|
1299
|
+
if (selection.name.value === name && selection.selectionSet) next.push(selection.selectionSet);
|
|
1300
|
+
}
|
|
1301
|
+
if (next.length === 0) return false;
|
|
1302
|
+
level = next;
|
|
1303
|
+
}
|
|
1304
|
+
return level.some((set) => set.selections.some((selection) => selection.kind !== Kind.FIELD || selection.name.value === "input" || selection.name.value === "output"));
|
|
1305
|
+
}
|
|
1306
|
+
function readsStepData(info, path) {
|
|
1307
|
+
if (!info) return true;
|
|
1308
|
+
return selectsStepData(info.fieldNodes.flatMap((node) => node.selectionSet ? [node.selectionSet] : []), path);
|
|
1309
|
+
}
|
|
1310
|
+
function requireAdmin(authorizationService, ctx) {
|
|
1311
|
+
if (!authorizationService.isSupremeAdmin(ctx.user?.address)) throw new GraphQLError("Admin access required");
|
|
1312
|
+
}
|
|
1313
|
+
async function syncAware(call) {
|
|
1314
|
+
try {
|
|
1315
|
+
return await call();
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
if (error instanceof Error && error.name === "WorkflowSyncingError") throw new GraphQLError(error.message, { extensions: {
|
|
1318
|
+
code: "WORKFLOW_SYNCING",
|
|
1319
|
+
retryable: true
|
|
1320
|
+
} });
|
|
1321
|
+
throw error;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
function parseJson(value) {
|
|
1325
|
+
if (value === null) return null;
|
|
1326
|
+
try {
|
|
1327
|
+
return JSON.parse(value);
|
|
1328
|
+
} catch {
|
|
1329
|
+
return value;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
function toStepRecord(row) {
|
|
1333
|
+
return {
|
|
1334
|
+
stepId: row.step_id,
|
|
1335
|
+
stepKey: row.step_key,
|
|
1336
|
+
pieceName: row.piece_name,
|
|
1337
|
+
blockName: row.block_name,
|
|
1338
|
+
status: row.status,
|
|
1339
|
+
input: parseJson(row.input),
|
|
1340
|
+
output: parseJson(row.output),
|
|
1341
|
+
port: row.port,
|
|
1342
|
+
error: row.error,
|
|
1343
|
+
startedAt: row.started_at,
|
|
1344
|
+
endedAt: row.ended_at,
|
|
1345
|
+
pieceVersion: row.piece_version,
|
|
1346
|
+
pieceSource: row.piece_source,
|
|
1347
|
+
versionMatch: row.version_match,
|
|
1348
|
+
versionNote: row.version_note
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
function toRunRecord(row, steps) {
|
|
1352
|
+
return {
|
|
1353
|
+
id: row.id,
|
|
1354
|
+
workflowId: row.workflow_id,
|
|
1355
|
+
workflowName: row.workflow_name,
|
|
1356
|
+
workflowVersion: row.workflow_version,
|
|
1357
|
+
triggerKind: row.trigger_kind,
|
|
1358
|
+
triggerPayload: parseJson(row.trigger_payload),
|
|
1359
|
+
status: row.status,
|
|
1360
|
+
error: row.error,
|
|
1361
|
+
startedAt: row.started_at,
|
|
1362
|
+
endedAt: row.ended_at,
|
|
1363
|
+
rerunOf: row.rerun_of,
|
|
1364
|
+
warnings: row.warnings,
|
|
1365
|
+
warningNotes: parseJson(row.warning_notes) ?? [],
|
|
1366
|
+
steps: steps.map(toStepRecord)
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
function isAbsolute(url) {
|
|
1370
|
+
return /^https?:\/\//i.test(url);
|
|
1371
|
+
}
|
|
1372
|
+
function redirectUriFor(routing, requested) {
|
|
1373
|
+
if (!routing) throw new GraphQLError("This host serves no OAuth2 callback");
|
|
1374
|
+
if (isAbsolute(routing.callbackUrl)) return routing.callbackUrl;
|
|
1375
|
+
if (!requested) throw new GraphQLError("This host does not know its public URL; pass redirectUri");
|
|
1376
|
+
let url;
|
|
1377
|
+
try {
|
|
1378
|
+
url = new URL(requested);
|
|
1379
|
+
} catch {
|
|
1380
|
+
throw new GraphQLError(`"${requested}" is not a URL`);
|
|
1381
|
+
}
|
|
1382
|
+
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}`);
|
|
1383
|
+
return url.href;
|
|
1384
|
+
}
|
|
1385
|
+
function returnUrlFor(requested, ctx) {
|
|
1386
|
+
if (!requested) return void 0;
|
|
1387
|
+
const origin = ctx.headers.origin;
|
|
1388
|
+
let url;
|
|
1389
|
+
try {
|
|
1390
|
+
url = new URL(requested);
|
|
1391
|
+
} catch {
|
|
1392
|
+
url = void 0;
|
|
1393
|
+
}
|
|
1394
|
+
if (!url || !origin || url.origin !== origin) throw new GraphQLError("returnUrl must be on the requesting page's origin");
|
|
1395
|
+
return url.href;
|
|
1396
|
+
}
|
|
1397
|
+
const getResolvers = (runtime, authorizationService, oauth) => {
|
|
1398
|
+
return {
|
|
1399
|
+
Query: { workflowRuntime: () => ({}) },
|
|
1400
|
+
WorkflowRuntimeQueries: {
|
|
1401
|
+
health: () => "ok",
|
|
1402
|
+
blockDescriptor: (_parent, args) => runtime.blockDescriptor(blockRef(args.block)),
|
|
1403
|
+
blockOptions: (_parent, args, ctx) => runtime.blockOptions(blockRef(args.block), args.propName, args.input, args.connectionId ?? void 0, ctx, args.searchValue ?? void 0),
|
|
1404
|
+
pieceCatalog: () => runtime.pieceCatalog(),
|
|
1405
|
+
pieceActions: (_parent, args) => runtime.pieceActions(args.packageName, args.version ?? void 0),
|
|
1406
|
+
pieceTriggers: (_parent, args) => runtime.pieceTriggers(args.packageName, args.version ?? void 0),
|
|
1407
|
+
blockOutputTree: (_parent, args) => runtime.blockOutputTree(blockRef(args.block), args.config),
|
|
1408
|
+
stepOutputTree: (_parent, args, ctx) => runtime.stepOutputTree(args.workflowId, args.stepId, ctx),
|
|
1409
|
+
blockResolutions: (_parent, args, ctx) => runtime.blockResolutions(args.workflowId, ctx),
|
|
1410
|
+
pieceDetail: (_parent, args) => runtime.pieceDetail(args.packageName, args.version ?? void 0),
|
|
1411
|
+
searchBlocks: (_parent, args) => runtime.searchBlocks(args.query, args.limit ?? void 0),
|
|
1412
|
+
connections: (_parent, _args, ctx) => runtime.connections(ctx),
|
|
1413
|
+
webhookEndpoint: (_parent, args, ctx) => syncAware(() => runtime.webhookEndpoint(args.workflowId, ctx, { driveId: args.driveId ?? void 0 })),
|
|
1414
|
+
secret: async (_parent, args) => {
|
|
1415
|
+
try {
|
|
1416
|
+
return await (await runtime.secrets()).stat(args.ref);
|
|
1417
|
+
} catch {
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
},
|
|
1421
|
+
secrets: async () => (await runtime.secrets()).list(),
|
|
1422
|
+
oauthRedirectUri: () => oauth?.callbackUrl ?? null,
|
|
1423
|
+
oauthAttempt: (_parent, args, ctx) => runtime.oauthAttempt(args.state, ctx),
|
|
1424
|
+
triggerStates: async (_parent, _args, ctx) => (await runtime.triggerStates(ctx)).map((row) => ({
|
|
1425
|
+
workflowId: row.workflow_id,
|
|
1426
|
+
pieceName: row.piece_name,
|
|
1427
|
+
triggerName: row.trigger_name,
|
|
1428
|
+
status: row.status,
|
|
1429
|
+
intervalMs: row.interval_ms,
|
|
1430
|
+
nextPollAt: row.next_poll_at,
|
|
1431
|
+
lastPollAt: row.last_poll_at,
|
|
1432
|
+
lastError: row.last_error,
|
|
1433
|
+
consecutiveFailures: row.consecutive_failures,
|
|
1434
|
+
nextRenewAt: row.next_renew_at,
|
|
1435
|
+
renewError: row.renew_error,
|
|
1436
|
+
renewFailures: row.renew_failures,
|
|
1437
|
+
pieceVersion: row.piece_version,
|
|
1438
|
+
pieceSource: row.piece_source,
|
|
1439
|
+
versionMatch: row.version_match,
|
|
1440
|
+
versionNote: row.version_note
|
|
1441
|
+
})),
|
|
1442
|
+
runs: async (_parent, args, ctx, info) => (await runtime.runs({
|
|
1443
|
+
...args,
|
|
1444
|
+
withStepData: readsStepData(info, [])
|
|
1445
|
+
}, ctx)).map((record) => toRunRecord(record.row, record.steps)),
|
|
1446
|
+
runsPage: async (_parent, args, ctx, info) => {
|
|
1447
|
+
const { paging, ...scope } = args;
|
|
1448
|
+
const page = await cursorAware(() => runtime.runsPage({
|
|
1449
|
+
...scope,
|
|
1450
|
+
limit: paging?.limit ?? void 0,
|
|
1451
|
+
cursor: paging?.cursor ?? null,
|
|
1452
|
+
withStepData: readsStepData(info, ["items"])
|
|
1453
|
+
}, ctx));
|
|
1454
|
+
return {
|
|
1455
|
+
items: page.records.map((record) => toRunRecord(record.row, record.steps)),
|
|
1456
|
+
hasNextPage: page.hasNextPage,
|
|
1457
|
+
hasPreviousPage: Boolean(paging?.cursor),
|
|
1458
|
+
cursor: page.cursor
|
|
1459
|
+
};
|
|
1460
|
+
},
|
|
1461
|
+
run: async (_parent, args, ctx) => {
|
|
1462
|
+
const record = await runtime.run(args.id, ctx);
|
|
1463
|
+
return record ? toRunRecord(record.row, record.steps) : null;
|
|
1464
|
+
}
|
|
1465
|
+
},
|
|
1466
|
+
Mutation: { workflowRuntime: () => ({}) },
|
|
1467
|
+
WorkflowRuntimeMutations: {
|
|
1468
|
+
fire: (_parent, args, ctx) => runtime.fire(args.workflowId, args.payload, "manual", void 0, ctx),
|
|
1469
|
+
testTrigger: (_parent, args, ctx) => syncAware(() => runtime.testTrigger(args.workflowId, ctx, {
|
|
1470
|
+
...args.payload !== void 0 ? { payload: args.payload } : {},
|
|
1471
|
+
...typeof args.timeoutSeconds === "number" ? { timeoutMs: args.timeoutSeconds * 1e3 } : {},
|
|
1472
|
+
driveId: args.driveId ?? void 0
|
|
1473
|
+
})),
|
|
1474
|
+
cancelTriggerTest: (_parent, args, ctx) => runtime.cancelTriggerTestFor(args.workflowId, ctx),
|
|
1475
|
+
testStep: (_parent, args, ctx) => syncAware(() => runtime.testStep(args.workflowId, args.stepId, ctx, { driveId: args.driveId ?? void 0 })),
|
|
1476
|
+
rerun: (_parent, args, ctx) => runtime.rerun(args.runId, ctx),
|
|
1477
|
+
createSecret: async (_parent, args, ctx) => {
|
|
1478
|
+
requireAdmin(authorizationService, ctx);
|
|
1479
|
+
return (await runtime.secrets()).create({
|
|
1480
|
+
value: args.value,
|
|
1481
|
+
label: args.label ?? void 0
|
|
1482
|
+
});
|
|
1483
|
+
},
|
|
1484
|
+
rotateSecret: async (_parent, args, ctx) => {
|
|
1485
|
+
requireAdmin(authorizationService, ctx);
|
|
1486
|
+
return (await runtime.secrets()).rotate(args.ref, args.value);
|
|
1487
|
+
},
|
|
1488
|
+
deleteSecret: async (_parent, args, ctx) => {
|
|
1489
|
+
requireAdmin(authorizationService, ctx);
|
|
1490
|
+
await (await runtime.secrets()).delete(args.ref);
|
|
1491
|
+
return true;
|
|
1492
|
+
},
|
|
1493
|
+
checkConnection: (_parent, args, ctx) => runtime.checkConnection(args.connectionId, ctx),
|
|
1494
|
+
startOAuth: (_parent, args, ctx) => {
|
|
1495
|
+
requireAdmin(authorizationService, ctx);
|
|
1496
|
+
const returnUrl = returnUrlFor(args.returnUrl, ctx);
|
|
1497
|
+
return runtime.startOAuth(args.connectionId, ctx, {
|
|
1498
|
+
redirectUri: redirectUriFor(oauth, args.redirectUri),
|
|
1499
|
+
...returnUrl ? { returnUrl } : {}
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
};
|
|
1505
|
+
//#endregion
|
|
1506
|
+
//#region src/workflow/schema.ts
|
|
1507
|
+
const schema = gql`
|
|
1508
|
+
"""
|
|
1509
|
+
WorkflowRuntime Queries
|
|
1510
|
+
"""
|
|
1511
|
+
type WorkflowRuntimeQueries {
|
|
1512
|
+
health: String!
|
|
1513
|
+
"""
|
|
1514
|
+
Persisted runs, newest first. Scope them to one workflow, or to every
|
|
1515
|
+
workflow a drive holds; workflowId wins when both are given.
|
|
1516
|
+
"""
|
|
1517
|
+
runs(
|
|
1518
|
+
workflowId: String
|
|
1519
|
+
driveId: String
|
|
1520
|
+
limit: Int
|
|
1521
|
+
"Trigger kinds to leave out, e.g. test."
|
|
1522
|
+
excludeTriggerKinds: [String!]
|
|
1523
|
+
): [WorkflowRunRecord!]!
|
|
1524
|
+
"""
|
|
1525
|
+
The same listing a page at a time, newest journaled first. Pass a page's
|
|
1526
|
+
cursor back in paging to read the next; a page is short only when
|
|
1527
|
+
hasNextPage is false, or when the caller could read none of a long
|
|
1528
|
+
stretch of runs. A run that starts between pages keeps its place.
|
|
1529
|
+
"""
|
|
1530
|
+
runsPage(
|
|
1531
|
+
workflowId: String
|
|
1532
|
+
driveId: String
|
|
1533
|
+
excludeTriggerKinds: [String!]
|
|
1534
|
+
paging: WorkflowRunsPagingInput
|
|
1535
|
+
): WorkflowRunResultPage!
|
|
1536
|
+
run(id: String!): WorkflowRunRecord
|
|
1537
|
+
"""
|
|
1538
|
+
The descriptor (props, auth, ports) of one action or trigger, as the
|
|
1539
|
+
version that runs it describes it; triggers come back under "trigger".
|
|
1540
|
+
"""
|
|
1541
|
+
blockDescriptor(block: BlockInput!): Unknown
|
|
1542
|
+
"""
|
|
1543
|
+
Resolves a dynamic prop against the current config values: a DROPDOWN
|
|
1544
|
+
yields { options, placeholder, disabled }, a DYNAMIC prop yields the
|
|
1545
|
+
resolved sub-property descriptor list.
|
|
1546
|
+
"""
|
|
1547
|
+
blockOptions(
|
|
1548
|
+
block: BlockInput!
|
|
1549
|
+
propName: String!
|
|
1550
|
+
input: Unknown
|
|
1551
|
+
connectionId: String
|
|
1552
|
+
"What the author typed, for a DROPDOWN declared with refreshOnSearch."
|
|
1553
|
+
searchValue: String
|
|
1554
|
+
): Unknown
|
|
1555
|
+
"""
|
|
1556
|
+
Every piece this reactor offers: the core piece, its package pieces, and
|
|
1557
|
+
the pieces the registry and Activepieces publish.
|
|
1558
|
+
"""
|
|
1559
|
+
pieceCatalog: Unknown
|
|
1560
|
+
"""
|
|
1561
|
+
A piece's actions at one version; pin a step to that version.
|
|
1562
|
+
"""
|
|
1563
|
+
pieceActions(
|
|
1564
|
+
packageName: String!
|
|
1565
|
+
"That version; the installed or latest one when omitted."
|
|
1566
|
+
version: String
|
|
1567
|
+
): Unknown
|
|
1568
|
+
"""
|
|
1569
|
+
A piece's triggers at one version; pin a trigger to that version.
|
|
1570
|
+
"""
|
|
1571
|
+
pieceTriggers(
|
|
1572
|
+
packageName: String!
|
|
1573
|
+
"That version; the installed or latest one when omitted."
|
|
1574
|
+
version: String
|
|
1575
|
+
): Unknown
|
|
1576
|
+
"""
|
|
1577
|
+
Full piece detail (PieceMetadataModel-shaped), verbatim from the cloud API.
|
|
1578
|
+
"""
|
|
1579
|
+
pieceDetail(
|
|
1580
|
+
packageName: String!
|
|
1581
|
+
"That version; the installed or latest one when omitted."
|
|
1582
|
+
version: String
|
|
1583
|
+
): Unknown
|
|
1584
|
+
"""
|
|
1585
|
+
Action/trigger name search across the catalog. The index builds lazily on
|
|
1586
|
+
first use; poll while status is "indexing".
|
|
1587
|
+
"""
|
|
1588
|
+
searchBlocks(query: String!, limit: Int): BlockSearchResult!
|
|
1589
|
+
"""
|
|
1590
|
+
Health of every registered piece trigger (poll schedule, errors).
|
|
1591
|
+
"""
|
|
1592
|
+
triggerStates: [TriggerStateRecord!]!
|
|
1593
|
+
"""
|
|
1594
|
+
Authored output shape of a block (SDL / outputSchema / sampleData).
|
|
1595
|
+
"""
|
|
1596
|
+
blockOutputTree(block: BlockInput!, config: Unknown): Unknown
|
|
1597
|
+
"""
|
|
1598
|
+
Output shape of a draft step or trigger: its latest test output
|
|
1599
|
+
(source "test", with sample and testedAt) when tested, else as blockOutputTree.
|
|
1600
|
+
"""
|
|
1601
|
+
stepOutputTree(workflowId: String!, stepId: String!): Unknown
|
|
1602
|
+
"""
|
|
1603
|
+
Every block of the draft (trigger first) as this reactor resolves it:
|
|
1604
|
+
which piece version runs, from where, and how it matches the pin.
|
|
1605
|
+
"""
|
|
1606
|
+
blockResolutions(workflowId: String!): [BlockResolutionRecord!]!
|
|
1607
|
+
"""
|
|
1608
|
+
Every powerhouse/connection document, for connection pickers.
|
|
1609
|
+
"""
|
|
1610
|
+
connections: [ConnectionRecord!]!
|
|
1611
|
+
"""
|
|
1612
|
+
The webhook endpoint for a workflow: the URL to hand the provider. Minted
|
|
1613
|
+
on first ask, whether or not the workflow is armed — an author needs the
|
|
1614
|
+
URL before enabling, and the armed field carries that difference. Null
|
|
1615
|
+
only when the host has no webhook service. With driveId, a workflow not
|
|
1616
|
+
synced here yet is waited for briefly, then fails as WORKFLOW_SYNCING.
|
|
1617
|
+
"""
|
|
1618
|
+
webhookEndpoint(workflowId: String!, driveId: String): WebhookEndpointRecord
|
|
1619
|
+
"""
|
|
1620
|
+
Secret metadata (label, version, status). Never the value.
|
|
1621
|
+
"""
|
|
1622
|
+
secret(ref: String!): SecretRecord
|
|
1623
|
+
secrets: [SecretRecord!]!
|
|
1624
|
+
"""
|
|
1625
|
+
The URL an OAuth2 app must register as its redirect. A bare path when
|
|
1626
|
+
this host does not know its public origin; resolve it against the URL
|
|
1627
|
+
this API is reached at. Null when the host serves no HTTP routes.
|
|
1628
|
+
"""
|
|
1629
|
+
oauthRedirectUri: String
|
|
1630
|
+
"""
|
|
1631
|
+
How an OAuth2 sign-in opened by startOAuth stands; null when unknown.
|
|
1632
|
+
"""
|
|
1633
|
+
oauthAttempt(state: String!): OAuthAttempt
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
type OAuthAttempt {
|
|
1637
|
+
connectionId: String!
|
|
1638
|
+
"PENDING | EXCHANGING | OK | ERROR"
|
|
1639
|
+
status: String!
|
|
1640
|
+
error: String
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
type OAuthStart {
|
|
1644
|
+
state: String!
|
|
1645
|
+
"Where to send the user to sign in."
|
|
1646
|
+
authorizationUrl: String!
|
|
1647
|
+
redirectUri: String!
|
|
1648
|
+
expiresAt: String!
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
"""
|
|
1652
|
+
One action or trigger of a piece, as a workflow pins it. The engine's own
|
|
1653
|
+
blocks are the piece @powerhousedao/piece-core.
|
|
1654
|
+
"""
|
|
1655
|
+
input BlockInput {
|
|
1656
|
+
pieceName: String!
|
|
1657
|
+
"Exact semver."
|
|
1658
|
+
pieceVersion: String!
|
|
1659
|
+
"The action or trigger name."
|
|
1660
|
+
name: String!
|
|
1661
|
+
"action | trigger"
|
|
1662
|
+
kind: String!
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
type BlockSearchHit {
|
|
1666
|
+
pieceName: String!
|
|
1667
|
+
"The version the hit is listed at; pin to it when picking the block."
|
|
1668
|
+
pieceVersion: String!
|
|
1669
|
+
"The action or trigger name."
|
|
1670
|
+
name: String!
|
|
1671
|
+
pieceDisplayName: String!
|
|
1672
|
+
logoUrl: String!
|
|
1673
|
+
displayName: String!
|
|
1674
|
+
description: String!
|
|
1675
|
+
"action | trigger"
|
|
1676
|
+
kind: String!
|
|
1677
|
+
"Triggers only: POLLING | WEBHOOK | APP_WEBHOOK | MANUAL"
|
|
1678
|
+
strategy: String
|
|
1679
|
+
"Why this block cannot run on this reactor; null when it can."
|
|
1680
|
+
unsupported: String
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
type BlockResolutionRecord {
|
|
1684
|
+
"The trigger's or step's id."
|
|
1685
|
+
stepId: String!
|
|
1686
|
+
pieceName: String!
|
|
1687
|
+
"The version the block pins."
|
|
1688
|
+
pieceVersion: String!
|
|
1689
|
+
"The action or trigger name."
|
|
1690
|
+
name: String!
|
|
1691
|
+
"action | trigger"
|
|
1692
|
+
kind: String!
|
|
1693
|
+
"The version that runs; null when nothing resolves."
|
|
1694
|
+
resolvedVersion: String
|
|
1695
|
+
"local | registry | activepieces | npm; null when nothing resolves."
|
|
1696
|
+
source: String
|
|
1697
|
+
"exact | compatible | fallback | installed | missing"
|
|
1698
|
+
match: String!
|
|
1699
|
+
"Why the version that runs differs from the pin, or why nothing resolves."
|
|
1700
|
+
note: String
|
|
1701
|
+
"The newest version any source offers."
|
|
1702
|
+
latestVersion: String
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
type BlockSearchResult {
|
|
1706
|
+
"ready | indexing | error"
|
|
1707
|
+
status: String!
|
|
1708
|
+
hits: [BlockSearchHit!]!
|
|
1709
|
+
indexedPieces: Int!
|
|
1710
|
+
error: String
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
type SecretRecord {
|
|
1714
|
+
ref: String!
|
|
1715
|
+
label: String
|
|
1716
|
+
version: Int!
|
|
1717
|
+
status: String!
|
|
1718
|
+
createdAt: String!
|
|
1719
|
+
updatedAt: String!
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
type ConnectionRecord {
|
|
1723
|
+
id: String!
|
|
1724
|
+
name: String!
|
|
1725
|
+
connectorId: String!
|
|
1726
|
+
authType: String!
|
|
1727
|
+
status: String!
|
|
1728
|
+
accountLabel: String
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
type ConnectionCheckResult {
|
|
1732
|
+
ok: Boolean!
|
|
1733
|
+
detail: String
|
|
1734
|
+
"""
|
|
1735
|
+
From auth.getConnectionIdentifier when the check passes; otherwise the
|
|
1736
|
+
label the connection already holds.
|
|
1737
|
+
"""
|
|
1738
|
+
accountLabel: String
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
type WebhookEndpointRecord {
|
|
1742
|
+
workflowId: String!
|
|
1743
|
+
url: String!
|
|
1744
|
+
"""
|
|
1745
|
+
False when the host gave the webhook service no public origin, so the url
|
|
1746
|
+
above is a bare path. Switchboard always gives one, falling back to
|
|
1747
|
+
http://localhost:<port> when PUBLIC_URL is unset.
|
|
1748
|
+
"""
|
|
1749
|
+
absoluteUrl: Boolean!
|
|
1750
|
+
"True while the workflow is ENABLED with a valid webhook trigger"
|
|
1751
|
+
armed: Boolean!
|
|
1752
|
+
createdAt: String!
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
type TriggerStateRecord {
|
|
1756
|
+
workflowId: String!
|
|
1757
|
+
pieceName: String!
|
|
1758
|
+
triggerName: String!
|
|
1759
|
+
status: String!
|
|
1760
|
+
intervalMs: Int!
|
|
1761
|
+
nextPollAt: String
|
|
1762
|
+
lastPollAt: String
|
|
1763
|
+
lastError: String
|
|
1764
|
+
consecutiveFailures: Int!
|
|
1765
|
+
"When a webhook trigger next renews its subscription; null if it never does."
|
|
1766
|
+
nextRenewAt: String
|
|
1767
|
+
"The last failed renewal, kept apart from lastError, the poll's."
|
|
1768
|
+
renewError: String
|
|
1769
|
+
renewFailures: Int!
|
|
1770
|
+
"The piece version the trigger armed with; null for a host-fed trigger."
|
|
1771
|
+
pieceVersion: String
|
|
1772
|
+
pieceSource: String
|
|
1773
|
+
versionMatch: String
|
|
1774
|
+
versionNote: String
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
type Query {
|
|
1778
|
+
workflowRuntime: WorkflowRuntimeQueries!
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
type WorkflowStepRunRecord {
|
|
1782
|
+
stepId: String!
|
|
1783
|
+
stepKey: String!
|
|
1784
|
+
pieceName: String!
|
|
1785
|
+
"The action the step ran, or the trigger a trigger test sampled."
|
|
1786
|
+
blockName: String!
|
|
1787
|
+
status: String!
|
|
1788
|
+
input: Unknown
|
|
1789
|
+
output: Unknown
|
|
1790
|
+
port: String
|
|
1791
|
+
error: String
|
|
1792
|
+
startedAt: String
|
|
1793
|
+
endedAt: String
|
|
1794
|
+
"The piece version that ran; null for an unresolved block."
|
|
1795
|
+
pieceVersion: String
|
|
1796
|
+
"local | registry | activepieces | npm"
|
|
1797
|
+
pieceSource: String
|
|
1798
|
+
"exact | compatible | fallback | installed | missing"
|
|
1799
|
+
versionMatch: String
|
|
1800
|
+
"Why the version that ran differs from the pin."
|
|
1801
|
+
versionNote: String
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
type WorkflowRunRecord {
|
|
1805
|
+
id: String!
|
|
1806
|
+
workflowId: String!
|
|
1807
|
+
workflowName: String!
|
|
1808
|
+
workflowVersion: Int!
|
|
1809
|
+
triggerKind: String!
|
|
1810
|
+
triggerPayload: Unknown
|
|
1811
|
+
status: String!
|
|
1812
|
+
error: String
|
|
1813
|
+
"When the run began executing; a PENDING run's is when it was journaled."
|
|
1814
|
+
startedAt: String!
|
|
1815
|
+
endedAt: String
|
|
1816
|
+
rerunOf: String
|
|
1817
|
+
"How many warningNotes the run has; a run with any is not a plain success."
|
|
1818
|
+
warnings: Int!
|
|
1819
|
+
"Steps that ran a fallback piece version, edges on ports nothing emits."
|
|
1820
|
+
warningNotes: [String!]!
|
|
1821
|
+
steps: [WorkflowStepRunRecord!]!
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
input WorkflowRunsPagingInput {
|
|
1825
|
+
"Page size; default 25, at most 100."
|
|
1826
|
+
limit: Int
|
|
1827
|
+
"The cursor of the previous page."
|
|
1828
|
+
cursor: String
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
type WorkflowRunResultPage {
|
|
1832
|
+
items: [WorkflowRunRecord!]!
|
|
1833
|
+
hasNextPage: Boolean!
|
|
1834
|
+
"True when the page was read after a cursor."
|
|
1835
|
+
hasPreviousPage: Boolean!
|
|
1836
|
+
"Resumes after this page's last run; null for an empty page."
|
|
1837
|
+
cursor: String
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
type WorkflowStepRun {
|
|
1841
|
+
stepId: String!
|
|
1842
|
+
key: String!
|
|
1843
|
+
pieceName: String!
|
|
1844
|
+
blockName: String!
|
|
1845
|
+
status: String!
|
|
1846
|
+
input: Unknown
|
|
1847
|
+
output: Unknown
|
|
1848
|
+
port: String
|
|
1849
|
+
error: String
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
type WorkflowRunPayload {
|
|
1853
|
+
runId: String
|
|
1854
|
+
status: String!
|
|
1855
|
+
error: String
|
|
1856
|
+
steps: [WorkflowStepRun!]!
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
type WorkflowStepTestResult {
|
|
1860
|
+
"Null when the test never started, e.g. an upstream block is untested."
|
|
1861
|
+
runId: String
|
|
1862
|
+
"SUCCEEDED | FAILED"
|
|
1863
|
+
status: String!
|
|
1864
|
+
output: Unknown
|
|
1865
|
+
error: String
|
|
1866
|
+
durationMs: Int!
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
"""
|
|
1870
|
+
WorkflowRuntime Mutations
|
|
1871
|
+
"""
|
|
1872
|
+
type WorkflowRuntimeMutations {
|
|
1873
|
+
"""
|
|
1874
|
+
Fires a workflow's manual trigger (the core piece's) and runs it to completion.
|
|
1875
|
+
"""
|
|
1876
|
+
fire(workflowId: String!, payload: Unknown): WorkflowRunPayload!
|
|
1877
|
+
"""
|
|
1878
|
+
Samples the draft trigger and saves it as the trigger's last test. A piece
|
|
1879
|
+
trigger runs its test hook (no cursor changes). Of the core piece's
|
|
1880
|
+
triggers, manual takes payload, schedule samples a fire now, and webhook
|
|
1881
|
+
waits for the next delivery to the workflow's endpoint, up to
|
|
1882
|
+
timeoutSeconds (default and cap 300), and that delivery runs nothing.
|
|
1883
|
+
"""
|
|
1884
|
+
testTrigger(
|
|
1885
|
+
workflowId: String!
|
|
1886
|
+
payload: Unknown
|
|
1887
|
+
timeoutSeconds: Int
|
|
1888
|
+
driveId: String
|
|
1889
|
+
): Unknown
|
|
1890
|
+
"""
|
|
1891
|
+
Stops a webhook trigger test that is waiting; false when none was.
|
|
1892
|
+
"""
|
|
1893
|
+
cancelTriggerTest(workflowId: String!): Boolean!
|
|
1894
|
+
"""
|
|
1895
|
+
Runs one draft step against the latest test outputs of the blocks it
|
|
1896
|
+
reads, journaled as a "test" run and noted as the step's lastTest.
|
|
1897
|
+
driveId waits for an unsynced workflow, as for webhookEndpoint.
|
|
1898
|
+
"""
|
|
1899
|
+
testStep(
|
|
1900
|
+
workflowId: String!
|
|
1901
|
+
stepId: String!
|
|
1902
|
+
driveId: String
|
|
1903
|
+
): WorkflowStepTestResult!
|
|
1904
|
+
"""
|
|
1905
|
+
Resumes a FAILED run: succeeded steps replay from the journal,
|
|
1906
|
+
execution restarts at the failure. Produces a new run.
|
|
1907
|
+
"""
|
|
1908
|
+
rerun(runId: String!): WorkflowRunPayload!
|
|
1909
|
+
"""
|
|
1910
|
+
Mints a managed secret and returns its ref; the value is stored
|
|
1911
|
+
encrypted and is never readable back over any API.
|
|
1912
|
+
"""
|
|
1913
|
+
createSecret(value: String!, label: String): SecretRecord!
|
|
1914
|
+
"""
|
|
1915
|
+
Replaces the value behind an existing ref; documents stay untouched.
|
|
1916
|
+
"""
|
|
1917
|
+
rotateSecret(ref: String!, value: String!): SecretRecord!
|
|
1918
|
+
"""
|
|
1919
|
+
Tombstones a secret; its value becomes unrecoverable.
|
|
1920
|
+
"""
|
|
1921
|
+
deleteSecret(ref: String!): Boolean!
|
|
1922
|
+
"""
|
|
1923
|
+
Runs the piece's auth.validate against the connection's credentials,
|
|
1924
|
+
then auth.getConnectionIdentifier for the account label, and records the
|
|
1925
|
+
outcome on the connection document.
|
|
1926
|
+
"""
|
|
1927
|
+
checkConnection(connectionId: String!): ConnectionCheckResult!
|
|
1928
|
+
"""
|
|
1929
|
+
Opens an OAuth2 sign-in with the connection's own app (client_id in its
|
|
1930
|
+
config, client_secret in its secretRefs). redirectUri is needed only when
|
|
1931
|
+
oauthRedirectUri is a bare path. With returnUrl, which must be on the
|
|
1932
|
+
caller's origin, the callback sends the browser back there with
|
|
1933
|
+
?ph_oauth=<state> instead of closing the window.
|
|
1934
|
+
"""
|
|
1935
|
+
startOAuth(
|
|
1936
|
+
connectionId: String!
|
|
1937
|
+
redirectUri: String
|
|
1938
|
+
returnUrl: String
|
|
1939
|
+
): OAuthStart!
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
type Mutation {
|
|
1943
|
+
workflowRuntime: WorkflowRuntimeMutations!
|
|
1944
|
+
}
|
|
1945
|
+
`;
|
|
1946
|
+
//#endregion
|
|
1947
|
+
//#region src/workflow/subgraph.ts
|
|
1948
|
+
/** The runtime's read/write surface. The runtime itself is composed by the
|
|
1949
|
+
* host, so the subgraph only serves what it is handed. */
|
|
1950
|
+
function createWorkflowRuntimeSubgraph(runtime, oauth) {
|
|
1951
|
+
return class WorkflowRuntimeSubgraph extends BaseSubgraph {
|
|
1952
|
+
name = "workflow-runtime";
|
|
1953
|
+
typeDefs = schema;
|
|
1954
|
+
resolvers = getResolvers(runtime, this.authorizationService, oauth);
|
|
1955
|
+
additionalContextFields = {};
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
//#endregion
|
|
1959
|
+
//#region src/workflow-runtime.mts
|
|
1960
|
+
/** The npm name the workflow package owns: its HTTP namespace and its models. */
|
|
1961
|
+
const WORKFLOW_PACKAGE_NAME = "@powerhousedao/workflow";
|
|
1962
|
+
/** The env var and OpenFeature flag key that turns workflows on. */
|
|
1963
|
+
const PH_WORKFLOWS_ENABLED = "PH_WORKFLOWS_ENABLED";
|
|
1964
|
+
/** Precedence, unchanged from the reactor-api resolver this replaces: the
|
|
1965
|
+
* host's option, then PH_WORKFLOWS_ENABLED, then the config file, then off. */
|
|
1966
|
+
async function resolveWorkflowsEnabled({ featureFlags, override, configEnabled = false, env = process.env }) {
|
|
1967
|
+
if (override !== void 0) return override;
|
|
1968
|
+
const raw = env[PH_WORKFLOWS_ENABLED]?.trim();
|
|
1969
|
+
if (raw === "1") return true;
|
|
1970
|
+
if (raw === "0") return false;
|
|
1971
|
+
return featureFlags.getBooleanValue(PH_WORKFLOWS_ENABLED, configEnabled);
|
|
1972
|
+
}
|
|
1973
|
+
async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao/workflow/document-models")) {
|
|
1974
|
+
try {
|
|
1975
|
+
await load();
|
|
1976
|
+
} catch (error) {
|
|
1977
|
+
throw new Error(`Workflows are enabled but ${WORKFLOW_PACKAGE_NAME} could not be loaded`, { cause: error });
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
function readAssertion(authorizationService, reactorClient) {
|
|
1981
|
+
const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
|
|
1982
|
+
const hostCanRead = async (identifier, ctx) => {
|
|
1983
|
+
if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
|
|
1984
|
+
if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
|
|
1985
|
+
let documentId;
|
|
1986
|
+
try {
|
|
1987
|
+
documentId = await resolveCanonical(identifier);
|
|
1988
|
+
} catch {
|
|
1989
|
+
throw new ForbiddenError();
|
|
1990
|
+
}
|
|
1991
|
+
if (!await authorizationService.canRead(documentId, ctx.user?.address)) throw new ForbiddenError("to read this document");
|
|
1992
|
+
};
|
|
1993
|
+
return async (identifier, caller) => {
|
|
1994
|
+
const ctx = caller;
|
|
1995
|
+
await hostCanRead(identifier, ctx);
|
|
1996
|
+
let served;
|
|
1997
|
+
try {
|
|
1998
|
+
served = await reactorClient.isServed(identifier, { subject: callerSubject(ctx.user) });
|
|
1999
|
+
} catch {
|
|
2000
|
+
throw new ForbiddenError();
|
|
2001
|
+
}
|
|
2002
|
+
if (!served) throw new ForbiddenError("to read this document");
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
/** The same, for a design-time call that writes what it names. */
|
|
2006
|
+
function writeAssertion(authorizationService, reactorClient) {
|
|
2007
|
+
const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
|
|
2008
|
+
return async (identifier, caller) => {
|
|
2009
|
+
const ctx = caller;
|
|
2010
|
+
if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
|
|
2011
|
+
if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
|
|
2012
|
+
let documentId;
|
|
2013
|
+
try {
|
|
2014
|
+
documentId = await resolveCanonical(identifier);
|
|
2015
|
+
} catch {
|
|
2016
|
+
throw new ForbiddenError();
|
|
2017
|
+
}
|
|
2018
|
+
if (!await authorizationService.canWrite(documentId, ctx.user?.address)) throw new ForbiddenError("to write this document");
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
/** A step reads any well-formed attachment, as it reads any document: a run
|
|
2022
|
+
* carries no caller to check against. */
|
|
2023
|
+
function canReadAttachmentRef(_documentId, ref) {
|
|
2024
|
+
try {
|
|
2025
|
+
return Promise.resolve(parseRef(ref).version === 1);
|
|
2026
|
+
} catch {
|
|
2027
|
+
return Promise.resolve(false);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
async function registerWorkflowTriggersReadModel(engine, runtime, clientModule) {
|
|
2031
|
+
const reactorModule = clientModule?.reactorModule;
|
|
2032
|
+
if (!reactorModule) return {
|
|
2033
|
+
status: "unavailable",
|
|
2034
|
+
reason: "in-process-reactor-module-unavailable"
|
|
2035
|
+
};
|
|
2036
|
+
const coordinator = reactorModule.readModelCoordinator;
|
|
2037
|
+
if (!supportsLiveReadModelRegistration(coordinator)) return {
|
|
2038
|
+
status: "unavailable",
|
|
2039
|
+
reason: "live-read-model-registration-unsupported"
|
|
2040
|
+
};
|
|
2041
|
+
const readModel = new engine.WorkflowTriggersReadModel(reactorModule.database.withSchema(REACTOR_SCHEMA), reactorModule.operationIndex, reactorModule.writeCache, reactorModule.processorManagerConsistencyTracker, runtime);
|
|
2042
|
+
await readModel.init();
|
|
2043
|
+
coordinator.addReadModel(readModel, engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE);
|
|
2044
|
+
return { status: "available" };
|
|
2045
|
+
}
|
|
2046
|
+
function bindPackagePieces(registry, source) {
|
|
2047
|
+
const apply = (byPackage) => {
|
|
2048
|
+
registry.setPieces([...byPackage.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).flatMap(([, pieces]) => pieces));
|
|
2049
|
+
};
|
|
2050
|
+
apply(source.getPieces());
|
|
2051
|
+
source.onPiecesChange(apply);
|
|
2052
|
+
}
|
|
2053
|
+
async function composeWorkflowRuntime(deps) {
|
|
2054
|
+
const load = deps.load ?? (() => import("@powerhousedao/reactor-workflow"));
|
|
2055
|
+
let engine;
|
|
2056
|
+
try {
|
|
2057
|
+
engine = await load();
|
|
2058
|
+
} catch (error) {
|
|
2059
|
+
throw new Error("Workflows are enabled but @powerhousedao/reactor-workflow could not be loaded", { cause: error });
|
|
2060
|
+
}
|
|
2061
|
+
engine.setPieceRegistryUrl(deps.pieceRegistryUrl);
|
|
2062
|
+
if (deps.pieces) bindPackagePieces(engine.packagePieces, deps.pieces);
|
|
2063
|
+
const runtime = engine.createWorkflowRuntime({
|
|
2064
|
+
relationalDb: deps.relationalDb,
|
|
2065
|
+
secretsKeyFile: deps.secretsKeyFile,
|
|
2066
|
+
reactorClient: deps.reactorClient,
|
|
2067
|
+
assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
|
|
2068
|
+
subjectOf: (caller) => callerSubject(caller.user),
|
|
2069
|
+
assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
|
|
2070
|
+
webhooks: deps.webhooks,
|
|
2071
|
+
attachments: deps.attachments,
|
|
2072
|
+
canReadAttachmentRef,
|
|
2073
|
+
logger: deps.logger
|
|
2074
|
+
});
|
|
2075
|
+
const triggers = await registerWorkflowTriggersReadModel(engine, runtime, deps.clientModule);
|
|
2076
|
+
if (triggers.status === "available") deps.logger.info(`Workflow trigger read model registered (${engine.WORKFLOW_TRIGGERS_READ_MODEL}, ${engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE})`);
|
|
2077
|
+
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);
|
|
2078
|
+
let stopped = false;
|
|
2079
|
+
const oauthCallback = deps.http ? registerOAuthCallback(deps.http, runtime) : void 0;
|
|
2080
|
+
return {
|
|
2081
|
+
subgraph: createWorkflowRuntimeSubgraph(runtime, deps.http ? { callbackUrl: callbackUrlOf(deps.http) } : void 0),
|
|
2082
|
+
triggers,
|
|
2083
|
+
async start() {
|
|
2084
|
+
await runtime.registerWebhookEndpoint();
|
|
2085
|
+
runtime.startTriggerSupervisor();
|
|
2086
|
+
},
|
|
2087
|
+
stop() {
|
|
2088
|
+
if (stopped) return Promise.resolve();
|
|
2089
|
+
stopped = true;
|
|
2090
|
+
oauthCallback?.dispose();
|
|
2091
|
+
runtime.shutdown();
|
|
2092
|
+
return Promise.resolve();
|
|
2093
|
+
}
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
//#endregion
|
|
868
2097
|
//#region src/pglite-dialect.ts
|
|
869
2098
|
var ClosablePGliteDialect = class extends PGliteDialect {
|
|
870
2099
|
#pglite;
|
|
@@ -1003,6 +2232,26 @@ function resolveReactorFeatureFlags(env) {
|
|
|
1003
2232
|
};
|
|
1004
2233
|
}
|
|
1005
2234
|
//#endregion
|
|
2235
|
+
//#region src/create-signature-policy.mts
|
|
2236
|
+
/**
|
|
2237
|
+
* What new documents are created as, from CREATE_SIGNATURE_POLICY. Without a
|
|
2238
|
+
* signer this switchboard cannot write to a v2-required document, so it falls
|
|
2239
|
+
* back to legacy and says so.
|
|
2240
|
+
*/
|
|
2241
|
+
function resolveCreateSignaturePolicy(env, options) {
|
|
2242
|
+
const raw = env.CREATE_SIGNATURE_POLICY?.trim();
|
|
2243
|
+
let policy = DEFAULT_SIGNATURE_POLICY;
|
|
2244
|
+
if (raw) {
|
|
2245
|
+
if (!isSignaturePolicy(raw)) throw new Error(`CREATE_SIGNATURE_POLICY must be "legacy" or "v2-required", got "${raw}"`);
|
|
2246
|
+
policy = raw;
|
|
2247
|
+
}
|
|
2248
|
+
if (policy === "v2-required" && !options.hasSigner) {
|
|
2249
|
+
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.");
|
|
2250
|
+
return "legacy";
|
|
2251
|
+
}
|
|
2252
|
+
return policy;
|
|
2253
|
+
}
|
|
2254
|
+
//#endregion
|
|
1006
2255
|
//#region src/renown.ts
|
|
1007
2256
|
const logger = childLogger(["switchboard", "renown"]);
|
|
1008
2257
|
/**
|
|
@@ -1025,15 +2274,123 @@ async function initRenown(options = {}) {
|
|
|
1025
2274
|
* Get the signer config for the given renown instance.
|
|
1026
2275
|
*
|
|
1027
2276
|
* @param renown - The renown instance
|
|
1028
|
-
* @param
|
|
2277
|
+
* @param keypairPath - Where `initRenown` loaded the key from
|
|
1029
2278
|
*/
|
|
1030
|
-
function getRenownSignerConfig(renown,
|
|
2279
|
+
function getRenownSignerConfig(renown, keypairPath) {
|
|
1031
2280
|
return {
|
|
1032
2281
|
signer: renown.signer,
|
|
1033
|
-
|
|
2282
|
+
workerSigner: getRenownWorkerSignerSpec(renown, keypairPath)
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* What a pooled executor worker imports to sign as this switchboard: it
|
|
2287
|
+
* reloads the key `initRenown` stored, and takes the user known at boot.
|
|
2288
|
+
*/
|
|
2289
|
+
function getRenownWorkerSignerSpec(renown, keypairPath) {
|
|
2290
|
+
const { signer } = renown;
|
|
2291
|
+
const user = signer.user;
|
|
2292
|
+
return {
|
|
2293
|
+
module: {
|
|
2294
|
+
filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
|
|
2295
|
+
exportName: "createNodeRenownSigner"
|
|
2296
|
+
},
|
|
2297
|
+
initArgs: {
|
|
2298
|
+
appName: signer.app?.name ?? "switchboard",
|
|
2299
|
+
keypairPath: resolve(keypairPath ?? DEFAULT_KEYPAIR_PATH),
|
|
2300
|
+
...signer.app?.key ? { did: signer.app.key } : {},
|
|
2301
|
+
...user ? { user: {
|
|
2302
|
+
address: user.address,
|
|
2303
|
+
networkId: user.networkId,
|
|
2304
|
+
chainId: user.chainId
|
|
2305
|
+
} } : {}
|
|
2306
|
+
}
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Admits a key as a signer for an address a Renown credential binds it to; a
|
|
2311
|
+
* `self` source has no worker spec. In process, the switchboard's own key is
|
|
2312
|
+
* matched against its current user; the worker spec carries the user known now.
|
|
2313
|
+
*/
|
|
2314
|
+
async function getRenownTrustPolicyConfig(trustSource, renown) {
|
|
2315
|
+
const ownSigner = renown?.signer;
|
|
2316
|
+
if (trustSource.source === "self") return { trustPolicy: createRenownTrustPolicy({
|
|
2317
|
+
switchboard: trustSource.request,
|
|
2318
|
+
ownSigner
|
|
2319
|
+
}) };
|
|
2320
|
+
const switchboardUrl = await resolveSwitchboardEndpoint({
|
|
2321
|
+
switchboardUrl: trustSource.switchboardUrl,
|
|
2322
|
+
baseUrl: trustSource.renownUrl
|
|
2323
|
+
});
|
|
2324
|
+
const options = {
|
|
2325
|
+
...switchboardUrl ? { switchboard: switchboardUrl } : {},
|
|
2326
|
+
...trustSource.renownUrl ? { renownUrl: trustSource.renownUrl } : {}
|
|
2327
|
+
};
|
|
2328
|
+
const self = ownIdentity(renown);
|
|
2329
|
+
return {
|
|
2330
|
+
trustPolicy: createRenownTrustPolicy({
|
|
2331
|
+
...options,
|
|
2332
|
+
ownSigner
|
|
2333
|
+
}),
|
|
2334
|
+
workerTrustPolicy: {
|
|
2335
|
+
module: {
|
|
2336
|
+
filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
|
|
2337
|
+
exportName: "createRenownTrustPolicy"
|
|
2338
|
+
},
|
|
2339
|
+
initArgs: {
|
|
2340
|
+
...options,
|
|
2341
|
+
...self ? { self } : {}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Fails the boot when pooled workers would refuse every signed write the
|
|
2348
|
+
* in-process executor accepts: a `self` Renown source gives workers no trust
|
|
2349
|
+
* policy spec, so under authEnforcement they apply the default.
|
|
2350
|
+
*/
|
|
2351
|
+
function assertWorkerTrustPolicy(config) {
|
|
2352
|
+
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).");
|
|
2353
|
+
}
|
|
2354
|
+
function ownIdentity(renown) {
|
|
2355
|
+
const key = renown?.signer.app?.key;
|
|
2356
|
+
if (!key) return;
|
|
2357
|
+
const user = renown.signer.user;
|
|
2358
|
+
return {
|
|
2359
|
+
key,
|
|
2360
|
+
user: {
|
|
2361
|
+
address: user?.address ?? "",
|
|
2362
|
+
networkId: user?.networkId ?? "",
|
|
2363
|
+
chainId: user?.chainId ?? 0
|
|
2364
|
+
}
|
|
1034
2365
|
};
|
|
1035
2366
|
}
|
|
1036
2367
|
//#endregion
|
|
2368
|
+
//#region src/unsupported-stored-documents.mts
|
|
2369
|
+
const MODES = ["refuse", "read-only"];
|
|
2370
|
+
/** From REACTOR_UNSUPPORTED_STORED_DOCUMENTS; `input` wins when defined. */
|
|
2371
|
+
function resolveUnsupportedStoredDocuments(input, env) {
|
|
2372
|
+
if (input !== void 0) return input;
|
|
2373
|
+
const raw = env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS?.trim();
|
|
2374
|
+
if (!raw) return "refuse";
|
|
2375
|
+
if (!MODES.includes(raw)) throw new Error(`REACTOR_UNSUPPORTED_STORED_DOCUMENTS must be "refuse" or "read-only", got "${raw}"`);
|
|
2376
|
+
return raw;
|
|
2377
|
+
}
|
|
2378
|
+
/** The reactor refused to start over documents this build does not run. */
|
|
2379
|
+
var StoredDocumentsRefusedError = class extends Error {
|
|
2380
|
+
versions;
|
|
2381
|
+
documents;
|
|
2382
|
+
constructor(cause) {
|
|
2383
|
+
const named = cause.versions.map(({ protocol, version }) => `${protocol} ${version}`).join(", ");
|
|
2384
|
+
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 });
|
|
2385
|
+
this.name = "StoredDocumentsRefusedError";
|
|
2386
|
+
this.versions = cause.versions;
|
|
2387
|
+
this.documents = cause.documents;
|
|
2388
|
+
}
|
|
2389
|
+
static isError(error) {
|
|
2390
|
+
return Error.isError(error) && error.name === "StoredDocumentsRefusedError";
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
//#endregion
|
|
1037
2394
|
//#region src/server.mts
|
|
1038
2395
|
const defaultLogger = childLogger(["switchboard"]);
|
|
1039
2396
|
const LogLevel = process.env.LOG_LEVEL || "info";
|
|
@@ -1041,8 +2398,6 @@ setLogLevel(LogLevel);
|
|
|
1041
2398
|
dotenv.config();
|
|
1042
2399
|
const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
|
|
1043
2400
|
const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
|
|
1044
|
-
const REQUIRE_SIGNATURES = "REQUIRE_SIGNATURES";
|
|
1045
|
-
const REQUIRE_SIGNATURES_DEFAULT = false;
|
|
1046
2401
|
const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
|
|
1047
2402
|
const PORT_FALLBACK_ATTEMPTS = 20;
|
|
1048
2403
|
const PGLITE_FLUSH_INTERVAL_MS = (() => {
|
|
@@ -1051,6 +2406,9 @@ const PGLITE_FLUSH_INTERVAL_MS = (() => {
|
|
|
1051
2406
|
const parsed = Number(raw);
|
|
1052
2407
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 100;
|
|
1053
2408
|
})();
|
|
2409
|
+
function onPgliteFlushError(err) {
|
|
2410
|
+
triggerFatalShutdown("PGlite snapshot flush failed", err);
|
|
2411
|
+
}
|
|
1054
2412
|
const PGLITE_IN_MEMORY = process.env.PH_PGLITE_IN_MEMORY === "1";
|
|
1055
2413
|
/**
|
|
1056
2414
|
* Attempt to bind a throwaway TCP server to the given port. Resolves true if
|
|
@@ -1077,6 +2435,13 @@ function isPortAvailable(port) {
|
|
|
1077
2435
|
function resolveConfigPath(configFile) {
|
|
1078
2436
|
return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
|
|
1079
2437
|
}
|
|
2438
|
+
function readConfigWorkflowsEnabled(configPath) {
|
|
2439
|
+
try {
|
|
2440
|
+
return getConfig(configPath).workflows?.enabled ?? false;
|
|
2441
|
+
} catch {
|
|
2442
|
+
return false;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
1080
2445
|
async function resolveServerPort(requested, strictPort, logger) {
|
|
1081
2446
|
if (strictPort) return requested;
|
|
1082
2447
|
for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
|
|
@@ -1108,7 +2473,8 @@ async function createReactorKysely(opts) {
|
|
|
1108
2473
|
const { PGlite } = await loadPGliteModule(reactorPgliteMajor);
|
|
1109
2474
|
const pglite = inMemory ? new PGlite() : new PGlite({ fs: new AtomicNodeFs(reactorPgliteDir, {
|
|
1110
2475
|
logger,
|
|
1111
|
-
flushIntervalMs
|
|
2476
|
+
flushIntervalMs,
|
|
2477
|
+
onFlushError: onPgliteFlushError
|
|
1112
2478
|
}) });
|
|
1113
2479
|
logger.info(inMemory ? `Using in-memory PGlite (PG${reactorPgliteMajor}) for reactor storage [PH_PGLITE_IN_MEMORY=1]` : `Using PGlite (PG${reactorPgliteMajor}) for reactor storage at ${reactorPgliteDir}`);
|
|
1114
2480
|
return {
|
|
@@ -1189,8 +2555,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1189
2555
|
}
|
|
1190
2556
|
if (workerPool) {
|
|
1191
2557
|
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");
|
|
2558
|
+
assertWorkerTrustPolicy({
|
|
2559
|
+
workers: workerPool.numWorkers,
|
|
2560
|
+
authEnforcement: resolveReactorFeatureFlags(process.env).flags.authEnforcement === true,
|
|
2561
|
+
renownSource: renownConfig.source
|
|
2562
|
+
});
|
|
1192
2563
|
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
2564
|
}
|
|
2565
|
+
const unsupportedStoredDocuments = resolveUnsupportedStoredDocuments(options.unsupportedStoredDocuments, process.env);
|
|
1194
2566
|
let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
|
|
1195
2567
|
if (projectionWorker && options.reactor) {
|
|
1196
2568
|
logger.warn("Projection worker configuration ignored: the caller-provided reactor owns its own read-model coordinator");
|
|
@@ -1224,7 +2596,14 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1224
2596
|
reactorLogger.error(`Projection worker ${shardId} died; shutting down so the supervisor restarts a healthy process`, reason);
|
|
1225
2597
|
process.kill(process.pid, "SIGTERM");
|
|
1226
2598
|
};
|
|
2599
|
+
const workflowsEnabled = options.workflows?.enabled === true;
|
|
2600
|
+
const privacyConfig = resolvePrivacy(options.privacy, process.env);
|
|
2601
|
+
if (workflowsEnabled) {
|
|
2602
|
+
await assertWorkflowPackageLoadable();
|
|
2603
|
+
if (!packages.includes("@powerhousedao/workflow")) packages.push(WORKFLOW_PACKAGE_NAME);
|
|
2604
|
+
}
|
|
1227
2605
|
let ownedReactorModule;
|
|
2606
|
+
let localRenownRequest;
|
|
1228
2607
|
const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
|
|
1229
2608
|
if (options.reactor) {
|
|
1230
2609
|
const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
|
|
@@ -1251,7 +2630,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1251
2630
|
if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
|
|
1252
2631
|
const { flags: reactorFeatureFlags, enabled: enabledFeatureFlags } = resolveReactorFeatureFlags(process.env);
|
|
1253
2632
|
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" });
|
|
2633
|
+
const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" }).withUnsupportedStoredDocuments(unsupportedStoredDocuments);
|
|
1255
2634
|
if (poolInstrumentation) reactorBuilder.withInstrumentedPool(poolInstrumentation);
|
|
1256
2635
|
const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
|
|
1257
2636
|
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 +2643,19 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1264
2643
|
} : void 0,
|
|
1265
2644
|
documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
|
|
1266
2645
|
logger: reactorLogger,
|
|
1267
|
-
signer: renown ? getRenownSignerConfig(renown, options.identity?.
|
|
2646
|
+
signer: renown ? getRenownSignerConfig(renown, options.identity?.keypairPath) : void 0,
|
|
2647
|
+
createSignaturePolicy: resolveCreateSignaturePolicy(process.env, {
|
|
2648
|
+
hasSigner: renown !== null,
|
|
2649
|
+
logger
|
|
2650
|
+
}),
|
|
2651
|
+
trustPolicy: reactorFeatureFlags.authEnforcement ? await getRenownTrustPolicyConfig(renownConfig.source === "self" ? {
|
|
2652
|
+
source: "self",
|
|
2653
|
+
request: (query, variables) => localRenownRequest ? localRenownRequest(query, variables) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet"))
|
|
2654
|
+
} : {
|
|
2655
|
+
source: "remote",
|
|
2656
|
+
renownUrl: renownConfig.url,
|
|
2657
|
+
switchboardUrl: renownConfig.switchboardUrl
|
|
2658
|
+
}, renown) : void 0
|
|
1268
2659
|
});
|
|
1269
2660
|
if (workerPool) {
|
|
1270
2661
|
if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
|
|
@@ -1308,17 +2699,30 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1308
2699
|
reactorBuilder.withShutdownHook(async () => {
|
|
1309
2700
|
if (apiRef.current) await apiRef.current.dispose();
|
|
1310
2701
|
});
|
|
1311
|
-
|
|
2702
|
+
let module;
|
|
2703
|
+
try {
|
|
2704
|
+
module = await clientBuilder.buildModule();
|
|
2705
|
+
} catch (error) {
|
|
2706
|
+
try {
|
|
2707
|
+
await baseKysely.destroy();
|
|
2708
|
+
} catch (destroyError) {
|
|
2709
|
+
logger.error("Aborting boot: reactor database destroy failed: @error", destroyError);
|
|
2710
|
+
}
|
|
2711
|
+
if (UnsupportedStoredProtocolError.isError(error)) throw new StoredDocumentsRefusedError(error);
|
|
2712
|
+
throw error;
|
|
2713
|
+
}
|
|
1312
2714
|
if (module.reactorModule) {
|
|
1313
2715
|
new ReactorInstrumentation(module.reactorModule).start();
|
|
1314
2716
|
reactorLogger.info("Reactor metrics instrumentation started");
|
|
1315
2717
|
}
|
|
1316
|
-
|
|
2718
|
+
const reactorDriveSchemaDb = baseKysely.withSchema(REACTOR_SCHEMA);
|
|
2719
|
+
driveNodeView = new DriveNodeView(reactorDriveSchemaDb);
|
|
1317
2720
|
const reactorDriveClient = new ReactorDriveClient({
|
|
1318
2721
|
reactor: module.client,
|
|
1319
2722
|
readModel: driveNodeView
|
|
1320
2723
|
});
|
|
1321
2724
|
ownedReactorModule = module;
|
|
2725
|
+
if (options.fatalErrorShutdown) installFatalErrorShutdown(logger);
|
|
1322
2726
|
return {
|
|
1323
2727
|
module,
|
|
1324
2728
|
reactorDriveClient,
|
|
@@ -1352,7 +2756,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1352
2756
|
if (dev) {
|
|
1353
2757
|
const { VitePackageLoader, createViteLogger, startViteServer } = await import("@powerhousedao/reactor-api/vite");
|
|
1354
2758
|
vite = await startViteServer(process.cwd(), createViteLogger(logger));
|
|
1355
|
-
viteLoader = VitePackageLoader.build(vite);
|
|
2759
|
+
viteLoader = VitePackageLoader.build(vite, { resolveFrom: [import.meta.url] });
|
|
1356
2760
|
}
|
|
1357
2761
|
const vetraProcessorFactory = dev ? (await import("@powerhousedao/vetra/processors")).processorFactory : void 0;
|
|
1358
2762
|
if (!options.disableLocalPackages) packages.push(basePath);
|
|
@@ -1373,7 +2777,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1373
2777
|
pgliteFactory = PGLITE_IN_MEMORY ? () => new ReadModelPGlite({ parsers: PGLITE_UTC_PARSERS }) : (connectionString) => new ReadModelPGlite({
|
|
1374
2778
|
fs: new AtomicNodeFs(connectionString ?? readModelPgliteDir, {
|
|
1375
2779
|
logger,
|
|
1376
|
-
flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS
|
|
2780
|
+
flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS,
|
|
2781
|
+
onFlushError: onPgliteFlushError
|
|
1377
2782
|
}),
|
|
1378
2783
|
parsers: PGLITE_UTC_PARSERS
|
|
1379
2784
|
});
|
|
@@ -1401,7 +2806,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1401
2806
|
await abortBoot(api);
|
|
1402
2807
|
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
2808
|
}
|
|
1404
|
-
|
|
2809
|
+
localRenownRequest = (query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables);
|
|
2810
|
+
localCredentialCheck = createLocalCredentialVerifier(localRenownRequest, { onError: (error) => logger.error("Renown read model query failed: @error", error) });
|
|
1405
2811
|
logger.info("Renown credentials will be verified against this switchboard's own renown read model");
|
|
1406
2812
|
}
|
|
1407
2813
|
registerAttachmentRoutes(api);
|
|
@@ -1409,6 +2815,65 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1409
2815
|
if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
|
|
1410
2816
|
const { client, graphqlManager, documentModelRegistry } = api;
|
|
1411
2817
|
const lateSubgraphs = [];
|
|
2818
|
+
let workflows;
|
|
2819
|
+
if (workflowsEnabled) {
|
|
2820
|
+
workflows = await composeWorkflowRuntime({
|
|
2821
|
+
reactorClient: client,
|
|
2822
|
+
clientModule: options.reactor ?? ownedReactorModule,
|
|
2823
|
+
relationalDb: api.relationalDb,
|
|
2824
|
+
secretsKeyFile: readModelPgliteDir === null ? false : void 0,
|
|
2825
|
+
attachments: createAttachmentClient(api.attachments.service),
|
|
2826
|
+
webhooks: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME).webhooks,
|
|
2827
|
+
http: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME),
|
|
2828
|
+
authorizationService: api.authorizationService,
|
|
2829
|
+
pieces: api.packageManager,
|
|
2830
|
+
pieceRegistryUrl: registryUrl,
|
|
2831
|
+
logger: logger.child(["workflow-runtime"])
|
|
2832
|
+
});
|
|
2833
|
+
const WorkflowRuntimeSubgraph = workflows.subgraph;
|
|
2834
|
+
const workflowSubgraph = new WorkflowRuntimeSubgraph({
|
|
2835
|
+
reactorClient: client,
|
|
2836
|
+
http: graphqlManager.scopeForPackage(WORKFLOW_PACKAGE_NAME),
|
|
2837
|
+
relationalDb: api.relationalDb,
|
|
2838
|
+
analyticsStore: void 0,
|
|
2839
|
+
graphqlManager,
|
|
2840
|
+
syncManager: api.syncManager,
|
|
2841
|
+
authorizationService: graphqlManager.getAuthorizationService(),
|
|
2842
|
+
path: graphqlManager.getBasePath(),
|
|
2843
|
+
attachments: api.attachmentClientProvider
|
|
2844
|
+
});
|
|
2845
|
+
lateSubgraphs.push(graphqlManager.registerSubgraphInstance(workflowSubgraph, "graphql", false).catch((error) => {
|
|
2846
|
+
logger.error("Failed to register workflow-runtime subgraph: @error", error);
|
|
2847
|
+
}));
|
|
2848
|
+
await workflows.start();
|
|
2849
|
+
logger.info("Workflow runtime started");
|
|
2850
|
+
}
|
|
2851
|
+
let privacy;
|
|
2852
|
+
if (privacyConfig.enabled) {
|
|
2853
|
+
const signer = options.reactor ? options.privacy?.signer : options.privacy?.signer ?? renown?.signer;
|
|
2854
|
+
try {
|
|
2855
|
+
privacy = await startPrivacy({
|
|
2856
|
+
config: privacyConfig,
|
|
2857
|
+
reactorModule: (options.reactor ?? ownedReactorModule)?.reactorModule,
|
|
2858
|
+
signer,
|
|
2859
|
+
authorizationService: api.authorizationService,
|
|
2860
|
+
documentPermissionService: api.documentPermissionService,
|
|
2861
|
+
graphqlManager,
|
|
2862
|
+
reactorClient: client,
|
|
2863
|
+
logger: logger.child(["privacy"])
|
|
2864
|
+
});
|
|
2865
|
+
} catch (error) {
|
|
2866
|
+
await workflows?.stop();
|
|
2867
|
+
await abortBoot(api);
|
|
2868
|
+
throw error;
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
const shutdown = async () => {
|
|
2872
|
+
await privacy?.stop();
|
|
2873
|
+
await workflows?.stop();
|
|
2874
|
+
await api.dispose();
|
|
2875
|
+
};
|
|
2876
|
+
apiRef.current = { dispose: shutdown };
|
|
1412
2877
|
if (httpLoader) {
|
|
1413
2878
|
const packageManagementService = new PackageManagementService({
|
|
1414
2879
|
defaultRegistryUrl: registryUrl,
|
|
@@ -1430,7 +2895,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1430
2895
|
path: graphqlManager.getBasePath(),
|
|
1431
2896
|
authorizationService: graphqlManager.getAuthorizationService(),
|
|
1432
2897
|
packageManagementService,
|
|
1433
|
-
http: graphqlManager.scopeForPackage("@powerhousedao/switchboard")
|
|
2898
|
+
http: graphqlManager.scopeForPackage("@powerhousedao/switchboard"),
|
|
2899
|
+
attachments: api.attachmentClientProvider
|
|
1434
2900
|
});
|
|
1435
2901
|
lateSubgraphs.push(graphqlManager.registerSubgraphInstance(packagesSubgraph, "graphql", false).catch((error) => {
|
|
1436
2902
|
logger.error("Failed to register packages subgraph: @error", error);
|
|
@@ -1438,10 +2904,15 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1438
2904
|
}
|
|
1439
2905
|
if (driveNodeView) {
|
|
1440
2906
|
graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
|
|
2907
|
+
const authorizationService = graphqlManager.getAuthorizationService();
|
|
1441
2908
|
const reactorDriveSubgraph = {
|
|
1442
2909
|
name: "reactor-drive",
|
|
1443
2910
|
path: graphqlManager.getBasePath(),
|
|
1444
|
-
resolvers: createReactorDriveResolvers(
|
|
2911
|
+
resolvers: createReactorDriveResolvers({
|
|
2912
|
+
reactorClient: client,
|
|
2913
|
+
readModel: driveNodeView,
|
|
2914
|
+
hostCanRead: (documentId, address) => authorizationService.canRead(documentId, address)
|
|
2915
|
+
}),
|
|
1445
2916
|
typeDefs: reactorDriveSubgraphTypeDefs,
|
|
1446
2917
|
reactorClient: client,
|
|
1447
2918
|
relationalDb: void 0
|
|
@@ -1492,9 +2963,13 @@ async function initServer(serverPort, options, renown, renownConfig) {
|
|
|
1492
2963
|
reactor: client,
|
|
1493
2964
|
attachmentService,
|
|
1494
2965
|
attachmentReferenceProjection: api.attachmentReferenceProjection,
|
|
2966
|
+
workflowTriggers: workflows?.triggers,
|
|
2967
|
+
workflowsEnabled,
|
|
2968
|
+
privacy: privacy ? { erasure: privacy.erasure } : void 0,
|
|
2969
|
+
mcpEnabled: options.mcp !== false,
|
|
1495
2970
|
renown,
|
|
1496
2971
|
port: serverPort,
|
|
1497
|
-
shutdown
|
|
2972
|
+
shutdown
|
|
1498
2973
|
};
|
|
1499
2974
|
}
|
|
1500
2975
|
/**
|
|
@@ -1521,16 +2996,26 @@ const startSwitchboard = async (options = {}) => {
|
|
|
1521
2996
|
const featureFlags = await initFeatureFlags();
|
|
1522
2997
|
const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
|
|
1523
2998
|
options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
|
|
1524
|
-
|
|
2999
|
+
options.mcp = await resolveMcpEnabled({
|
|
3000
|
+
featureFlags,
|
|
3001
|
+
option: options.mcp
|
|
3002
|
+
});
|
|
3003
|
+
const configPathForFlags = resolveConfigPath(options.configFile);
|
|
3004
|
+
const workflowsEnabled = await resolveWorkflowsEnabled({
|
|
3005
|
+
featureFlags,
|
|
3006
|
+
override: options.workflows?.enabled,
|
|
3007
|
+
configEnabled: readConfigWorkflowsEnabled(configPathForFlags)
|
|
3008
|
+
});
|
|
3009
|
+
options.workflows = { enabled: workflowsEnabled };
|
|
1525
3010
|
const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
|
|
1526
3011
|
options.identity = {
|
|
1527
3012
|
...options.identity,
|
|
1528
|
-
requireSignatures,
|
|
1529
3013
|
baseUrl: options.identity?.baseUrl ?? renownConfig.url
|
|
1530
3014
|
};
|
|
1531
3015
|
logger.info("Feature flags: @flags", JSON.stringify({
|
|
1532
3016
|
DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
|
|
1533
|
-
|
|
3017
|
+
MCP_ENABLED: options.mcp,
|
|
3018
|
+
PH_WORKFLOWS_ENABLED: workflowsEnabled
|
|
1534
3019
|
}, null, 2));
|
|
1535
3020
|
let renown = null;
|
|
1536
3021
|
try {
|
|
@@ -1543,13 +3028,14 @@ const startSwitchboard = async (options = {}) => {
|
|
|
1543
3028
|
return await initServer(serverPort, options, renown, renownConfig);
|
|
1544
3029
|
} catch (e) {
|
|
1545
3030
|
Sentry.captureException(e);
|
|
1546
|
-
logger.error(
|
|
3031
|
+
if (StoredDocumentsRefusedError.isError(e)) logger.error(e.message);
|
|
3032
|
+
else logger.error("App crashed: @error", e);
|
|
1547
3033
|
throw e;
|
|
1548
3034
|
}
|
|
1549
3035
|
};
|
|
1550
|
-
if (import.meta.main) await startSwitchboard();
|
|
3036
|
+
if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
|
|
1551
3037
|
//#endregion
|
|
1552
|
-
export {
|
|
3038
|
+
export { resolveUnsupportedStoredDocuments as a, StoredDocumentsRefusedError as i, isPortAvailable as n, applySwitchboardReactorDefaults as o, startSwitchboard as r, parseForcePgVersion as s, deriveAttachmentServiceConfig as t };
|
|
1553
3039
|
|
|
1554
|
-
//# sourceMappingURL=server-
|
|
1555
|
-
//# debugId=
|
|
3040
|
+
//# sourceMappingURL=server-D3CYuEka.mjs.map
|
|
3041
|
+
//# debugId=98e9dab8-b145-5eb1-8457-7a9d38dda98e
|