@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.50
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/durable-node-fs-pg16.test.d.ts +2 -0
- package/.tsbuild/test/durable-node-fs-pg16.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/observability-signals.test.d.ts +2 -0
- package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
- package/.tsbuild/test/pglite-preflight.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/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.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-reactor-access.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-enforcement.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 +509 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +48 -49
- 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-Zz7SqTwO.mjs +3335 -0
- package/dist/server-Zz7SqTwO.mjs.map +1 -0
- package/dist/server.d.mts +154 -30
- 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 -18
- 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/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +165 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +159 -2
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/snapshot-writer.ts +94 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +322 -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-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +805 -0
- package/test/workflow-read-gate.test.ts +210 -0
- package/test/workflow-runtime.test.ts +572 -0
- package/tsconfig.json +9 -3
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs +0 -1555
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -0,0 +1,3335 @@
|
|
|
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]="12676ab9-2bd3-55f1-9a41-559509adace0")}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
|
+
import { metrics, trace } from "@opentelemetry/api";
|
|
6
|
+
import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
|
|
7
|
+
import * as Sentry from "@sentry/node";
|
|
8
|
+
import { childLogger, documentModelDocumentModelModule, setLogLevel } from "document-model";
|
|
9
|
+
import dotenv from "dotenv";
|
|
10
|
+
import { getConfig } from "@powerhousedao/config/node";
|
|
11
|
+
import { existsSync, promises, realpathSync } from "node:fs";
|
|
12
|
+
import path, { resolve } from "node:path";
|
|
13
|
+
import { createDurableNodeFs, preparePgliteDataDir, removeStalePgliteFiles, resolvePgliteFsync, syncTree } from "@powerhousedao/reactor-api/pglite-node";
|
|
14
|
+
import { ChannelScheme, DriveCollectionId, EventBus, HardenedPGliteDialect, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, UnsupportedStoredProtocolError, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
|
|
15
|
+
import { AuthorizationPolicy, BaseSubgraph, ForbiddenError, HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, PrivacyPermissionAdapter, callerSubject, createCanonicalDocumentIdResolver, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
|
|
16
|
+
import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
|
|
17
|
+
import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentMetadata, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget, parseRef, validateReserveMetadata } from "@powerhousedao/reactor-attachments";
|
|
18
|
+
import { createAttachmentClient } from "@powerhousedao/reactor-attachments/client";
|
|
19
|
+
import { DriveNodeView, NodeProcessor, ReactorDriveClient, createReactorDriveResolvers, reactorDriveDocumentModelModule, reactorDriveSubgraphTypeDefs } from "@powerhousedao/reactor-drive";
|
|
20
|
+
import { DEFAULT_KEYPAIR_PATH, DEFAULT_RENOWN_URL, NodeKeyStorage, RENOWN_READ_MODEL_SUBGRAPH, RenownBuilder, RenownCryptoBuilder, createLocalCredentialVerifier, createRenownTrustPolicy, resolveSwitchboardEndpoint } from "@renown/sdk/node";
|
|
21
|
+
import { Kysely, PostgresDialect } from "kysely";
|
|
22
|
+
import net from "node:net";
|
|
23
|
+
import path$1 from "path";
|
|
24
|
+
import { Pool } from "pg";
|
|
25
|
+
import { Readable } from "node:stream";
|
|
26
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
27
|
+
import { DisclosureService, MIN_DEPLOYMENT_SECRET_BYTES, createModuleErasure, createPrivacySubgraph, registerSubjectDocumentsReadModel } from "@powerhousedao/reactor-privacy";
|
|
28
|
+
import { ReactorGroupV1 } from "@powerhousedao/reactor-group";
|
|
29
|
+
import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
|
|
30
|
+
import { DEFAULT_SIGNATURE_POLICY, isSignaturePolicy } from "@powerhousedao/shared/document-model";
|
|
31
|
+
import { readFile } from "node:fs/promises";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
34
|
+
import { EnvVarProvider } from "@openfeature/env-var-provider";
|
|
35
|
+
import { OpenFeature } from "@openfeature/server-sdk";
|
|
36
|
+
import { GraphQLError, Kind } from "graphql";
|
|
37
|
+
import { gql } from "graphql-tag";
|
|
38
|
+
//#region src/pglite-version.ts
|
|
39
|
+
const SUPPORTED_PG_MAJORS = [16, 17];
|
|
40
|
+
async function readPgVersionFile(dataDir) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await promises.readFile(path.join(dataDir, "PG_VERSION"), "utf8");
|
|
43
|
+
const major = parseInt(raw.trim(), 10);
|
|
44
|
+
return Number.isFinite(major) ? major : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function isSupportedMajor(major) {
|
|
50
|
+
return SUPPORTED_PG_MAJORS.includes(major);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parses the `PH_FORCE_PG_VERSION` env var. Returns the validated major, or
|
|
54
|
+
* `null` when the var is unset/empty. Throws on any value that is not a
|
|
55
|
+
* supported major — invalid configuration must fail before the server starts
|
|
56
|
+
* touching disk.
|
|
57
|
+
*/
|
|
58
|
+
function parseForcePgVersion(raw) {
|
|
59
|
+
if (raw === void 0 || raw.trim() === "") return null;
|
|
60
|
+
const parsed = Number(raw);
|
|
61
|
+
if (Number.isInteger(parsed) && isSupportedMajor(parsed)) return parsed;
|
|
62
|
+
throw new Error(`PH_FORCE_PG_VERSION must be one of: ${SUPPORTED_PG_MAJORS.join(", ")} (got: ${raw})`);
|
|
63
|
+
}
|
|
64
|
+
async function loadPGliteModule(major) {
|
|
65
|
+
if (major === 16) return await import("pglite-legacy-02");
|
|
66
|
+
return import("@electric-sql/pglite");
|
|
67
|
+
}
|
|
68
|
+
/** The stock NodeFS class of the PGlite module that opens `major` dirs. */
|
|
69
|
+
async function loadNodeFsClass(major) {
|
|
70
|
+
if (major === 16) return (await import("pglite-legacy-02/nodefs")).NodeFS;
|
|
71
|
+
return (await import("@electric-sql/pglite/nodefs")).NodeFS;
|
|
72
|
+
}
|
|
73
|
+
/** Opens a converted dir over stock NodeFS so the conversion can check it. */
|
|
74
|
+
async function openForVerify(major, dataDir) {
|
|
75
|
+
if (!isSupportedMajor(major)) throw new Error(`Cannot verify PGlite data dir ${dataDir}: PG_VERSION=${major} is not supported (expected one of ${SUPPORTED_PG_MAJORS.join(", ")})`);
|
|
76
|
+
const [{ PGlite }, NodeFS] = await Promise.all([loadPGliteModule(major), loadNodeFsClass(major)]);
|
|
77
|
+
const pg = new PGlite({ fs: new NodeFS(dataDir) });
|
|
78
|
+
await pg.waitReady;
|
|
79
|
+
return pg;
|
|
80
|
+
}
|
|
81
|
+
async function loadPgDump(major) {
|
|
82
|
+
if (major === 16) return (await import("pglite-tools-legacy-02/pg_dump")).pgDump;
|
|
83
|
+
return (await import("@electric-sql/pglite-tools/pg_dump")).pgDump;
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/profiler.ts
|
|
87
|
+
let started = false;
|
|
88
|
+
async function initProfilerFromEnv(env) {
|
|
89
|
+
if (started || !env.PYROSCOPE_SERVER_ADDRESS) return;
|
|
90
|
+
started = true;
|
|
91
|
+
const { PYROSCOPE_SERVER_ADDRESS: serverAddress, PYROSCOPE_APPLICATION_NAME: appName, PYROSCOPE_USER: basicAuthUser, PYROSCOPE_PASSWORD: basicAuthPassword, PYROSCOPE_WALL_ENABLED: wallEnabled, PYROSCOPE_HEAP_ENABLED: heapEnabled } = env;
|
|
92
|
+
return initProfiler({
|
|
93
|
+
serverAddress,
|
|
94
|
+
appName,
|
|
95
|
+
basicAuthUser,
|
|
96
|
+
basicAuthPassword,
|
|
97
|
+
wall: {
|
|
98
|
+
samplingDurationMs: 1e4,
|
|
99
|
+
samplingIntervalMicros: 1e4,
|
|
100
|
+
collectCpuTime: true
|
|
101
|
+
},
|
|
102
|
+
heap: {
|
|
103
|
+
samplingIntervalBytes: 524288,
|
|
104
|
+
stackDepth: 64
|
|
105
|
+
}
|
|
106
|
+
}, {
|
|
107
|
+
wallEnabled: wallEnabled !== "false",
|
|
108
|
+
heapEnabled: heapEnabled === "true"
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function initProfiler(options, flags = {
|
|
112
|
+
wallEnabled: true,
|
|
113
|
+
heapEnabled: false
|
|
114
|
+
}) {
|
|
115
|
+
console.log("Initializing Pyroscope profiler at:", options?.serverAddress);
|
|
116
|
+
console.log(" Wall profiling:", flags.wallEnabled ? "enabled" : "disabled");
|
|
117
|
+
console.log(" Heap profiling:", flags.heapEnabled ? "enabled" : "disabled");
|
|
118
|
+
const { default: Pyroscope } = await import("@pyroscope/nodejs");
|
|
119
|
+
Pyroscope.init(options);
|
|
120
|
+
if (flags.wallEnabled) Pyroscope.startWallProfiling();
|
|
121
|
+
Pyroscope.startCpuProfiling();
|
|
122
|
+
if (flags.heapEnabled) Pyroscope.startHeapProfiling();
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/attachments/auth.ts
|
|
126
|
+
const ANONYMOUS_ACTOR = {
|
|
127
|
+
user: void 0,
|
|
128
|
+
authEnabled: false
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Wrap a Node-style handler so that, when `authService` is provided and auth is
|
|
132
|
+
* enabled, the request must carry a verifiable Bearer token. The handler always
|
|
133
|
+
* receives an actor context: the verified bearer user when auth is enabled, or
|
|
134
|
+
* the anonymous context when it is disabled. With `allowAnonymous`, a missing
|
|
135
|
+
* bearer yields an anonymous actor with `authEnabled: true` instead of a 401 —
|
|
136
|
+
* unless `requireAuthenticatedCaller` says the deployment refuses anonymous
|
|
137
|
+
* callers outright, which overrides it.
|
|
138
|
+
*
|
|
139
|
+
* With no `authService` there is no bearer to read and every caller is
|
|
140
|
+
* anonymous, so nothing is refused here. That combination cannot carry the
|
|
141
|
+
* floor: `assertRequireAuthenticatedCallerAllowed` refuses to boot with
|
|
142
|
+
* `REQUIRE_AUTHENTICATED_CALLER` set and no identity resolution, and identity
|
|
143
|
+
* resolution is what builds the service.
|
|
144
|
+
*/
|
|
145
|
+
function requireAuth(authService, handler, options) {
|
|
146
|
+
if (!authService) return (req, res, body) => handler(req, res, body, ANONYMOUS_ACTOR);
|
|
147
|
+
return async (req, res, body) => {
|
|
148
|
+
let result;
|
|
149
|
+
try {
|
|
150
|
+
result = await authService.verifyBearer(req.headers.authorization);
|
|
151
|
+
} catch {
|
|
152
|
+
res.statusCode = 500;
|
|
153
|
+
res.setHeader("Content-Type", "application/json");
|
|
154
|
+
res.end(JSON.stringify({ error: "Internal authentication error" }));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (result instanceof Response) {
|
|
158
|
+
const body = await result.text();
|
|
159
|
+
res.statusCode = result.status;
|
|
160
|
+
const contentType = result.headers.get("content-type");
|
|
161
|
+
if (contentType) res.setHeader("Content-Type", contentType);
|
|
162
|
+
res.end(body);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!result.user) {
|
|
166
|
+
if (options?.requireAuthenticatedCaller === true || result.auth_enabled && !options?.allowAnonymous) {
|
|
167
|
+
res.statusCode = 401;
|
|
168
|
+
res.setHeader("Content-Type", "application/json");
|
|
169
|
+
res.end(JSON.stringify({ error: "Authentication required" }));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await handler(req, res, body, {
|
|
174
|
+
user: result.user,
|
|
175
|
+
authEnabled: result.auth_enabled
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/attachments/mount-auth.ts
|
|
181
|
+
/**
|
|
182
|
+
* Mount a Node-style attachment route with `requireAuth` applied unconditionally.
|
|
183
|
+
* When `api.authService` is undefined (auth disabled), the handler still runs
|
|
184
|
+
* through `requireAuth` and receives the anonymous actor context — that is the
|
|
185
|
+
* only way to opt out of bearer verification. To register a route without auth
|
|
186
|
+
* wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
|
|
187
|
+
*
|
|
188
|
+
* `allowAnonymous` is reserved for routes whose handlers make a per-document
|
|
189
|
+
* authorization decision themselves; identity-only routes must not use it. It
|
|
190
|
+
* is also not the last word: a deployment that refuses anonymous callers
|
|
191
|
+
* (`REQUIRE_AUTHENTICATED_CALLER`) refuses them here too, because these routes
|
|
192
|
+
* are mounted on the HTTP adapter and never reach the fetch chain that carries
|
|
193
|
+
* that floor for GraphQL. The flag is read from the API, so every route
|
|
194
|
+
* mounted through here inherits it without each one remembering to.
|
|
195
|
+
*/
|
|
196
|
+
function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
|
|
197
|
+
api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, {
|
|
198
|
+
...options,
|
|
199
|
+
requireAuthenticatedCaller: api.requireAuthenticatedCaller
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/attachments/routes.ts
|
|
204
|
+
const logger$2 = childLogger(["switchboard", "attachments"]);
|
|
205
|
+
const RETRY_AFTER_SECONDS = 5;
|
|
206
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/i;
|
|
207
|
+
function sendJson(res, status, body) {
|
|
208
|
+
res.statusCode = status;
|
|
209
|
+
res.setHeader("Content-Type", "application/json");
|
|
210
|
+
res.end(JSON.stringify(body));
|
|
211
|
+
}
|
|
212
|
+
function sendError(res, status, message) {
|
|
213
|
+
sendJson(res, status, { error: message });
|
|
214
|
+
}
|
|
215
|
+
function statusForError(err) {
|
|
216
|
+
if (err instanceof AttachmentNotFound) return 404;
|
|
217
|
+
if (err instanceof ReservationNotFound) return 404;
|
|
218
|
+
if (err instanceof InvalidAttachmentRef) return 400;
|
|
219
|
+
if (err instanceof UploadTooLarge) return 413;
|
|
220
|
+
return 500;
|
|
221
|
+
}
|
|
222
|
+
function sendErrorFromException(res, err) {
|
|
223
|
+
const status = statusForError(err);
|
|
224
|
+
if (status >= 500) {
|
|
225
|
+
logger$2.error("Attachment route error: @error", err);
|
|
226
|
+
sendError(res, status, "Internal error");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
sendError(res, status, err instanceof Error ? err.message : String(err));
|
|
230
|
+
}
|
|
231
|
+
async function readJsonBody(req, body) {
|
|
232
|
+
if (body !== void 0 && body !== null && typeof body === "object") return body;
|
|
233
|
+
const chunks = [];
|
|
234
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
235
|
+
if (chunks.length === 0) return void 0;
|
|
236
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
237
|
+
if (text.length === 0) return void 0;
|
|
238
|
+
return JSON.parse(text);
|
|
239
|
+
}
|
|
240
|
+
function parseReserveOptions(input) {
|
|
241
|
+
if (input === null || typeof input !== "object") return null;
|
|
242
|
+
const obj = input;
|
|
243
|
+
const { mimeType, fileName } = obj;
|
|
244
|
+
const extension = obj.extension ?? null;
|
|
245
|
+
try {
|
|
246
|
+
validateReserveMetadata({
|
|
247
|
+
mimeType,
|
|
248
|
+
fileName,
|
|
249
|
+
extension
|
|
250
|
+
});
|
|
251
|
+
} catch (err) {
|
|
252
|
+
if (err instanceof InvalidAttachmentMetadata) return null;
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
if (typeof mimeType !== "string" || typeof fileName !== "string") return null;
|
|
256
|
+
if (extension !== null && typeof extension !== "string") return null;
|
|
257
|
+
if (obj.clientHash !== void 0) {
|
|
258
|
+
if (typeof obj.clientHash !== "string" || !HASH_PATTERN.test(obj.clientHash)) return null;
|
|
259
|
+
if (typeof obj.sizeBytes !== "number" || !Number.isInteger(obj.sizeBytes) || obj.sizeBytes <= 0 || !Number.isSafeInteger(obj.sizeBytes)) return null;
|
|
260
|
+
return {
|
|
261
|
+
mimeType,
|
|
262
|
+
fileName,
|
|
263
|
+
extension,
|
|
264
|
+
clientHash: obj.clientHash.toLowerCase(),
|
|
265
|
+
sizeBytes: obj.sizeBytes
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
mimeType,
|
|
270
|
+
fileName,
|
|
271
|
+
extension
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function quoteFilename(name) {
|
|
275
|
+
return `"${name.replace(/[\\"]/g, "\\$&")}"`;
|
|
276
|
+
}
|
|
277
|
+
function buildContentDisposition(fileName) {
|
|
278
|
+
const ascii = fileName.replace(/[^\x20-\x21\x23-\x5b\x5d-\x7e]/g, "_");
|
|
279
|
+
const encoded = encodeURIComponent(fileName).replace(/['()*!]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
280
|
+
return `attachment; filename=${quoteFilename(ascii)}; filename*=UTF-8''${encoded}`;
|
|
281
|
+
}
|
|
282
|
+
function makeReserveHandler(attachments) {
|
|
283
|
+
return async (req, res, body) => {
|
|
284
|
+
let parsed;
|
|
285
|
+
try {
|
|
286
|
+
parsed = await readJsonBody(req, body);
|
|
287
|
+
} catch {
|
|
288
|
+
sendError(res, 400, "Invalid JSON body");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const opts = parseReserveOptions(parsed);
|
|
292
|
+
if (!opts) {
|
|
293
|
+
sendError(res, 400, "Body must be { mimeType: string (type/subtype), fileName: string (no control characters, max 255 chars), extension?: string|null, clientHash?: string (64 hex chars), sizeBytes?: number (required with clientHash) }");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
let upload;
|
|
297
|
+
try {
|
|
298
|
+
upload = await attachments.service.reserve(opts);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (err instanceof AttachmentAlreadyExists) {
|
|
301
|
+
sendJson(res, 409, {
|
|
302
|
+
error: "already_exists",
|
|
303
|
+
ref: err.ref
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
sendErrorFromException(res, err);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
sendJson(res, 201, {
|
|
311
|
+
reservationId: upload.reservationId,
|
|
312
|
+
ref: upload.ref,
|
|
313
|
+
expiresAtUtc: upload.expiresAtUtc,
|
|
314
|
+
...upload.uploadTarget ? { uploadTarget: upload.uploadTarget } : {}
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function makeUploadHandler(attachments) {
|
|
319
|
+
return async (req, res) => {
|
|
320
|
+
const reservationId = extractParam(req, "reservationId");
|
|
321
|
+
if (!reservationId) {
|
|
322
|
+
sendError(res, 400, "Missing reservationId");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (attachments.backend?.kind === "s3") {
|
|
326
|
+
sendError(res, 405, "Use the reservation uploadTarget for S3 uploads");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
let reservation;
|
|
330
|
+
try {
|
|
331
|
+
reservation = await attachments.reservations.get(reservationId);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
sendErrorFromException(res, err);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const upload = attachments.uploadFactory.createUpload(reservation);
|
|
337
|
+
const webStream = Readable.toWeb(req);
|
|
338
|
+
try {
|
|
339
|
+
sendJson(res, 200, await upload.send(webStream));
|
|
340
|
+
} catch (err) {
|
|
341
|
+
if (err instanceof HashMismatch) {
|
|
342
|
+
sendJson(res, 422, {
|
|
343
|
+
error: "hash_mismatch",
|
|
344
|
+
claimed: err.claimed,
|
|
345
|
+
actual: err.actual
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (err instanceof SizeMismatch) {
|
|
350
|
+
sendJson(res, 422, {
|
|
351
|
+
error: "size_mismatch",
|
|
352
|
+
declared: err.declared,
|
|
353
|
+
actual: err.actual
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
sendErrorFromException(res, err);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function makeDownloadHandler(attachments, attachmentAccess, urlSigner) {
|
|
362
|
+
return async (req, res, _body, actor) => {
|
|
363
|
+
const hash = extractParam(req, "hash");
|
|
364
|
+
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
365
|
+
sendError(res, 400, "Invalid attachment hash");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const canonicalHash = hash.toLowerCase();
|
|
369
|
+
const grant = await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner);
|
|
370
|
+
if (!admitGrant(res, grant)) return;
|
|
371
|
+
const controller = new AbortController();
|
|
372
|
+
req.once("close", () => controller.abort());
|
|
373
|
+
let response;
|
|
374
|
+
try {
|
|
375
|
+
response = await attachments.store.get(canonicalHash, controller.signal, grant.documentId);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (err instanceof AttachmentPending) {
|
|
378
|
+
res.statusCode = 202;
|
|
379
|
+
res.setHeader("Cache-Control", "private");
|
|
380
|
+
res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
|
|
381
|
+
res.setHeader("Attachment-Pending", buildPendingHeader({
|
|
382
|
+
expiresAtUtc: err.expiresAtUtc,
|
|
383
|
+
...err.metadata ?? {}
|
|
384
|
+
}));
|
|
385
|
+
res.end();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
sendErrorFromException(res, err);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const { header, body } = response;
|
|
392
|
+
res.statusCode = 200;
|
|
393
|
+
res.setHeader("Cache-Control", "private");
|
|
394
|
+
res.setHeader("Content-Type", header.mimeType);
|
|
395
|
+
res.setHeader("Content-Length", String(header.sizeBytes));
|
|
396
|
+
res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
|
|
397
|
+
res.setHeader("Attachment-Metadata", buildMetadataHeader(header));
|
|
398
|
+
Readable.fromWeb(body).pipe(res);
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function toAsciiJson(value) {
|
|
402
|
+
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
403
|
+
}
|
|
404
|
+
function buildPendingHeader(pending) {
|
|
405
|
+
return toAsciiJson(pending);
|
|
406
|
+
}
|
|
407
|
+
function buildMetadataHeader(header) {
|
|
408
|
+
return toAsciiJson({
|
|
409
|
+
mimeType: header.mimeType,
|
|
410
|
+
fileName: header.fileName,
|
|
411
|
+
sizeBytes: header.sizeBytes,
|
|
412
|
+
extension: header.extension,
|
|
413
|
+
createdAtUtc: header.createdAtUtc,
|
|
414
|
+
lastAccessedAtUtc: header.lastAccessedAtUtc
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function makeStatHandler(attachments, attachmentAccess, urlSigner) {
|
|
418
|
+
return async (req, res, _body, actor) => {
|
|
419
|
+
const hash = extractParam(req, "hash");
|
|
420
|
+
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
421
|
+
sendError(res, 400, "Invalid attachment hash");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const canonicalHash = hash.toLowerCase();
|
|
425
|
+
if (!admitGrant(res, await resolveByteGrant(req, canonicalHash, actor, attachmentAccess, urlSigner))) return;
|
|
426
|
+
let header;
|
|
427
|
+
try {
|
|
428
|
+
header = await attachments.store.stat(canonicalHash);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
sendErrorFromException(res, err);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
res.setHeader("Cache-Control", "private");
|
|
434
|
+
if (header.status === "pending") {
|
|
435
|
+
res.statusCode = 202;
|
|
436
|
+
res.setHeader("Retry-After", String(RETRY_AFTER_SECONDS));
|
|
437
|
+
res.setHeader("Attachment-Pending", buildPendingHeader({
|
|
438
|
+
expiresAtUtc: header.expiresAtUtc,
|
|
439
|
+
mimeType: header.mimeType,
|
|
440
|
+
fileName: header.fileName,
|
|
441
|
+
sizeBytes: header.sizeBytes
|
|
442
|
+
}));
|
|
443
|
+
res.end();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
res.statusCode = 200;
|
|
447
|
+
res.setHeader("Content-Type", header.mimeType);
|
|
448
|
+
res.setHeader("Content-Length", String(header.sizeBytes));
|
|
449
|
+
res.setHeader("Content-Disposition", buildContentDisposition(header.fileName));
|
|
450
|
+
res.setHeader("Attachment-Metadata", buildMetadataHeader(header));
|
|
451
|
+
res.end();
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function makeGetReservationHandler(attachments) {
|
|
455
|
+
return async (req, res) => {
|
|
456
|
+
const reservationId = extractParam(req, "reservationId");
|
|
457
|
+
if (!reservationId) {
|
|
458
|
+
sendError(res, 400, "Missing reservationId");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
sendJson(res, 200, await attachments.reservations.get(reservationId));
|
|
463
|
+
} catch (err) {
|
|
464
|
+
sendErrorFromException(res, err);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function makeDeleteReservationHandler(attachments) {
|
|
469
|
+
return async (req, res) => {
|
|
470
|
+
const reservationId = extractParam(req, "reservationId");
|
|
471
|
+
if (!reservationId) {
|
|
472
|
+
sendError(res, 400, "Missing reservationId");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
await attachments.reservations.delete(reservationId);
|
|
477
|
+
res.statusCode = 204;
|
|
478
|
+
res.end();
|
|
479
|
+
} catch (err) {
|
|
480
|
+
sendErrorFromException(res, err);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function extractParam(req, name) {
|
|
485
|
+
return req.params?.[name];
|
|
486
|
+
}
|
|
487
|
+
const MAX_DOCUMENT_ID_LEN = 512;
|
|
488
|
+
const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
|
|
489
|
+
const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 604800;
|
|
490
|
+
/**
|
|
491
|
+
* How long a download target may live, by default.
|
|
492
|
+
*
|
|
493
|
+
* The authorization behind a presigned URL is decided once, when it is issued,
|
|
494
|
+
* and the URL keeps working until it expires however the document's policy
|
|
495
|
+
* changes in between. A week of that is a revocation that does not revoke: an
|
|
496
|
+
* account deactivated, a company reassigned or a group left, and the bytes stay
|
|
497
|
+
* fetchable by anyone holding the link. Minutes are enough to open a file;
|
|
498
|
+
* days are only enough to lose control of it.
|
|
499
|
+
*
|
|
500
|
+
* A deployment that needs longer sets ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS,
|
|
501
|
+
* still bounded by the signing ceiling above.
|
|
502
|
+
*/
|
|
503
|
+
const DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS = 300;
|
|
504
|
+
/**
|
|
505
|
+
* The ceiling in force, read per call so a deployment can set it without the
|
|
506
|
+
* value being frozen at import — which is also what lets a test cover it.
|
|
507
|
+
* Anything unparseable falls back to the default rather than to the maximum:
|
|
508
|
+
* a typo must not quietly widen the window it was meant to narrow.
|
|
509
|
+
*/
|
|
510
|
+
function downloadTargetTtlCapSeconds() {
|
|
511
|
+
const configured = process.env.ATTACHMENT_DOWNLOAD_TARGET_MAX_TTL_SECONDS;
|
|
512
|
+
if (configured === void 0) return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
|
|
513
|
+
const parsed = Number(configured);
|
|
514
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
515
|
+
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}`);
|
|
516
|
+
return DEFAULT_DOWNLOAD_TARGET_TTL_SECONDS;
|
|
517
|
+
}
|
|
518
|
+
return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Returns the single `documentId` query value, or null when it is missing,
|
|
522
|
+
* duplicated, blank, or oversized. Validation happens before authorization so
|
|
523
|
+
* malformed requests never reach the access service.
|
|
524
|
+
*/
|
|
525
|
+
function extractSingleDocumentId(req) {
|
|
526
|
+
if (!req.url) return null;
|
|
527
|
+
let url;
|
|
528
|
+
try {
|
|
529
|
+
url = new URL(req.url, "http://switchboard.invalid");
|
|
530
|
+
} catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
const values = url.searchParams.getAll("documentId");
|
|
534
|
+
if (values.length !== 1) return null;
|
|
535
|
+
const value = values[0];
|
|
536
|
+
if (value.trim().length === 0 || value.length > MAX_DOCUMENT_ID_LEN) return null;
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Returns the target TTL in seconds, or "invalid" when the caller's own value
|
|
541
|
+
* is malformed (duplicated, non-integer, or non-positive).
|
|
542
|
+
*
|
|
543
|
+
* The cap applies whether or not the caller asked for a lifetime: omitting
|
|
544
|
+
* `expiresIn` used to fall through to whatever the storage backend was
|
|
545
|
+
* configured with, which would make the ceiling something a client opts out of
|
|
546
|
+
* by saying nothing.
|
|
547
|
+
*/
|
|
548
|
+
function extractExpiresIn(req) {
|
|
549
|
+
const cap = downloadTargetTtlCapSeconds();
|
|
550
|
+
if (!req.url) return cap;
|
|
551
|
+
let url;
|
|
552
|
+
try {
|
|
553
|
+
url = new URL(req.url, "http://switchboard.invalid");
|
|
554
|
+
} catch {
|
|
555
|
+
return cap;
|
|
556
|
+
}
|
|
557
|
+
const values = url.searchParams.getAll("expiresIn");
|
|
558
|
+
if (values.length === 0) return cap;
|
|
559
|
+
if (values.length > 1) return "invalid";
|
|
560
|
+
const parsed = Number(values[0]);
|
|
561
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
|
|
562
|
+
return Math.min(parsed, cap);
|
|
563
|
+
}
|
|
564
|
+
function singleQueryValue(params, name) {
|
|
565
|
+
const values = params.getAll(name);
|
|
566
|
+
return values.length === 1 ? values[0] : null;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Byte routes serve only under a grant: a signed URL minted by the
|
|
570
|
+
* download-target route, or a documentId the caller may read the attachment
|
|
571
|
+
* through. A signed request is judged by its signature alone.
|
|
572
|
+
*/
|
|
573
|
+
async function resolveByteGrant(req, hash, actor, attachmentAccess, urlSigner) {
|
|
574
|
+
let params;
|
|
575
|
+
try {
|
|
576
|
+
params = new URL(req.url ?? "", "http://switchboard.invalid").searchParams;
|
|
577
|
+
} catch {
|
|
578
|
+
return { kind: "denied" };
|
|
579
|
+
}
|
|
580
|
+
const documentId = extractSingleDocumentId(req);
|
|
581
|
+
if (documentId === null) return { kind: "denied" };
|
|
582
|
+
if (params.has("signature") || params.has("expires")) {
|
|
583
|
+
const signature = singleQueryValue(params, "signature");
|
|
584
|
+
const expires = singleQueryValue(params, "expires");
|
|
585
|
+
if (!urlSigner || signature === null || expires === null) return { kind: "denied" };
|
|
586
|
+
return urlSigner.verify(hash, documentId, expires, signature) ? {
|
|
587
|
+
kind: "granted",
|
|
588
|
+
documentId
|
|
589
|
+
} : { kind: "denied" };
|
|
590
|
+
}
|
|
591
|
+
let decision;
|
|
592
|
+
try {
|
|
593
|
+
decision = await attachmentAccess.canReadAttachment({
|
|
594
|
+
documentId,
|
|
595
|
+
attachmentRef: createRef(hash),
|
|
596
|
+
userAddress: actor?.user?.address,
|
|
597
|
+
appKey: actor?.user?.appKey
|
|
598
|
+
});
|
|
599
|
+
} catch (err) {
|
|
600
|
+
logger$2.error("Attachment access decision failed: @error", err);
|
|
601
|
+
return { kind: "error" };
|
|
602
|
+
}
|
|
603
|
+
if (decision.kind === "projection-unavailable") return { kind: "unavailable" };
|
|
604
|
+
if (decision.kind === "denied") return { kind: "denied" };
|
|
605
|
+
return {
|
|
606
|
+
kind: "granted",
|
|
607
|
+
documentId: decision.documentId
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/** Answers a refused grant; true when the request may proceed. */
|
|
611
|
+
function admitGrant(res, grant) {
|
|
612
|
+
switch (grant.kind) {
|
|
613
|
+
case "granted": return true;
|
|
614
|
+
case "denied":
|
|
615
|
+
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
616
|
+
return false;
|
|
617
|
+
case "unavailable":
|
|
618
|
+
sendError(res, 503, "Attachment downloads are temporarily unavailable");
|
|
619
|
+
return false;
|
|
620
|
+
case "error":
|
|
621
|
+
sendError(res, 500, "Internal error");
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Base URL of this Switchboard as seen by the caller, used to build
|
|
627
|
+
* filesystem `switchboard` download targets that point back at the signed
|
|
628
|
+
* byte route.
|
|
629
|
+
*/
|
|
630
|
+
function requestBaseUrl(req) {
|
|
631
|
+
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
632
|
+
const proto = (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0]?.trim() || (req.socket.encrypted ? "https" : "http");
|
|
633
|
+
const forwardedHost = req.headers["x-forwarded-host"];
|
|
634
|
+
const host = (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? req.headers.host;
|
|
635
|
+
if (!host) return null;
|
|
636
|
+
return `${proto}://${host}`;
|
|
637
|
+
}
|
|
638
|
+
function makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner) {
|
|
639
|
+
return async (req, res, _body, actor) => {
|
|
640
|
+
res.setHeader("Cache-Control", "no-store");
|
|
641
|
+
const hash = extractParam(req, "hash");
|
|
642
|
+
if (!hash || !HASH_PATTERN.test(hash)) {
|
|
643
|
+
sendError(res, 400, "Invalid attachment hash");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const documentId = extractSingleDocumentId(req);
|
|
647
|
+
if (documentId === null) {
|
|
648
|
+
sendError(res, 400, "documentId is required exactly once as a non-empty query parameter");
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const expiresIn = extractExpiresIn(req);
|
|
652
|
+
if (expiresIn === "invalid") {
|
|
653
|
+
sendError(res, 400, "expiresIn must be a single positive integer number of seconds");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const canonicalHash = hash.toLowerCase();
|
|
657
|
+
let decision;
|
|
658
|
+
try {
|
|
659
|
+
decision = await attachmentAccess.canReadAttachment({
|
|
660
|
+
documentId,
|
|
661
|
+
attachmentRef: createRef(canonicalHash),
|
|
662
|
+
userAddress: actor?.user?.address,
|
|
663
|
+
appKey: actor?.user?.appKey
|
|
664
|
+
});
|
|
665
|
+
} catch (err) {
|
|
666
|
+
logger$2.error("Attachment access decision failed: @error", err);
|
|
667
|
+
sendError(res, 500, "Internal error");
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (decision.kind === "projection-unavailable") {
|
|
671
|
+
sendError(res, 503, "Attachment downloads are temporarily unavailable");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (decision.kind === "denied") {
|
|
675
|
+
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
let header;
|
|
679
|
+
try {
|
|
680
|
+
header = await attachments.store.stat(canonicalHash);
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if (err instanceof AttachmentNotFound) {
|
|
683
|
+
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
logger$2.error("Attachment metadata lookup failed: @error", err);
|
|
687
|
+
sendError(res, 500, "Internal error");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (header.status !== "available") {
|
|
691
|
+
sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
let target;
|
|
695
|
+
if (attachments.backend && attachments.backend.kind !== "filesystem") try {
|
|
696
|
+
target = await attachments.backend.prepareDownloadTarget(canonicalHash, expiresIn);
|
|
697
|
+
} catch {
|
|
698
|
+
sendError(res, 502, "Attachment download target unavailable");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
if (!urlSigner) {
|
|
703
|
+
sendError(res, 503, "Attachment download signing is not configured");
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const base = requestBaseUrl(req);
|
|
707
|
+
if (!base) {
|
|
708
|
+
sendError(res, 500, "Internal error");
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const signed = urlSigner.sign(canonicalHash, decision.documentId, expiresIn);
|
|
712
|
+
try {
|
|
713
|
+
target = parseAttachmentDownloadTarget({
|
|
714
|
+
kind: "switchboard",
|
|
715
|
+
method: "GET",
|
|
716
|
+
url: `${base}/attachments/${canonicalHash}?${signed.query}`,
|
|
717
|
+
headers: {},
|
|
718
|
+
expiresAtUtc: signed.expiresAtUtc
|
|
719
|
+
});
|
|
720
|
+
} catch {
|
|
721
|
+
sendError(res, 500, "Internal error");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
sendJson(res, 200, target);
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/attachments/url-signer.ts
|
|
730
|
+
const ATTACHMENT_URL_SIGNING_SECRET_ENV = "PH_ATTACHMENT_URL_SIGNING_SECRET";
|
|
731
|
+
const MIN_SECRET_LENGTH = 32;
|
|
732
|
+
const EXPIRES_PATTERN = /^[1-9]\d{0,15}$/;
|
|
733
|
+
const SIGNATURE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
734
|
+
var AttachmentUrlSigner = class {
|
|
735
|
+
secret;
|
|
736
|
+
now;
|
|
737
|
+
constructor(secret, now = Date.now) {
|
|
738
|
+
this.secret = secret;
|
|
739
|
+
this.now = now;
|
|
740
|
+
}
|
|
741
|
+
sign(hash, documentId, ttlSeconds) {
|
|
742
|
+
const expires = Math.floor(this.now() / 1e3) + ttlSeconds;
|
|
743
|
+
return {
|
|
744
|
+
query: new URLSearchParams({
|
|
745
|
+
documentId,
|
|
746
|
+
expires: String(expires),
|
|
747
|
+
signature: this.mac(hash, documentId, String(expires))
|
|
748
|
+
}).toString(),
|
|
749
|
+
expiresAtUtc: (/* @__PURE__ */ new Date(expires * 1e3)).toISOString()
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
verify(hash, documentId, expires, signature) {
|
|
753
|
+
if (!EXPIRES_PATTERN.test(expires) || !SIGNATURE_PATTERN.test(signature)) return false;
|
|
754
|
+
if (Number(expires) * 1e3 <= this.now()) return false;
|
|
755
|
+
const expected = Buffer.from(this.mac(hash, documentId, expires));
|
|
756
|
+
const presented = Buffer.from(signature);
|
|
757
|
+
return expected.length === presented.length && timingSafeEqual(expected, presented);
|
|
758
|
+
}
|
|
759
|
+
mac(hash, documentId, expires) {
|
|
760
|
+
return createHmac("sha256", this.secret).update(JSON.stringify([
|
|
761
|
+
"attachment-download/v1",
|
|
762
|
+
hash,
|
|
763
|
+
documentId,
|
|
764
|
+
expires
|
|
765
|
+
])).digest("base64url");
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
function resolveAttachmentUrlSigning(env = process.env) {
|
|
769
|
+
const secret = env[ATTACHMENT_URL_SIGNING_SECRET_ENV];
|
|
770
|
+
if (secret !== void 0) {
|
|
771
|
+
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`);
|
|
772
|
+
return {
|
|
773
|
+
status: "configured",
|
|
774
|
+
signer: new AttachmentUrlSigner(secret)
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
if (env.NODE_ENV === "production") return { status: "unconfigured" };
|
|
778
|
+
return {
|
|
779
|
+
status: "ephemeral",
|
|
780
|
+
signer: new AttachmentUrlSigner(randomBytes(32))
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
//#endregion
|
|
784
|
+
//#region src/attachments/index.ts
|
|
785
|
+
const logger$1 = childLogger(["switchboard", "attachments"]);
|
|
786
|
+
function registerAttachmentRoutes(api, options = {}) {
|
|
787
|
+
const { attachments, attachmentAccess } = api;
|
|
788
|
+
const urlSigner = options.urlSigner === void 0 ? signerFromEnvironment(attachments.backend?.kind ?? "filesystem") : options.urlSigner;
|
|
789
|
+
mountAuthenticatedNodeRoute(api, "POST", "/attachments/reservations", makeReserveHandler(attachments));
|
|
790
|
+
mountAuthenticatedNodeRoute(api, "GET", "/attachments/reservations/:reservationId", makeGetReservationHandler(attachments));
|
|
791
|
+
mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
|
|
792
|
+
mountAuthenticatedNodeRoute(api, "PUT", "/attachments/reservations/:reservationId", makeUploadHandler(attachments));
|
|
793
|
+
mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
|
|
794
|
+
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
|
|
795
|
+
mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments, attachmentAccess, urlSigner), { allowAnonymous: true });
|
|
796
|
+
}
|
|
797
|
+
function signerFromEnvironment(backend) {
|
|
798
|
+
const signing = resolveAttachmentUrlSigning();
|
|
799
|
+
if (signing.status === "unconfigured") {
|
|
800
|
+
if (backend === "filesystem") logger$1.error(`${ATTACHMENT_URL_SIGNING_SECRET_ENV} is not set: filesystem attachment download targets are refused until it is`);
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
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`);
|
|
804
|
+
return signing.signer;
|
|
805
|
+
}
|
|
806
|
+
//#endregion
|
|
807
|
+
//#region src/attachment-reference-read-model.mts
|
|
808
|
+
const attachmentSchemaCompiler = new AttachmentSchemaCompiler();
|
|
809
|
+
function createAttachmentReferenceReadModel(baseKysely, dependencies, attachmentReferenceWriter) {
|
|
810
|
+
return new AttachmentReferenceReadModel(baseKysely.withSchema(REACTOR_SCHEMA), dependencies.operationIndex, dependencies.writeCache, dependencies.processorManagerConsistencyTracker, dependencies.documentModelRegistry, attachmentSchemaCompiler, attachmentReferenceWriter);
|
|
811
|
+
}
|
|
812
|
+
function registerAttachmentReferenceReadModel(reactorBuilder, registration) {
|
|
813
|
+
reactorBuilder.withReadModelFactory(async ({ documentModelRegistry, operationIndex, writeCache, processorManagerConsistencyTracker }) => {
|
|
814
|
+
const readModel = createAttachmentReferenceReadModel(registration.baseKysely, {
|
|
815
|
+
operationIndex,
|
|
816
|
+
writeCache,
|
|
817
|
+
processorManagerConsistencyTracker,
|
|
818
|
+
documentModelRegistry
|
|
819
|
+
}, registration.attachmentReferenceWriter);
|
|
820
|
+
await readModel.init();
|
|
821
|
+
return readModel;
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
async function registerAttachmentReferenceReadModelOnModule(clientModule, attachmentReferenceWriter) {
|
|
825
|
+
const reactorModule = clientModule.reactorModule;
|
|
826
|
+
if (!reactorModule) return {
|
|
827
|
+
status: "unavailable",
|
|
828
|
+
reason: "in-process-reactor-module-unavailable"
|
|
829
|
+
};
|
|
830
|
+
const coordinator = reactorModule.readModelCoordinator;
|
|
831
|
+
if (!supportsLiveReadModelRegistration(coordinator)) return {
|
|
832
|
+
status: "unavailable",
|
|
833
|
+
reason: "live-read-model-registration-unsupported"
|
|
834
|
+
};
|
|
835
|
+
const readModel = createAttachmentReferenceReadModel(reactorModule.database, {
|
|
836
|
+
operationIndex: reactorModule.operationIndex,
|
|
837
|
+
writeCache: reactorModule.writeCache,
|
|
838
|
+
processorManagerConsistencyTracker: reactorModule.processorManagerConsistencyTracker,
|
|
839
|
+
documentModelRegistry: reactorModule.documentModelRegistry
|
|
840
|
+
}, attachmentReferenceWriter);
|
|
841
|
+
await readModel.init();
|
|
842
|
+
coordinator.addReadModel(readModel, "pre_ready");
|
|
843
|
+
await readModel.init();
|
|
844
|
+
return { status: "available" };
|
|
845
|
+
}
|
|
846
|
+
//#endregion
|
|
847
|
+
//#region src/fatal-shutdown.mts
|
|
848
|
+
const FORCED_EXIT_MS = 15e3;
|
|
849
|
+
const fatalHandlers = /* @__PURE__ */ new WeakMap();
|
|
850
|
+
/**
|
|
851
|
+
* Sends an uncaught exception or unhandled rejection through the SIGTERM
|
|
852
|
+
* shutdown that `withSignalHandlers()` installs, so the reactor and read-model
|
|
853
|
+
* PGlite stores close with a shutdown checkpoint before the process exits
|
|
854
|
+
* with code 1. Node's default is to exit at once, which leaves both stores
|
|
855
|
+
* to WAL recovery on the next open. A store whose wasm runtime aborted
|
|
856
|
+
* (ENOSPC) rejects its close; the builder logs that and still exits. A
|
|
857
|
+
* shutdown that hangs is cut off after FORCED_EXIT_MS.
|
|
858
|
+
*
|
|
859
|
+
* A rejection is fatal only when this is its sole listener: Node exits on an
|
|
860
|
+
* unhandled rejection only when nothing listens for it, and Sentry's
|
|
861
|
+
* listener deliberately keeps the process running.
|
|
862
|
+
*/
|
|
863
|
+
function installFatalErrorShutdown(logger, proc = process) {
|
|
864
|
+
if (fatalHandlers.has(proc)) return;
|
|
865
|
+
const realExit = proc.exit.bind(proc);
|
|
866
|
+
let shuttingDown = false;
|
|
867
|
+
/**
|
|
868
|
+
* Logs without ever throwing. onFatal runs inside the uncaughtException
|
|
869
|
+
* handler, so a logger whose transport is gone (EPIPE on a closed stdout)
|
|
870
|
+
* would otherwise throw from the handler that reports throws, killing the
|
|
871
|
+
* process before the shutdown it is supposed to run. A logging failure is
|
|
872
|
+
* swallowed: there is nowhere left to report it.
|
|
873
|
+
*/
|
|
874
|
+
const logFatal = (message, err) => {
|
|
875
|
+
try {
|
|
876
|
+
if (err === void 0) logger.error(message);
|
|
877
|
+
else logger.error(message, err);
|
|
878
|
+
} catch {}
|
|
879
|
+
};
|
|
880
|
+
const onFatal = (kind, err) => {
|
|
881
|
+
logFatal(`${kind}: @error`, err);
|
|
882
|
+
if (shuttingDown) return;
|
|
883
|
+
shuttingDown = true;
|
|
884
|
+
proc.exitCode = 1;
|
|
885
|
+
setTimeout(() => {
|
|
886
|
+
logFatal("Shutdown after fatal error timed out; exiting");
|
|
887
|
+
realExit(1);
|
|
888
|
+
}, FORCED_EXIT_MS).unref();
|
|
889
|
+
proc.kill(proc.pid, "SIGTERM");
|
|
890
|
+
};
|
|
891
|
+
fatalHandlers.set(proc, onFatal);
|
|
892
|
+
proc.on("uncaughtException", (err) => onFatal("Uncaught exception", err));
|
|
893
|
+
proc.on("unhandledRejection", (reason) => {
|
|
894
|
+
if (proc.listenerCount("unhandledRejection") > 1) return;
|
|
895
|
+
onFatal("Unhandled rejection", reason);
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Routes a fatal condition the caller detected through the same shutdown as an
|
|
900
|
+
* uncaught error. Returns false, and does nothing, when
|
|
901
|
+
* `installFatalErrorShutdown` has not run for `proc`.
|
|
902
|
+
*/
|
|
903
|
+
function triggerFatalShutdown(kind, err, proc = process) {
|
|
904
|
+
const onFatal = fatalHandlers.get(proc);
|
|
905
|
+
if (!onFatal) return false;
|
|
906
|
+
onFatal(kind, err);
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
//#endregion
|
|
910
|
+
//#region src/privacy.mts
|
|
911
|
+
const DAY_MS = 864e5;
|
|
912
|
+
/** Enabling privacy on a switchboard that cannot run it safely. */
|
|
913
|
+
var PrivacyBootError = class extends Error {
|
|
914
|
+
constructor(message) {
|
|
915
|
+
super(message);
|
|
916
|
+
this.name = "PrivacyBootError";
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
function positive(env, name, unitMs) {
|
|
920
|
+
const raw = env[name];
|
|
921
|
+
if (raw === void 0 || raw.trim() === "") return void 0;
|
|
922
|
+
const value = Number(raw);
|
|
923
|
+
if (!Number.isFinite(value) || value <= 0) throw new PrivacyBootError(`${name} must be a positive number, got "${raw}"`);
|
|
924
|
+
return value * unitMs;
|
|
925
|
+
}
|
|
926
|
+
function resolvePrivacy(options, env) {
|
|
927
|
+
if (!(options?.enabled ?? env.PH_PRIVACY_ENABLED === "true")) return { enabled: false };
|
|
928
|
+
const deploymentSecret = options?.deploymentSecret ?? env.PH_PRIVACY_DEPLOYMENT_SECRET;
|
|
929
|
+
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`);
|
|
930
|
+
return {
|
|
931
|
+
enabled: true,
|
|
932
|
+
deploymentSecret,
|
|
933
|
+
intervalMs: positive(env, "PH_PRIVACY_INTERVAL_MS", 1),
|
|
934
|
+
deadlineMs: positive(env, "PH_PRIVACY_DEADLINE_DAYS", DAY_MS),
|
|
935
|
+
markerGraceMs: positive(env, "PH_PRIVACY_MARKER_GRACE_DAYS", DAY_MS),
|
|
936
|
+
purgeTimeoutMs: positive(env, "PH_PRIVACY_PURGE_TIMEOUT_MINUTES", 6e4)
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
/** Subject index, erasure scheduler and admin subgraph on a built reactor. */
|
|
940
|
+
async function startPrivacy(deps) {
|
|
941
|
+
const { config, reactorModule, authorizationService } = deps;
|
|
942
|
+
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.");
|
|
943
|
+
if (!reactorModule) throw new PrivacyBootError("PH_PRIVACY_ENABLED needs an in-process reactor module");
|
|
944
|
+
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");
|
|
945
|
+
const permissions = deps.documentPermissionService ? new PrivacyPermissionAdapter(deps.documentPermissionService) : void 0;
|
|
946
|
+
const { service, scheduler } = createModuleErasure(reactorModule, {
|
|
947
|
+
deploymentSecret: config.deploymentSecret,
|
|
948
|
+
signer: deps.signer,
|
|
949
|
+
permissions,
|
|
950
|
+
intervalMs: config.intervalMs,
|
|
951
|
+
deadlineMs: config.deadlineMs,
|
|
952
|
+
markerGraceMs: config.markerGraceMs,
|
|
953
|
+
purgeTimeoutMs: config.purgeTimeoutMs,
|
|
954
|
+
logger: deps.logger
|
|
955
|
+
});
|
|
956
|
+
const disclosure = new DisclosureService(reactorModule.database, config.deploymentSecret, permissions);
|
|
957
|
+
let subgraph;
|
|
958
|
+
try {
|
|
959
|
+
subgraph = createPrivacySubgraph({
|
|
960
|
+
authorizationService,
|
|
961
|
+
erasure: service,
|
|
962
|
+
disclosure
|
|
963
|
+
});
|
|
964
|
+
} catch (error) {
|
|
965
|
+
throw new PrivacyBootError(error instanceof Error ? error.message : String(error));
|
|
966
|
+
}
|
|
967
|
+
await registerSubjectDocumentsReadModel(reactorModule, { deploymentSecret: config.deploymentSecret });
|
|
968
|
+
await deps.graphqlManager.registerSubgraphInstance({
|
|
969
|
+
...subgraph,
|
|
970
|
+
path: deps.graphqlManager.getBasePath(),
|
|
971
|
+
reactorClient: deps.reactorClient,
|
|
972
|
+
relationalDb: void 0
|
|
973
|
+
}, "graphql", false);
|
|
974
|
+
scheduler.start();
|
|
975
|
+
deps.logger.info("Privacy add-on started: subject index, erasure scheduler");
|
|
976
|
+
return {
|
|
977
|
+
erasure: service,
|
|
978
|
+
scheduler,
|
|
979
|
+
stop: () => scheduler.stop()
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
//#endregion
|
|
983
|
+
//#region src/builder-defaults.mts
|
|
984
|
+
/**
|
|
985
|
+
* Apply switchboard's standard configuration to a reactor + client builder
|
|
986
|
+
* pair. Each piece is opt-out via the options object; defaults mirror what
|
|
987
|
+
* `startSwitchboard` does when building a reactor itself. Mutates both
|
|
988
|
+
* builders in place.
|
|
989
|
+
*
|
|
990
|
+
* Does NOT touch kysely or read models — callers wire those themselves
|
|
991
|
+
* (see `withKysely` / `withReadModelFactory` on the reactor builder).
|
|
992
|
+
*/
|
|
993
|
+
/** The models switchboard always registers alongside package models. */
|
|
994
|
+
function switchboardBaseDocumentModels() {
|
|
995
|
+
return [
|
|
996
|
+
documentModelDocumentModelModule,
|
|
997
|
+
driveDocumentModelModule,
|
|
998
|
+
reactorDriveDocumentModelModule,
|
|
999
|
+
ReactorGroupV1
|
|
1000
|
+
];
|
|
1001
|
+
}
|
|
1002
|
+
function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options = {}) {
|
|
1003
|
+
const baseModels = options.includeBaseModels !== false ? switchboardBaseDocumentModels() : [];
|
|
1004
|
+
const extra = options.documentModels ?? [];
|
|
1005
|
+
if (baseModels.length || extra.length) reactorBuilder.withDocumentModelSources(getUniqueDocumentModels(baseModels, extra));
|
|
1006
|
+
if (options.upgradeManifests?.length) reactorBuilder.withUpgradeManifests(options.upgradeManifests);
|
|
1007
|
+
const scheme = options.channelScheme === void 0 ? ChannelScheme.SWITCHBOARD : options.channelScheme;
|
|
1008
|
+
if (scheme !== false) reactorBuilder.withChannelScheme(scheme);
|
|
1009
|
+
if (options.signalHandlers !== false) reactorBuilder.withSignalHandlers();
|
|
1010
|
+
if (options.executorConfig?.maxSkipThreshold !== void 0 || options.executorConfig?.featureFlags !== void 0) reactorBuilder.withExecutorConfig({
|
|
1011
|
+
...options.executorConfig.maxSkipThreshold !== void 0 ? { maxSkipThreshold: options.executorConfig.maxSkipThreshold } : {},
|
|
1012
|
+
...options.executorConfig.featureFlags !== void 0 ? { featureFlags: options.executorConfig.featureFlags } : {}
|
|
1013
|
+
});
|
|
1014
|
+
if (options.documentModelLoader) reactorBuilder.withDocumentModelLoader(options.documentModelLoader);
|
|
1015
|
+
if (options.logger) reactorBuilder.withLogger(options.logger);
|
|
1016
|
+
if (options.signer) {
|
|
1017
|
+
clientBuilder.withSigner(options.signer);
|
|
1018
|
+
reactorBuilder.withSigner(options.signer.signer, options.signer.workerSigner);
|
|
1019
|
+
}
|
|
1020
|
+
if (options.createSignaturePolicy) clientBuilder.withCreateSignaturePolicy(options.createSignaturePolicy);
|
|
1021
|
+
const trust = options.trustPolicy ?? options.signer;
|
|
1022
|
+
if (trust?.trustPolicy) reactorBuilder.withTrustPolicy(trust.trustPolicy, trust.workerTrustPolicy);
|
|
1023
|
+
}
|
|
1024
|
+
//#endregion
|
|
1025
|
+
//#region src/worker-pool.mts
|
|
1026
|
+
const DEFAULT_DB_POOL_SIZE_PER_WORKER = 2;
|
|
1027
|
+
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5e3;
|
|
1028
|
+
const DEFAULT_DB_POOL_SIZE_HOST = 16;
|
|
1029
|
+
const AUTO_RESERVED_CORES = 2;
|
|
1030
|
+
const AUTO_WORKER_CAP = 8;
|
|
1031
|
+
/** Worker count for "auto": always at least 1 — auto sizes the pool, it does not turn worker mode off. */
|
|
1032
|
+
function autoWorkerCount(availableCores) {
|
|
1033
|
+
return Math.max(1, Math.min(AUTO_WORKER_CAP, availableCores - AUTO_RESERVED_CORES));
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Effective worker-pool config from programmatic options (which win) and the
|
|
1037
|
+
* REACTOR_* env vars; null when disabled (numWorkers 0, the default).
|
|
1038
|
+
* `"auto"` sizes the pool from the machine's available cores.
|
|
1039
|
+
*/
|
|
1040
|
+
function resolveWorkerPoolOptions(input, env, availableCores = os.availableParallelism()) {
|
|
1041
|
+
const requested = input?.numWorkers ?? parseWorkerCount(env.REACTOR_WORKERS) ?? 0;
|
|
1042
|
+
const mode = requested === "auto" ? "auto" : "explicit";
|
|
1043
|
+
const numWorkers = requested === "auto" ? autoWorkerCount(availableCores) : requested;
|
|
1044
|
+
if (!Number.isInteger(numWorkers) || numWorkers < 0) throw new Error(`workerPool.numWorkers must be "auto" or a non-negative integer, got ${numWorkers}`);
|
|
1045
|
+
if (numWorkers === 0) return null;
|
|
1046
|
+
return {
|
|
1047
|
+
numWorkers,
|
|
1048
|
+
mode,
|
|
1049
|
+
dbPoolSizePerWorker: input?.dbPoolSizePerWorker ?? parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_WORKER, "REACTOR_DB_POOL_SIZE_WORKER") ?? DEFAULT_DB_POOL_SIZE_PER_WORKER,
|
|
1050
|
+
acquireTimeoutMs: input?.acquireTimeoutMs ?? parseNonNegativeInt(env.REACTOR_DB_ACQUIRE_TIMEOUT_MS, "REACTOR_DB_ACQUIRE_TIMEOUT_MS") ?? DEFAULT_ACQUIRE_TIMEOUT_MS
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Size of the reactor's own (host) Postgres pool. Unlike the worker pool there
|
|
1055
|
+
* is no "disabled" state — the host pool is the only Postgres pool in the
|
|
1056
|
+
* server path, backing both reactor storage and the read models that reach it
|
|
1057
|
+
* via `withSchema` — so 0 is rejected rather than read as "off".
|
|
1058
|
+
*
|
|
1059
|
+
* The acquire timeout is deliberately not configurable here: the host pool
|
|
1060
|
+
* waits indefinitely today, and making it finite converts saturation from a
|
|
1061
|
+
* latency problem into a thrown error on the read-model path. That is a
|
|
1062
|
+
* behavior change worth making separately, once the retry semantics of a
|
|
1063
|
+
* rejected `pool.connect()` inside the read-model coordinator are settled.
|
|
1064
|
+
*/
|
|
1065
|
+
function resolveHostPoolSize(env) {
|
|
1066
|
+
const poolSize = parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_HOST, "REACTOR_DB_POOL_SIZE_HOST") ?? DEFAULT_DB_POOL_SIZE_HOST;
|
|
1067
|
+
if (poolSize < 1) throw new Error("REACTOR_DB_POOL_SIZE_HOST must be at least 1; the host pool cannot be disabled");
|
|
1068
|
+
return poolSize;
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* DbConfig for each worker's own pool, parsed from a postgres:// URL.
|
|
1072
|
+
* Explicit host, database, and user are required — no pg-side defaults.
|
|
1073
|
+
*/
|
|
1074
|
+
function buildWorkerDbConfig(postgresUrl, options) {
|
|
1075
|
+
let parsed;
|
|
1076
|
+
try {
|
|
1077
|
+
parsed = new URL(postgresUrl);
|
|
1078
|
+
} catch {
|
|
1079
|
+
throw new Error(`Worker pool requires a valid postgres:// URL for the reactor database, got "${postgresUrl}"`);
|
|
1080
|
+
}
|
|
1081
|
+
const database = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
|
|
1082
|
+
if (!parsed.hostname || !database) throw new Error(`Worker pool requires a postgres URL with a host and database name, got "${postgresUrl}"`);
|
|
1083
|
+
if (!parsed.username) throw new Error(`Worker pool requires explicit credentials in the postgres URL (workers open their own connections), got "${postgresUrl}"`);
|
|
1084
|
+
const sslmode = parsed.searchParams.get("sslmode");
|
|
1085
|
+
const ssl = parsed.searchParams.get("ssl") === "true" || sslmode !== null && sslmode !== "disable";
|
|
1086
|
+
return {
|
|
1087
|
+
host: parsed.hostname,
|
|
1088
|
+
port: parsed.port ? Number(parsed.port) : 5432,
|
|
1089
|
+
database,
|
|
1090
|
+
user: decodeURIComponent(parsed.username),
|
|
1091
|
+
password: decodeURIComponent(parsed.password),
|
|
1092
|
+
ssl,
|
|
1093
|
+
applicationName: "switchboard-worker",
|
|
1094
|
+
poolSize: options.dbPoolSizePerWorker,
|
|
1095
|
+
connectionTimeoutMillis: options.acquireTimeoutMs
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
/** Specifiers of the base models switchboard always registers. */
|
|
1099
|
+
const BASE_MODEL_SPECIFIERS = [
|
|
1100
|
+
"document-model",
|
|
1101
|
+
"@powerhousedao/shared/document-drive",
|
|
1102
|
+
"@powerhousedao/reactor-drive",
|
|
1103
|
+
"@powerhousedao/reactor-group/document-models"
|
|
1104
|
+
];
|
|
1105
|
+
function isModelModule(value) {
|
|
1106
|
+
const candidate = value;
|
|
1107
|
+
return typeof candidate?.reducer === "function" && typeof candidate.documentModel?.global?.id === "string";
|
|
1108
|
+
}
|
|
1109
|
+
async function keepImportableSources(sources, logger) {
|
|
1110
|
+
const kept = [];
|
|
1111
|
+
for (const source of sources) try {
|
|
1112
|
+
const exports = await import(pathToFileURL(source.filePath).href);
|
|
1113
|
+
if (Object.values(exports).some(isModelModule)) kept.push(source);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
logger.warn(`Worker model sources: "${source.filePath}" does not import, so workflow pieces will not see its models: ${String(error)}`);
|
|
1116
|
+
}
|
|
1117
|
+
return kept;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Resolves base models and configured package identifiers to `{ filePath }`
|
|
1121
|
+
* sources for the reactor builder. Resolution to absolute paths happens here
|
|
1122
|
+
* because workers (and the builder) resolve bare specifiers from the reactor
|
|
1123
|
+
* package's own dependency context, which cannot see switchboard or project
|
|
1124
|
+
* packages. Unresolvable identifiers are skipped with a warning; the builder
|
|
1125
|
+
* fails the boot if a registered model ends up without an importable source.
|
|
1126
|
+
*/
|
|
1127
|
+
async function resolveWorkerModelSources(packages, logger, options = {}) {
|
|
1128
|
+
const sources = [];
|
|
1129
|
+
for (const identifier of [...BASE_MODEL_SPECIFIERS, ...packages]) {
|
|
1130
|
+
const filePath = await resolveModelModuleFile(identifier) ?? await resolveRemoteSafely(identifier, options.resolveRemote, logger);
|
|
1131
|
+
if (!filePath) {
|
|
1132
|
+
logger.warn(`Worker model sources: no importable document-models entry for "${identifier}", skipping`);
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
sources.push({ filePath });
|
|
1136
|
+
}
|
|
1137
|
+
return sources;
|
|
1138
|
+
}
|
|
1139
|
+
async function resolveRemoteSafely(identifier, resolveRemote, logger) {
|
|
1140
|
+
if (!resolveRemote) return null;
|
|
1141
|
+
try {
|
|
1142
|
+
return await resolveRemote(identifier);
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
logger.warn(`Worker model sources: could not cache "${identifier}" from the registry: ${String(error)}`);
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Absolute file path of a source's models barrel. Mirrors reactor-api's
|
|
1150
|
+
* import-resolver: import.meta.resolve, then the project's node_modules via
|
|
1151
|
+
* the package.json exports map / dist layout.
|
|
1152
|
+
*/
|
|
1153
|
+
async function resolveModelModuleFile(source) {
|
|
1154
|
+
if (isFsPath(source)) return resolveFromPackageDir(source, "./document-models");
|
|
1155
|
+
const specifier = BASE_MODEL_SPECIFIERS.includes(source) ? source : `${source}/document-models`;
|
|
1156
|
+
try {
|
|
1157
|
+
const resolved = import.meta.resolve(specifier);
|
|
1158
|
+
if (resolved.startsWith("file:")) return fileURLToPath(resolved);
|
|
1159
|
+
} catch {}
|
|
1160
|
+
const { packageName, subpath } = splitSpecifier(specifier);
|
|
1161
|
+
const packageDir = path.join(process.cwd(), "node_modules", packageName);
|
|
1162
|
+
if (!existsSync(packageDir)) return null;
|
|
1163
|
+
let realDir;
|
|
1164
|
+
try {
|
|
1165
|
+
realDir = realpathSync(packageDir);
|
|
1166
|
+
} catch {
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
return resolveFromPackageDir(realDir, subpath);
|
|
1170
|
+
}
|
|
1171
|
+
function splitSpecifier(specifier) {
|
|
1172
|
+
const parts = specifier.split("/");
|
|
1173
|
+
const packageSegments = specifier.startsWith("@") ? 2 : 1;
|
|
1174
|
+
const packageName = parts.slice(0, packageSegments).join("/");
|
|
1175
|
+
const rest = parts.slice(packageSegments).join("/");
|
|
1176
|
+
return {
|
|
1177
|
+
packageName,
|
|
1178
|
+
subpath: rest ? `./${rest}` : "."
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
/** Exports-map lookup (import condition), then conventional dist paths. */
|
|
1182
|
+
async function resolveFromPackageDir(packageDir, subpath) {
|
|
1183
|
+
const fromManifest = await resolveViaManifest(packageDir, subpath);
|
|
1184
|
+
if (fromManifest) return fromManifest;
|
|
1185
|
+
const candidates = subpath === "." ? [path.join(packageDir, "dist", "index.js")] : [
|
|
1186
|
+
path.join(packageDir, "dist", subpath.slice(2), "index.js"),
|
|
1187
|
+
path.join(packageDir, "dist", `${subpath.slice(2)}.js`),
|
|
1188
|
+
path.join(packageDir, subpath.slice(2), "index.js")
|
|
1189
|
+
];
|
|
1190
|
+
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
async function resolveViaManifest(packageDir, subpath) {
|
|
1194
|
+
let raw;
|
|
1195
|
+
try {
|
|
1196
|
+
raw = await readFile(path.join(packageDir, "package.json"), "utf8");
|
|
1197
|
+
} catch {
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
let manifest;
|
|
1201
|
+
try {
|
|
1202
|
+
manifest = JSON.parse(raw);
|
|
1203
|
+
} catch {
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
let entry;
|
|
1207
|
+
const exportsField = manifest.exports;
|
|
1208
|
+
if (exportsField !== null && typeof exportsField === "object" && !Array.isArray(exportsField)) {
|
|
1209
|
+
const map = exportsField;
|
|
1210
|
+
entry = Object.keys(map).some((key) => key.startsWith(".")) ? map[subpath] : subpath === "." ? map : void 0;
|
|
1211
|
+
} else if (typeof exportsField === "string" && subpath === ".") entry = exportsField;
|
|
1212
|
+
let target = pickImportTarget(entry);
|
|
1213
|
+
if (!target && subpath === "." && typeof manifest.main === "string") target = manifest.main;
|
|
1214
|
+
if (!target) return null;
|
|
1215
|
+
const resolved = path.join(packageDir, target);
|
|
1216
|
+
return existsSync(resolved) ? resolved : null;
|
|
1217
|
+
}
|
|
1218
|
+
function pickImportTarget(entry) {
|
|
1219
|
+
if (typeof entry === "string") return entry;
|
|
1220
|
+
if (typeof entry !== "object" || entry === null) return null;
|
|
1221
|
+
const conditions = entry;
|
|
1222
|
+
for (const condition of [
|
|
1223
|
+
"import",
|
|
1224
|
+
"node",
|
|
1225
|
+
"default"
|
|
1226
|
+
]) {
|
|
1227
|
+
const value = conditions[condition];
|
|
1228
|
+
if (typeof value === "string") return value;
|
|
1229
|
+
}
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
function isFsPath(identifier) {
|
|
1233
|
+
return path.isAbsolute(identifier) || identifier.startsWith("./") || identifier.startsWith("../");
|
|
1234
|
+
}
|
|
1235
|
+
function parseWorkerCount(raw) {
|
|
1236
|
+
if (raw === void 0 || raw.trim() === "") return;
|
|
1237
|
+
if (raw.trim().toLowerCase() === "auto") return "auto";
|
|
1238
|
+
const value = Number.parseInt(raw, 10);
|
|
1239
|
+
if (!Number.isInteger(value) || value < 0 || String(value) !== raw.trim()) throw new Error(`REACTOR_WORKERS must be "auto" or a non-negative integer, got "${raw}"`);
|
|
1240
|
+
return value;
|
|
1241
|
+
}
|
|
1242
|
+
function parseNonNegativeInt(raw, name) {
|
|
1243
|
+
if (raw === void 0 || raw.trim() === "") return;
|
|
1244
|
+
const value = Number.parseInt(raw, 10);
|
|
1245
|
+
if (!Number.isInteger(value) || value < 0 || String(value) !== raw.trim()) throw new Error(`${name} must be a non-negative integer, got "${raw}"`);
|
|
1246
|
+
return value;
|
|
1247
|
+
}
|
|
1248
|
+
//#endregion
|
|
1249
|
+
//#region src/projection-worker.mts
|
|
1250
|
+
const DEFAULT_DB_POOL_SIZE_PROJECTION = 8;
|
|
1251
|
+
const ON_TOKENS = [
|
|
1252
|
+
"1",
|
|
1253
|
+
"true",
|
|
1254
|
+
"on",
|
|
1255
|
+
"yes"
|
|
1256
|
+
];
|
|
1257
|
+
const OFF_TOKENS = [
|
|
1258
|
+
"0",
|
|
1259
|
+
"false",
|
|
1260
|
+
"off",
|
|
1261
|
+
"no"
|
|
1262
|
+
];
|
|
1263
|
+
/**
|
|
1264
|
+
* Enabled by REACTOR_PROJECTION_WORKER (1/true/on/yes, case-insensitive) or
|
|
1265
|
+
* `input.enabled`, which wins when defined; null when off (the default).
|
|
1266
|
+
*/
|
|
1267
|
+
function resolveProjectionWorkerOptions(input, env) {
|
|
1268
|
+
if (!(input?.enabled ?? parseOnOff(env.REACTOR_PROJECTION_WORKER) ?? false)) return null;
|
|
1269
|
+
const dbPoolSize = input?.dbPoolSize ?? parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_PROJECTION, "REACTOR_DB_POOL_SIZE_PROJECTION") ?? DEFAULT_DB_POOL_SIZE_PROJECTION;
|
|
1270
|
+
if (dbPoolSize < 1) throw new Error("REACTOR_DB_POOL_SIZE_PROJECTION must be at least 1; the projection worker cannot run without a pool");
|
|
1271
|
+
return { dbPoolSize };
|
|
1272
|
+
}
|
|
1273
|
+
/** Same preconditions as the executor worker pool; message shape mirrors server.mts. */
|
|
1274
|
+
function assertProjectionWorkerSupported(args) {
|
|
1275
|
+
if (args.dev) throw new Error("The projection worker (REACTOR_PROJECTION_WORKER) is not supported in dev mode: Vite-loaded document models cannot cross a worker-thread boundary");
|
|
1276
|
+
if (!args.reactorDbUrl || !isPostgresUrl(args.reactorDbUrl)) throw new Error("The projection worker (REACTOR_PROJECTION_WORKER) requires a Postgres reactor database — set PH_REACTOR_DATABASE_URL or PH_SWITCHBOARD_DATABASE_URL. PGlite cannot be shared across worker threads.");
|
|
1277
|
+
}
|
|
1278
|
+
function parseOnOff(raw) {
|
|
1279
|
+
if (raw === void 0 || raw.trim() === "") return;
|
|
1280
|
+
const token = raw.trim().toLowerCase();
|
|
1281
|
+
if (ON_TOKENS.includes(token)) return true;
|
|
1282
|
+
if (OFF_TOKENS.includes(token)) return false;
|
|
1283
|
+
throw new Error(`REACTOR_PROJECTION_WORKER must be one of 1, true, on, yes, 0, false, off, no, got "${raw}"`);
|
|
1284
|
+
}
|
|
1285
|
+
//#endregion
|
|
1286
|
+
//#region src/feature-flags.ts
|
|
1287
|
+
async function initFeatureFlags() {
|
|
1288
|
+
const provider = new EnvVarProvider();
|
|
1289
|
+
await OpenFeature.setProviderAndWait(provider);
|
|
1290
|
+
return OpenFeature.getClient();
|
|
1291
|
+
}
|
|
1292
|
+
//#endregion
|
|
1293
|
+
//#region src/mcp-flag.mts
|
|
1294
|
+
/** The env var and OpenFeature flag key that can keep the MCP server unmounted. */
|
|
1295
|
+
const MCP_ENABLED = "MCP_ENABLED";
|
|
1296
|
+
/** Whether to mount the MCP server at `/mcp`. On unless the host passes
|
|
1297
|
+
* `mcp: false` or MCP_ENABLED is "false": the env var can turn a host's MCP
|
|
1298
|
+
* off, never on, so a deployment that serves no MCP client can close the
|
|
1299
|
+
* endpoint without the bin growing a flag of its own. */
|
|
1300
|
+
async function resolveMcpEnabled({ featureFlags, option }) {
|
|
1301
|
+
if (option === false) return false;
|
|
1302
|
+
return featureFlags.getBooleanValue(MCP_ENABLED, true);
|
|
1303
|
+
}
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/workflow/oauth-callback.ts
|
|
1306
|
+
const OAUTH_CALLBACK_PATH = "oauth/callback";
|
|
1307
|
+
function escapeHtml(text) {
|
|
1308
|
+
return text.replace(/[&<>"']/g, (char) => ({
|
|
1309
|
+
"&": "&",
|
|
1310
|
+
"<": "<",
|
|
1311
|
+
">": ">",
|
|
1312
|
+
"\"": """,
|
|
1313
|
+
"'": "'"
|
|
1314
|
+
})[char]);
|
|
1315
|
+
}
|
|
1316
|
+
function resultPage(ok, detail) {
|
|
1317
|
+
const title = ok ? "Connected" : "Sign-in failed";
|
|
1318
|
+
return `<!doctype html>
|
|
1319
|
+
<html><head><meta charset="utf-8"><title>${title}</title>
|
|
1320
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1321
|
+
<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>
|
|
1322
|
+
</head><body><main><h1>${title}</h1><p>${ok ? "You can close this window." : escapeHtml(detail ?? "The sign-in did not finish.")}</p></main>
|
|
1323
|
+
${ok ? "<script>setTimeout(function(){window.close()},600)<\/script>" : ""}
|
|
1324
|
+
</body></html>`;
|
|
1325
|
+
}
|
|
1326
|
+
function registerOAuthCallback(http, runtime) {
|
|
1327
|
+
return http.get(OAUTH_CALLBACK_PATH, { auth: "public" }, async (request) => {
|
|
1328
|
+
const params = new URL(request.url).searchParams;
|
|
1329
|
+
const state = params.get("state");
|
|
1330
|
+
const result = state ? await runtime.completeOAuth({
|
|
1331
|
+
state,
|
|
1332
|
+
code: params.get("code") ?? void 0,
|
|
1333
|
+
error: params.get("error") ?? void 0,
|
|
1334
|
+
errorDescription: params.get("error_description") ?? void 0
|
|
1335
|
+
}) : {
|
|
1336
|
+
ok: false,
|
|
1337
|
+
detail: "The provider sent no state",
|
|
1338
|
+
returnUrl: null
|
|
1339
|
+
};
|
|
1340
|
+
if (result.returnUrl && state) {
|
|
1341
|
+
const back = new URL(result.returnUrl);
|
|
1342
|
+
back.searchParams.set("ph_oauth", state);
|
|
1343
|
+
return new Response(null, {
|
|
1344
|
+
status: 303,
|
|
1345
|
+
headers: {
|
|
1346
|
+
location: back.href,
|
|
1347
|
+
"cache-control": "no-store"
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
return new Response(resultPage(result.ok, result.detail), {
|
|
1352
|
+
status: result.ok ? 200 : 400,
|
|
1353
|
+
headers: {
|
|
1354
|
+
"content-type": "text/html; charset=utf-8",
|
|
1355
|
+
"cache-control": "no-store",
|
|
1356
|
+
"referrer-policy": "no-referrer"
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
function callbackUrlOf(http) {
|
|
1362
|
+
return `${http.baseUrl.replace(/\/+$/, "")}/${OAUTH_CALLBACK_PATH}`;
|
|
1363
|
+
}
|
|
1364
|
+
//#endregion
|
|
1365
|
+
//#region src/workflow/resolvers.ts
|
|
1366
|
+
function blockRef(input) {
|
|
1367
|
+
if (input.kind !== "action" && input.kind !== "trigger") throw new GraphQLError(`A block's kind is "action" or "trigger", got "${input.kind}"`);
|
|
1368
|
+
return {
|
|
1369
|
+
pieceName: input.pieceName,
|
|
1370
|
+
pieceVersion: input.pieceVersion,
|
|
1371
|
+
name: input.name,
|
|
1372
|
+
kind: input.kind
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
async function cursorAware(call) {
|
|
1376
|
+
try {
|
|
1377
|
+
return await call();
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
if (error instanceof Error && error.name === "InvalidRunCursorError") throw new GraphQLError(error.message, { extensions: { code: "BAD_USER_INPUT" } });
|
|
1380
|
+
throw error;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
function selectsStepData(selections, path) {
|
|
1384
|
+
let level = selections;
|
|
1385
|
+
for (const name of [...path, "steps"]) {
|
|
1386
|
+
const next = [];
|
|
1387
|
+
for (const set of level) for (const selection of set.selections) {
|
|
1388
|
+
if (selection.kind !== Kind.FIELD) return true;
|
|
1389
|
+
if (selection.name.value === name && selection.selectionSet) next.push(selection.selectionSet);
|
|
1390
|
+
}
|
|
1391
|
+
if (next.length === 0) return false;
|
|
1392
|
+
level = next;
|
|
1393
|
+
}
|
|
1394
|
+
return level.some((set) => set.selections.some((selection) => selection.kind !== Kind.FIELD || selection.name.value === "input" || selection.name.value === "output"));
|
|
1395
|
+
}
|
|
1396
|
+
function readsStepData(info, path) {
|
|
1397
|
+
if (!info) return true;
|
|
1398
|
+
return selectsStepData(info.fieldNodes.flatMap((node) => node.selectionSet ? [node.selectionSet] : []), path);
|
|
1399
|
+
}
|
|
1400
|
+
function requireAdmin(authorizationService, ctx) {
|
|
1401
|
+
if (!authorizationService.isSupremeAdmin(ctx.user?.address)) throw new GraphQLError("Admin access required");
|
|
1402
|
+
}
|
|
1403
|
+
async function syncAware(call) {
|
|
1404
|
+
try {
|
|
1405
|
+
return await call();
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
if (error instanceof Error && error.name === "WorkflowSyncingError") throw new GraphQLError(error.message, { extensions: {
|
|
1408
|
+
code: "WORKFLOW_SYNCING",
|
|
1409
|
+
retryable: true
|
|
1410
|
+
} });
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
function parseJson(value) {
|
|
1415
|
+
if (value === null) return null;
|
|
1416
|
+
try {
|
|
1417
|
+
return JSON.parse(value);
|
|
1418
|
+
} catch {
|
|
1419
|
+
return value;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
function toStepRecord(row) {
|
|
1423
|
+
return {
|
|
1424
|
+
stepId: row.step_id,
|
|
1425
|
+
stepKey: row.step_key,
|
|
1426
|
+
pieceName: row.piece_name,
|
|
1427
|
+
blockName: row.block_name,
|
|
1428
|
+
status: row.status,
|
|
1429
|
+
input: parseJson(row.input),
|
|
1430
|
+
output: parseJson(row.output),
|
|
1431
|
+
port: row.port,
|
|
1432
|
+
error: row.error,
|
|
1433
|
+
errorName: row.error_name,
|
|
1434
|
+
startedAt: row.started_at,
|
|
1435
|
+
endedAt: row.ended_at,
|
|
1436
|
+
pieceVersion: row.piece_version,
|
|
1437
|
+
pieceSource: row.piece_source,
|
|
1438
|
+
versionMatch: row.version_match,
|
|
1439
|
+
versionNote: row.version_note
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
function toRunRecord(row, steps) {
|
|
1443
|
+
return {
|
|
1444
|
+
id: row.id,
|
|
1445
|
+
workflowId: row.workflow_id,
|
|
1446
|
+
workflowName: row.workflow_name,
|
|
1447
|
+
workflowVersion: row.workflow_version,
|
|
1448
|
+
triggerKind: row.trigger_kind,
|
|
1449
|
+
triggerPayload: parseJson(row.trigger_payload),
|
|
1450
|
+
status: row.status,
|
|
1451
|
+
error: row.error,
|
|
1452
|
+
errorName: row.error_name,
|
|
1453
|
+
startedAt: row.started_at,
|
|
1454
|
+
endedAt: row.ended_at,
|
|
1455
|
+
rerunOf: row.rerun_of,
|
|
1456
|
+
warnings: row.warnings,
|
|
1457
|
+
warningNotes: parseJson(row.warning_notes) ?? [],
|
|
1458
|
+
steps: steps.map(toStepRecord)
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
const UNKNOWN_ACCESS = {
|
|
1462
|
+
authEnforcement: true,
|
|
1463
|
+
authConditions: false,
|
|
1464
|
+
identity: null
|
|
1465
|
+
};
|
|
1466
|
+
function signedIn(ctx) {
|
|
1467
|
+
return Boolean(ctx.user?.address);
|
|
1468
|
+
}
|
|
1469
|
+
function isAbsolute(url) {
|
|
1470
|
+
return /^https?:\/\//i.test(url);
|
|
1471
|
+
}
|
|
1472
|
+
function redirectUriFor(routing, requested) {
|
|
1473
|
+
if (!routing) throw new GraphQLError("This host serves no OAuth2 callback");
|
|
1474
|
+
if (isAbsolute(routing.callbackUrl)) return routing.callbackUrl;
|
|
1475
|
+
if (!requested) throw new GraphQLError("This host does not know its public URL; pass redirectUri");
|
|
1476
|
+
let url;
|
|
1477
|
+
try {
|
|
1478
|
+
url = new URL(requested);
|
|
1479
|
+
} catch {
|
|
1480
|
+
throw new GraphQLError(`"${requested}" is not a URL`);
|
|
1481
|
+
}
|
|
1482
|
+
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}`);
|
|
1483
|
+
return url.href;
|
|
1484
|
+
}
|
|
1485
|
+
function returnUrlFor(requested, ctx) {
|
|
1486
|
+
if (!requested) return void 0;
|
|
1487
|
+
const origin = ctx.headers.origin;
|
|
1488
|
+
let url;
|
|
1489
|
+
try {
|
|
1490
|
+
url = new URL(requested);
|
|
1491
|
+
} catch {
|
|
1492
|
+
url = void 0;
|
|
1493
|
+
}
|
|
1494
|
+
if (!url || !origin || url.origin !== origin) throw new GraphQLError("returnUrl must be on the requesting page's origin");
|
|
1495
|
+
return url.href;
|
|
1496
|
+
}
|
|
1497
|
+
const getResolvers = (runtime, authorizationService, oauth, access = UNKNOWN_ACCESS, assertCanRead) => {
|
|
1498
|
+
return {
|
|
1499
|
+
Query: { workflowRuntime: () => ({}) },
|
|
1500
|
+
WorkflowRuntimeQueries: {
|
|
1501
|
+
health: () => "ok",
|
|
1502
|
+
authEnforcement: () => access.authEnforcement,
|
|
1503
|
+
reactorIdentity: (_parent, _args, ctx) => signedIn(ctx) ? access.identity : null,
|
|
1504
|
+
authConditions: (_parent, _args, ctx) => signedIn(ctx) ? access.authConditions : null,
|
|
1505
|
+
reactorAccessDenial: async (_parent, args, ctx) => {
|
|
1506
|
+
if (!assertCanRead) throw new GraphQLError("No read check configured");
|
|
1507
|
+
await assertCanRead(args.workflowId, ctx);
|
|
1508
|
+
return runtime.reactorAccessDenial(args.workflowId) ?? null;
|
|
1509
|
+
},
|
|
1510
|
+
blockDescriptor: (_parent, args) => runtime.blockDescriptor(blockRef(args.block)),
|
|
1511
|
+
blockOptions: (_parent, args, ctx) => runtime.blockOptions(blockRef(args.block), args.propName, args.input, args.connectionId ?? void 0, ctx, args.searchValue ?? void 0, args.reactorConnectionId ?? void 0),
|
|
1512
|
+
pieceCatalog: () => runtime.pieceCatalog(),
|
|
1513
|
+
pieceActions: (_parent, args) => runtime.pieceActions(args.packageName, args.version ?? void 0),
|
|
1514
|
+
pieceTriggers: (_parent, args) => runtime.pieceTriggers(args.packageName, args.version ?? void 0),
|
|
1515
|
+
blockOutputTree: (_parent, args) => runtime.blockOutputTree(blockRef(args.block), args.config),
|
|
1516
|
+
stepOutputTree: (_parent, args, ctx) => runtime.stepOutputTree(args.workflowId, args.stepId, ctx),
|
|
1517
|
+
blockResolutions: (_parent, args, ctx) => runtime.blockResolutions(args.workflowId, ctx),
|
|
1518
|
+
pieceDetail: (_parent, args) => runtime.pieceDetail(args.packageName, args.version ?? void 0),
|
|
1519
|
+
searchBlocks: (_parent, args) => runtime.searchBlocks(args.query, args.limit ?? void 0),
|
|
1520
|
+
connections: (_parent, _args, ctx) => runtime.connections(ctx),
|
|
1521
|
+
webhookEndpoint: (_parent, args, ctx) => syncAware(() => runtime.webhookEndpoint(args.workflowId, ctx, { driveId: args.driveId ?? void 0 })),
|
|
1522
|
+
secret: async (_parent, args) => {
|
|
1523
|
+
try {
|
|
1524
|
+
return await (await runtime.secrets()).stat(args.ref);
|
|
1525
|
+
} catch {
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
},
|
|
1529
|
+
secrets: async () => (await runtime.secrets()).list(),
|
|
1530
|
+
oauthRedirectUri: () => oauth?.callbackUrl ?? null,
|
|
1531
|
+
oauthAttempt: (_parent, args, ctx) => runtime.oauthAttempt(args.state, ctx),
|
|
1532
|
+
triggerStates: async (_parent, _args, ctx) => (await runtime.triggerStates(ctx)).map((row) => ({
|
|
1533
|
+
workflowId: row.workflow_id,
|
|
1534
|
+
pieceName: row.piece_name,
|
|
1535
|
+
triggerName: row.trigger_name,
|
|
1536
|
+
status: row.status,
|
|
1537
|
+
intervalMs: row.interval_ms,
|
|
1538
|
+
nextPollAt: row.next_poll_at,
|
|
1539
|
+
lastPollAt: row.last_poll_at,
|
|
1540
|
+
lastError: row.last_error,
|
|
1541
|
+
consecutiveFailures: row.consecutive_failures,
|
|
1542
|
+
nextRenewAt: row.next_renew_at,
|
|
1543
|
+
renewError: row.renew_error,
|
|
1544
|
+
renewFailures: row.renew_failures,
|
|
1545
|
+
pieceVersion: row.piece_version,
|
|
1546
|
+
pieceSource: row.piece_source,
|
|
1547
|
+
versionMatch: row.version_match,
|
|
1548
|
+
versionNote: row.version_note
|
|
1549
|
+
})),
|
|
1550
|
+
runs: async (_parent, args, ctx, info) => (await runtime.runs({
|
|
1551
|
+
...args,
|
|
1552
|
+
withStepData: readsStepData(info, [])
|
|
1553
|
+
}, ctx)).map((record) => toRunRecord(record.row, record.steps)),
|
|
1554
|
+
runsPage: async (_parent, args, ctx, info) => {
|
|
1555
|
+
const { paging, ...scope } = args;
|
|
1556
|
+
const page = await cursorAware(() => runtime.runsPage({
|
|
1557
|
+
...scope,
|
|
1558
|
+
limit: paging?.limit ?? void 0,
|
|
1559
|
+
cursor: paging?.cursor ?? null,
|
|
1560
|
+
withStepData: readsStepData(info, ["items"])
|
|
1561
|
+
}, ctx));
|
|
1562
|
+
return {
|
|
1563
|
+
items: page.records.map((record) => toRunRecord(record.row, record.steps)),
|
|
1564
|
+
hasNextPage: page.hasNextPage,
|
|
1565
|
+
hasPreviousPage: Boolean(paging?.cursor),
|
|
1566
|
+
cursor: page.cursor
|
|
1567
|
+
};
|
|
1568
|
+
},
|
|
1569
|
+
run: async (_parent, args, ctx) => {
|
|
1570
|
+
const record = await runtime.run(args.id, ctx);
|
|
1571
|
+
return record ? toRunRecord(record.row, record.steps) : null;
|
|
1572
|
+
}
|
|
1573
|
+
},
|
|
1574
|
+
Mutation: { workflowRuntime: () => ({}) },
|
|
1575
|
+
WorkflowRuntimeMutations: {
|
|
1576
|
+
fire: (_parent, args, ctx) => runtime.fire(args.workflowId, args.payload, "manual", void 0, ctx),
|
|
1577
|
+
testTrigger: (_parent, args, ctx) => syncAware(() => runtime.testTrigger(args.workflowId, ctx, {
|
|
1578
|
+
...args.payload !== void 0 ? { payload: args.payload } : {},
|
|
1579
|
+
...typeof args.timeoutSeconds === "number" ? { timeoutMs: args.timeoutSeconds * 1e3 } : {},
|
|
1580
|
+
driveId: args.driveId ?? void 0
|
|
1581
|
+
})),
|
|
1582
|
+
cancelTriggerTest: (_parent, args, ctx) => runtime.cancelTriggerTestFor(args.workflowId, ctx),
|
|
1583
|
+
testStep: (_parent, args, ctx) => syncAware(() => runtime.testStep(args.workflowId, args.stepId, ctx, { driveId: args.driveId ?? void 0 })),
|
|
1584
|
+
rerun: (_parent, args, ctx) => runtime.rerun(args.runId, ctx),
|
|
1585
|
+
createSecret: async (_parent, args, ctx) => {
|
|
1586
|
+
requireAdmin(authorizationService, ctx);
|
|
1587
|
+
return (await runtime.secrets()).create({
|
|
1588
|
+
value: args.value,
|
|
1589
|
+
label: args.label ?? void 0
|
|
1590
|
+
});
|
|
1591
|
+
},
|
|
1592
|
+
rotateSecret: async (_parent, args, ctx) => {
|
|
1593
|
+
requireAdmin(authorizationService, ctx);
|
|
1594
|
+
return (await runtime.secrets()).rotate(args.ref, args.value);
|
|
1595
|
+
},
|
|
1596
|
+
deleteSecret: async (_parent, args, ctx) => {
|
|
1597
|
+
requireAdmin(authorizationService, ctx);
|
|
1598
|
+
await (await runtime.secrets()).delete(args.ref);
|
|
1599
|
+
return true;
|
|
1600
|
+
},
|
|
1601
|
+
checkConnection: (_parent, args, ctx) => runtime.checkConnection(args.connectionId, ctx),
|
|
1602
|
+
startOAuth: (_parent, args, ctx) => {
|
|
1603
|
+
requireAdmin(authorizationService, ctx);
|
|
1604
|
+
const returnUrl = returnUrlFor(args.returnUrl, ctx);
|
|
1605
|
+
return runtime.startOAuth(args.connectionId, ctx, {
|
|
1606
|
+
redirectUri: redirectUriFor(oauth, args.redirectUri),
|
|
1607
|
+
...returnUrl ? { returnUrl } : {}
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
};
|
|
1613
|
+
//#endregion
|
|
1614
|
+
//#region src/workflow/schema.ts
|
|
1615
|
+
const schema = gql`
|
|
1616
|
+
"""
|
|
1617
|
+
WorkflowRuntime Queries
|
|
1618
|
+
"""
|
|
1619
|
+
type WorkflowRuntimeQueries {
|
|
1620
|
+
health: String!
|
|
1621
|
+
"""
|
|
1622
|
+
Whether this reactor enforces document auth (REACTOR_AUTH_ENFORCEMENT).
|
|
1623
|
+
On, a workflow needs a signed publish, and the host grants, to reach
|
|
1624
|
+
documents. Answered to any caller.
|
|
1625
|
+
"""
|
|
1626
|
+
authEnforcement: Boolean!
|
|
1627
|
+
"""
|
|
1628
|
+
Who this host signs as: grant it on each document a step writes. Null for
|
|
1629
|
+
a caller who is not signed in, or a host with no signer.
|
|
1630
|
+
"""
|
|
1631
|
+
reactorIdentity: ReactorIdentity
|
|
1632
|
+
"""
|
|
1633
|
+
REACTOR_AUTH_CONDITIONS. True: grant the host by key, with
|
|
1634
|
+
{ match: subject.key == key }; false: by address. Null for a caller who
|
|
1635
|
+
is not signed in.
|
|
1636
|
+
"""
|
|
1637
|
+
authConditions: Boolean
|
|
1638
|
+
"""
|
|
1639
|
+
Why the workflow's last publish or enable got no reactor access, naming
|
|
1640
|
+
the publisher and the connection; null when the check passed.
|
|
1641
|
+
"""
|
|
1642
|
+
reactorAccessDenial(workflowId: String!): String
|
|
1643
|
+
"""
|
|
1644
|
+
Persisted runs, newest first. Scope them to one workflow, or to every
|
|
1645
|
+
workflow a drive holds; workflowId wins when both are given.
|
|
1646
|
+
"""
|
|
1647
|
+
runs(
|
|
1648
|
+
workflowId: String
|
|
1649
|
+
driveId: String
|
|
1650
|
+
limit: Int
|
|
1651
|
+
"Trigger kinds to leave out, e.g. test."
|
|
1652
|
+
excludeTriggerKinds: [String!]
|
|
1653
|
+
): [WorkflowRunRecord!]!
|
|
1654
|
+
"""
|
|
1655
|
+
The same listing a page at a time, newest journaled first. Pass a page's
|
|
1656
|
+
cursor back in paging to read the next; a page is short only when
|
|
1657
|
+
hasNextPage is false, or when the caller could read none of a long
|
|
1658
|
+
stretch of runs. A run that starts between pages keeps its place.
|
|
1659
|
+
"""
|
|
1660
|
+
runsPage(
|
|
1661
|
+
workflowId: String
|
|
1662
|
+
driveId: String
|
|
1663
|
+
excludeTriggerKinds: [String!]
|
|
1664
|
+
paging: WorkflowRunsPagingInput
|
|
1665
|
+
): WorkflowRunResultPage!
|
|
1666
|
+
run(id: String!): WorkflowRunRecord
|
|
1667
|
+
"""
|
|
1668
|
+
The descriptor (props, auth, ports) of one action or trigger, as the
|
|
1669
|
+
version that runs it describes it; triggers come back under "trigger".
|
|
1670
|
+
"""
|
|
1671
|
+
blockDescriptor(block: BlockInput!): Unknown
|
|
1672
|
+
"""
|
|
1673
|
+
Resolves a dynamic prop against the current config values: a DROPDOWN
|
|
1674
|
+
yields { options, placeholder, disabled }, a DYNAMIC prop yields the
|
|
1675
|
+
resolved sub-property descriptor list.
|
|
1676
|
+
"""
|
|
1677
|
+
blockOptions(
|
|
1678
|
+
block: BlockInput!
|
|
1679
|
+
propName: String!
|
|
1680
|
+
input: Unknown
|
|
1681
|
+
connectionId: String
|
|
1682
|
+
"What the author typed, for a DROPDOWN declared with refreshOnSearch."
|
|
1683
|
+
searchValue: String
|
|
1684
|
+
"The step's reactor connection; its filter narrows what the resolver reads."
|
|
1685
|
+
reactorConnectionId: String
|
|
1686
|
+
): Unknown
|
|
1687
|
+
"""
|
|
1688
|
+
Every piece this reactor offers: the core piece, its package pieces, and
|
|
1689
|
+
the pieces the registry and Activepieces publish.
|
|
1690
|
+
"""
|
|
1691
|
+
pieceCatalog: Unknown
|
|
1692
|
+
"""
|
|
1693
|
+
A piece's actions at one version; pin a step to that version.
|
|
1694
|
+
"""
|
|
1695
|
+
pieceActions(
|
|
1696
|
+
packageName: String!
|
|
1697
|
+
"That version; the installed or latest one when omitted."
|
|
1698
|
+
version: String
|
|
1699
|
+
): Unknown
|
|
1700
|
+
"""
|
|
1701
|
+
A piece's triggers at one version; pin a trigger to that version.
|
|
1702
|
+
"""
|
|
1703
|
+
pieceTriggers(
|
|
1704
|
+
packageName: String!
|
|
1705
|
+
"That version; the installed or latest one when omitted."
|
|
1706
|
+
version: String
|
|
1707
|
+
): Unknown
|
|
1708
|
+
"""
|
|
1709
|
+
Full piece detail (PieceMetadataModel-shaped), verbatim from the cloud API.
|
|
1710
|
+
"""
|
|
1711
|
+
pieceDetail(
|
|
1712
|
+
packageName: String!
|
|
1713
|
+
"That version; the installed or latest one when omitted."
|
|
1714
|
+
version: String
|
|
1715
|
+
): Unknown
|
|
1716
|
+
"""
|
|
1717
|
+
Action/trigger name search across the catalog. The index builds lazily on
|
|
1718
|
+
first use; poll while status is "indexing".
|
|
1719
|
+
"""
|
|
1720
|
+
searchBlocks(query: String!, limit: Int): BlockSearchResult!
|
|
1721
|
+
"""
|
|
1722
|
+
Health of every registered piece trigger (poll schedule, errors).
|
|
1723
|
+
"""
|
|
1724
|
+
triggerStates: [TriggerStateRecord!]!
|
|
1725
|
+
"""
|
|
1726
|
+
Authored output shape of a block (SDL / outputSchema / sampleData).
|
|
1727
|
+
"""
|
|
1728
|
+
blockOutputTree(block: BlockInput!, config: Unknown): Unknown
|
|
1729
|
+
"""
|
|
1730
|
+
Output shape of a draft step or trigger: its latest test output
|
|
1731
|
+
(source "test", with sample and testedAt) when tested, else as blockOutputTree.
|
|
1732
|
+
"""
|
|
1733
|
+
stepOutputTree(workflowId: String!, stepId: String!): Unknown
|
|
1734
|
+
"""
|
|
1735
|
+
Every block of the draft (trigger first) as this reactor resolves it:
|
|
1736
|
+
which piece version runs, from where, and how it matches the pin.
|
|
1737
|
+
"""
|
|
1738
|
+
blockResolutions(workflowId: String!): [BlockResolutionRecord!]!
|
|
1739
|
+
"""
|
|
1740
|
+
Every powerhouse/connection document, for connection pickers.
|
|
1741
|
+
"""
|
|
1742
|
+
connections: [ConnectionRecord!]!
|
|
1743
|
+
"""
|
|
1744
|
+
The webhook endpoint for a workflow: the URL to hand the provider. Minted
|
|
1745
|
+
on first ask, whether or not the workflow is armed — an author needs the
|
|
1746
|
+
URL before enabling, and the armed field carries that difference. Null
|
|
1747
|
+
only when the host has no webhook service. With driveId, a workflow not
|
|
1748
|
+
synced here yet is waited for briefly, then fails as WORKFLOW_SYNCING.
|
|
1749
|
+
"""
|
|
1750
|
+
webhookEndpoint(workflowId: String!, driveId: String): WebhookEndpointRecord
|
|
1751
|
+
"""
|
|
1752
|
+
Secret metadata (label, version, status). Never the value.
|
|
1753
|
+
"""
|
|
1754
|
+
secret(ref: String!): SecretRecord
|
|
1755
|
+
secrets: [SecretRecord!]!
|
|
1756
|
+
"""
|
|
1757
|
+
The URL an OAuth2 app must register as its redirect. A bare path when
|
|
1758
|
+
this host does not know its public origin; resolve it against the URL
|
|
1759
|
+
this API is reached at. Null when the host serves no HTTP routes.
|
|
1760
|
+
"""
|
|
1761
|
+
oauthRedirectUri: String
|
|
1762
|
+
"""
|
|
1763
|
+
How an OAuth2 sign-in opened by startOAuth stands; null when unknown.
|
|
1764
|
+
"""
|
|
1765
|
+
oauthAttempt(state: String!): OAuthAttempt
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
type OAuthAttempt {
|
|
1769
|
+
connectionId: String!
|
|
1770
|
+
"PENDING | EXCHANGING | OK | ERROR"
|
|
1771
|
+
status: String!
|
|
1772
|
+
error: String
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
type OAuthStart {
|
|
1776
|
+
state: String!
|
|
1777
|
+
"Where to send the user to sign in."
|
|
1778
|
+
authorizationUrl: String!
|
|
1779
|
+
redirectUri: String!
|
|
1780
|
+
expiresAt: String!
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
"""
|
|
1784
|
+
One action or trigger of a piece, as a workflow pins it. The engine's own
|
|
1785
|
+
blocks are the piece @powerhousedao/piece-core.
|
|
1786
|
+
"""
|
|
1787
|
+
input BlockInput {
|
|
1788
|
+
pieceName: String!
|
|
1789
|
+
"Exact semver."
|
|
1790
|
+
pieceVersion: String!
|
|
1791
|
+
"The action or trigger name."
|
|
1792
|
+
name: String!
|
|
1793
|
+
"action | trigger"
|
|
1794
|
+
kind: String!
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
type BlockSearchHit {
|
|
1798
|
+
pieceName: String!
|
|
1799
|
+
"The version the hit is listed at; pin to it when picking the block."
|
|
1800
|
+
pieceVersion: String!
|
|
1801
|
+
"The action or trigger name."
|
|
1802
|
+
name: String!
|
|
1803
|
+
pieceDisplayName: String!
|
|
1804
|
+
logoUrl: String!
|
|
1805
|
+
displayName: String!
|
|
1806
|
+
description: String!
|
|
1807
|
+
"action | trigger"
|
|
1808
|
+
kind: String!
|
|
1809
|
+
"Triggers only: POLLING | WEBHOOK | APP_WEBHOOK | MANUAL"
|
|
1810
|
+
strategy: String
|
|
1811
|
+
"Why this block cannot run on this reactor; null when it can."
|
|
1812
|
+
unsupported: String
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
type BlockResolutionRecord {
|
|
1816
|
+
"The trigger's or step's id."
|
|
1817
|
+
stepId: String!
|
|
1818
|
+
pieceName: String!
|
|
1819
|
+
"The version the block pins."
|
|
1820
|
+
pieceVersion: String!
|
|
1821
|
+
"The action or trigger name."
|
|
1822
|
+
name: String!
|
|
1823
|
+
"action | trigger"
|
|
1824
|
+
kind: String!
|
|
1825
|
+
"The version that runs; null when nothing resolves."
|
|
1826
|
+
resolvedVersion: String
|
|
1827
|
+
"local | registry | activepieces | npm; null when nothing resolves."
|
|
1828
|
+
source: String
|
|
1829
|
+
"exact | compatible | fallback | installed | missing"
|
|
1830
|
+
match: String!
|
|
1831
|
+
"Why the version that runs differs from the pin, or why nothing resolves."
|
|
1832
|
+
note: String
|
|
1833
|
+
"The newest version any source offers."
|
|
1834
|
+
latestVersion: String
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
type BlockSearchResult {
|
|
1838
|
+
"ready | indexing | error"
|
|
1839
|
+
status: String!
|
|
1840
|
+
hits: [BlockSearchHit!]!
|
|
1841
|
+
indexedPieces: Int!
|
|
1842
|
+
error: String
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
type SecretRecord {
|
|
1846
|
+
ref: String!
|
|
1847
|
+
label: String
|
|
1848
|
+
version: Int!
|
|
1849
|
+
status: String!
|
|
1850
|
+
createdAt: String!
|
|
1851
|
+
updatedAt: String!
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
type ConnectionRecord {
|
|
1855
|
+
id: String!
|
|
1856
|
+
name: String!
|
|
1857
|
+
connectorId: String!
|
|
1858
|
+
authType: String!
|
|
1859
|
+
status: String!
|
|
1860
|
+
accountLabel: String
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
type ReactorIdentity {
|
|
1864
|
+
"The address the host signs as; null without a signed-in identity."
|
|
1865
|
+
address: String
|
|
1866
|
+
"The did:key that signs the host's operations."
|
|
1867
|
+
key: String!
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
type ConnectionCheckResult {
|
|
1871
|
+
ok: Boolean!
|
|
1872
|
+
detail: String
|
|
1873
|
+
"""
|
|
1874
|
+
From auth.getConnectionIdentifier when the check passes; otherwise the
|
|
1875
|
+
label the connection already holds.
|
|
1876
|
+
"""
|
|
1877
|
+
accountLabel: String
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
type WebhookEndpointRecord {
|
|
1881
|
+
workflowId: String!
|
|
1882
|
+
url: String!
|
|
1883
|
+
"""
|
|
1884
|
+
False when the host gave the webhook service no public origin, so the url
|
|
1885
|
+
above is a bare path. Switchboard always gives one, falling back to
|
|
1886
|
+
http://localhost:<port> when PUBLIC_URL is unset.
|
|
1887
|
+
"""
|
|
1888
|
+
absoluteUrl: Boolean!
|
|
1889
|
+
"True while the workflow is ENABLED with a valid webhook trigger"
|
|
1890
|
+
armed: Boolean!
|
|
1891
|
+
createdAt: String!
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
type TriggerStateRecord {
|
|
1895
|
+
workflowId: String!
|
|
1896
|
+
pieceName: String!
|
|
1897
|
+
triggerName: String!
|
|
1898
|
+
status: String!
|
|
1899
|
+
intervalMs: Int!
|
|
1900
|
+
nextPollAt: String
|
|
1901
|
+
lastPollAt: String
|
|
1902
|
+
lastError: String
|
|
1903
|
+
consecutiveFailures: Int!
|
|
1904
|
+
"When a webhook trigger next renews its subscription; null if it never does."
|
|
1905
|
+
nextRenewAt: String
|
|
1906
|
+
"The last failed renewal, kept apart from lastError, the poll's."
|
|
1907
|
+
renewError: String
|
|
1908
|
+
renewFailures: Int!
|
|
1909
|
+
"The piece version the trigger armed with; null for a host-fed trigger."
|
|
1910
|
+
pieceVersion: String
|
|
1911
|
+
pieceSource: String
|
|
1912
|
+
versionMatch: String
|
|
1913
|
+
versionNote: String
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
type Query {
|
|
1917
|
+
workflowRuntime: WorkflowRuntimeQueries!
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
type WorkflowStepRunRecord {
|
|
1921
|
+
stepId: String!
|
|
1922
|
+
stepKey: String!
|
|
1923
|
+
pieceName: String!
|
|
1924
|
+
"The action the step ran, or the trigger a trigger test sampled."
|
|
1925
|
+
blockName: String!
|
|
1926
|
+
status: String!
|
|
1927
|
+
input: Unknown
|
|
1928
|
+
output: Unknown
|
|
1929
|
+
port: String
|
|
1930
|
+
error: String
|
|
1931
|
+
"The thrown error's name, e.g. ReactorAccessDeniedError."
|
|
1932
|
+
errorName: String
|
|
1933
|
+
startedAt: String
|
|
1934
|
+
endedAt: String
|
|
1935
|
+
"The piece version that ran; null for an unresolved block."
|
|
1936
|
+
pieceVersion: String
|
|
1937
|
+
"local | registry | activepieces | npm"
|
|
1938
|
+
pieceSource: String
|
|
1939
|
+
"exact | compatible | fallback | installed | missing"
|
|
1940
|
+
versionMatch: String
|
|
1941
|
+
"Why the version that ran differs from the pin."
|
|
1942
|
+
versionNote: String
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
type WorkflowRunRecord {
|
|
1946
|
+
id: String!
|
|
1947
|
+
workflowId: String!
|
|
1948
|
+
workflowName: String!
|
|
1949
|
+
workflowVersion: Int!
|
|
1950
|
+
triggerKind: String!
|
|
1951
|
+
triggerPayload: Unknown
|
|
1952
|
+
status: String!
|
|
1953
|
+
error: String
|
|
1954
|
+
errorName: String
|
|
1955
|
+
"When the run began executing; a PENDING run's is when it was journaled."
|
|
1956
|
+
startedAt: String!
|
|
1957
|
+
endedAt: String
|
|
1958
|
+
rerunOf: String
|
|
1959
|
+
"How many warningNotes the run has; a run with any is not a plain success."
|
|
1960
|
+
warnings: Int!
|
|
1961
|
+
"Steps that ran a fallback piece version, edges on ports nothing emits."
|
|
1962
|
+
warningNotes: [String!]!
|
|
1963
|
+
steps: [WorkflowStepRunRecord!]!
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
input WorkflowRunsPagingInput {
|
|
1967
|
+
"Page size; default 25, at most 100."
|
|
1968
|
+
limit: Int
|
|
1969
|
+
"The cursor of the previous page."
|
|
1970
|
+
cursor: String
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
type WorkflowRunResultPage {
|
|
1974
|
+
items: [WorkflowRunRecord!]!
|
|
1975
|
+
hasNextPage: Boolean!
|
|
1976
|
+
"True when the page was read after a cursor."
|
|
1977
|
+
hasPreviousPage: Boolean!
|
|
1978
|
+
"Resumes after this page's last run; null for an empty page."
|
|
1979
|
+
cursor: String
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
type WorkflowStepRun {
|
|
1983
|
+
stepId: String!
|
|
1984
|
+
key: String!
|
|
1985
|
+
pieceName: String!
|
|
1986
|
+
blockName: String!
|
|
1987
|
+
status: String!
|
|
1988
|
+
input: Unknown
|
|
1989
|
+
output: Unknown
|
|
1990
|
+
port: String
|
|
1991
|
+
error: String
|
|
1992
|
+
errorName: String
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
type WorkflowRunPayload {
|
|
1996
|
+
runId: String
|
|
1997
|
+
status: String!
|
|
1998
|
+
error: String
|
|
1999
|
+
errorName: String
|
|
2000
|
+
steps: [WorkflowStepRun!]!
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
type WorkflowStepTestResult {
|
|
2004
|
+
"Null when the test never started, e.g. an upstream block is untested."
|
|
2005
|
+
runId: String
|
|
2006
|
+
"""
|
|
2007
|
+
SUCCEEDED | FAILED | INDETERMINATE. INDETERMINATE is neither: a host call
|
|
2008
|
+
the block made timed out, so a write it asked for may well have landed.
|
|
2009
|
+
Show it distinctly - it is not a confirmed pass.
|
|
2010
|
+
"""
|
|
2011
|
+
status: String!
|
|
2012
|
+
output: Unknown
|
|
2013
|
+
error: String
|
|
2014
|
+
errorName: String
|
|
2015
|
+
durationMs: Int!
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
"""
|
|
2019
|
+
WorkflowRuntime Mutations
|
|
2020
|
+
"""
|
|
2021
|
+
type WorkflowRuntimeMutations {
|
|
2022
|
+
"""
|
|
2023
|
+
Fires a workflow's manual trigger (the core piece's) and runs it to completion.
|
|
2024
|
+
"""
|
|
2025
|
+
fire(workflowId: String!, payload: Unknown): WorkflowRunPayload!
|
|
2026
|
+
"""
|
|
2027
|
+
Samples the draft trigger and saves it as the trigger's last test. A piece
|
|
2028
|
+
trigger runs its test hook (no cursor changes). Of the core piece's
|
|
2029
|
+
triggers, manual takes payload, schedule samples a fire now, and webhook
|
|
2030
|
+
waits for the next delivery to the workflow's endpoint, up to
|
|
2031
|
+
timeoutSeconds (default and cap 300), and that delivery runs nothing.
|
|
2032
|
+
"""
|
|
2033
|
+
testTrigger(
|
|
2034
|
+
workflowId: String!
|
|
2035
|
+
payload: Unknown
|
|
2036
|
+
timeoutSeconds: Int
|
|
2037
|
+
driveId: String
|
|
2038
|
+
): Unknown
|
|
2039
|
+
"""
|
|
2040
|
+
Stops a webhook trigger test that is waiting; false when none was.
|
|
2041
|
+
"""
|
|
2042
|
+
cancelTriggerTest(workflowId: String!): Boolean!
|
|
2043
|
+
"""
|
|
2044
|
+
Runs one draft step against the latest test outputs of the blocks it
|
|
2045
|
+
reads, journaled as a "test" run and noted as the step's lastTest.
|
|
2046
|
+
driveId waits for an unsynced workflow, as for webhookEndpoint.
|
|
2047
|
+
"""
|
|
2048
|
+
testStep(
|
|
2049
|
+
workflowId: String!
|
|
2050
|
+
stepId: String!
|
|
2051
|
+
driveId: String
|
|
2052
|
+
): WorkflowStepTestResult!
|
|
2053
|
+
"""
|
|
2054
|
+
Resumes a FAILED run: succeeded steps replay from the journal,
|
|
2055
|
+
execution restarts at the failure. Produces a new run.
|
|
2056
|
+
"""
|
|
2057
|
+
rerun(runId: String!): WorkflowRunPayload!
|
|
2058
|
+
"""
|
|
2059
|
+
Mints a managed secret and returns its ref; the value is stored
|
|
2060
|
+
encrypted and is never readable back over any API.
|
|
2061
|
+
"""
|
|
2062
|
+
createSecret(value: String!, label: String): SecretRecord!
|
|
2063
|
+
"""
|
|
2064
|
+
Replaces the value behind an existing ref; documents stay untouched.
|
|
2065
|
+
"""
|
|
2066
|
+
rotateSecret(ref: String!, value: String!): SecretRecord!
|
|
2067
|
+
"""
|
|
2068
|
+
Tombstones a secret; its value becomes unrecoverable.
|
|
2069
|
+
"""
|
|
2070
|
+
deleteSecret(ref: String!): Boolean!
|
|
2071
|
+
"""
|
|
2072
|
+
Runs the piece's auth.validate against the connection's credentials,
|
|
2073
|
+
then auth.getConnectionIdentifier for the account label, and records the
|
|
2074
|
+
outcome on the connection document.
|
|
2075
|
+
"""
|
|
2076
|
+
checkConnection(connectionId: String!): ConnectionCheckResult!
|
|
2077
|
+
"""
|
|
2078
|
+
Opens an OAuth2 sign-in with the connection's own app (client_id in its
|
|
2079
|
+
config, client_secret in its secretRefs). redirectUri is needed only when
|
|
2080
|
+
oauthRedirectUri is a bare path. With returnUrl, which must be on the
|
|
2081
|
+
caller's origin, the callback sends the browser back there with
|
|
2082
|
+
?ph_oauth=<state> instead of closing the window.
|
|
2083
|
+
"""
|
|
2084
|
+
startOAuth(
|
|
2085
|
+
connectionId: String!
|
|
2086
|
+
redirectUri: String
|
|
2087
|
+
returnUrl: String
|
|
2088
|
+
): OAuthStart!
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
type Mutation {
|
|
2092
|
+
workflowRuntime: WorkflowRuntimeMutations!
|
|
2093
|
+
}
|
|
2094
|
+
`;
|
|
2095
|
+
//#endregion
|
|
2096
|
+
//#region src/workflow/subgraph.ts
|
|
2097
|
+
/** The runtime's read/write surface. The runtime itself is composed by the
|
|
2098
|
+
* host, so the subgraph only serves what it is handed. */
|
|
2099
|
+
function createWorkflowRuntimeSubgraph(runtime, oauth, access) {
|
|
2100
|
+
return class WorkflowRuntimeSubgraph extends BaseSubgraph {
|
|
2101
|
+
name = "workflow-runtime";
|
|
2102
|
+
typeDefs = schema;
|
|
2103
|
+
resolvers = getResolvers(runtime, this.authorizationService, oauth, access, (documentId, ctx) => this.assertCanRead(documentId, ctx));
|
|
2104
|
+
additionalContextFields = {};
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
//#endregion
|
|
2108
|
+
//#region src/workflow-runtime.mts
|
|
2109
|
+
/** The npm name the workflow package owns: its HTTP namespace and its models. */
|
|
2110
|
+
const WORKFLOW_PACKAGE_NAME = "@powerhousedao/workflow";
|
|
2111
|
+
const WORKFLOW_TELEMETRY_SCOPE = "@powerhousedao/reactor-workflow";
|
|
2112
|
+
/** The env var and OpenFeature flag key that turns workflows on. */
|
|
2113
|
+
const PH_WORKFLOWS_ENABLED = "PH_WORKFLOWS_ENABLED";
|
|
2114
|
+
/** Precedence, unchanged from the reactor-api resolver this replaces: the
|
|
2115
|
+
* host's option, then PH_WORKFLOWS_ENABLED, then the config file, then off. */
|
|
2116
|
+
async function resolveWorkflowsEnabled({ featureFlags, override, configEnabled = false, env = process.env }) {
|
|
2117
|
+
if (override !== void 0) return override;
|
|
2118
|
+
const raw = env[PH_WORKFLOWS_ENABLED]?.trim();
|
|
2119
|
+
if (raw === "1") return true;
|
|
2120
|
+
if (raw === "0") return false;
|
|
2121
|
+
return featureFlags.getBooleanValue(PH_WORKFLOWS_ENABLED, configEnabled);
|
|
2122
|
+
}
|
|
2123
|
+
async function assertWorkflowPackageLoadable(load = () => import("@powerhousedao/workflow/document-models")) {
|
|
2124
|
+
try {
|
|
2125
|
+
await load();
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
throw new Error(`Workflows are enabled but ${WORKFLOW_PACKAGE_NAME} could not be loaded`, { cause: error });
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
function modelManifestSource(builder) {
|
|
2131
|
+
return {
|
|
2132
|
+
modelManifest: () => builder.getResolvedModelManifest() ?? [],
|
|
2133
|
+
modelEntries: (documentType) => builder.getImportableEntries(documentType)
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
function readAssertion(authorizationService, reactorClient) {
|
|
2137
|
+
const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
|
|
2138
|
+
const hostCanRead = async (identifier, ctx) => {
|
|
2139
|
+
if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
|
|
2140
|
+
if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
|
|
2141
|
+
let documentId;
|
|
2142
|
+
try {
|
|
2143
|
+
documentId = await resolveCanonical(identifier);
|
|
2144
|
+
} catch {
|
|
2145
|
+
throw new ForbiddenError();
|
|
2146
|
+
}
|
|
2147
|
+
if (!await authorizationService.canRead(documentId, ctx.user?.address)) throw new ForbiddenError("to read this document");
|
|
2148
|
+
};
|
|
2149
|
+
return async (identifier, caller) => {
|
|
2150
|
+
const ctx = caller;
|
|
2151
|
+
await hostCanRead(identifier, ctx);
|
|
2152
|
+
let served;
|
|
2153
|
+
try {
|
|
2154
|
+
served = await reactorClient.isServed(identifier, { subject: callerSubject(ctx.user) });
|
|
2155
|
+
} catch {
|
|
2156
|
+
throw new ForbiddenError();
|
|
2157
|
+
}
|
|
2158
|
+
if (!served) throw new ForbiddenError("to read this document");
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
/** The same, for a design-time call that writes what it names. */
|
|
2162
|
+
function writeAssertion(authorizationService, reactorClient) {
|
|
2163
|
+
const resolveCanonical = createCanonicalDocumentIdResolver(reactorClient);
|
|
2164
|
+
return async (identifier, caller) => {
|
|
2165
|
+
const ctx = caller;
|
|
2166
|
+
if (authorizationService.isSupremeAdmin(ctx.user?.address)) return;
|
|
2167
|
+
if (authorizationService.config.policy !== AuthorizationPolicy.DOCUMENT_PERMISSIONS) throw new ForbiddenError();
|
|
2168
|
+
let documentId;
|
|
2169
|
+
try {
|
|
2170
|
+
documentId = await resolveCanonical(identifier);
|
|
2171
|
+
} catch {
|
|
2172
|
+
throw new ForbiddenError();
|
|
2173
|
+
}
|
|
2174
|
+
if (!await authorizationService.canWrite(documentId, ctx.user?.address)) throw new ForbiddenError("to write this document");
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
/** What the reactor enforces and who it signs as; unknown counts as enforced. */
|
|
2178
|
+
function reactorAccessOf(clientModule) {
|
|
2179
|
+
const flags = clientModule?.reactorModule?.featureFlags;
|
|
2180
|
+
const signer = clientModule?.signer;
|
|
2181
|
+
const key = signer?.app?.key;
|
|
2182
|
+
const identity = key ? {
|
|
2183
|
+
address: signer.user?.address ?? null,
|
|
2184
|
+
key
|
|
2185
|
+
} : null;
|
|
2186
|
+
return {
|
|
2187
|
+
authEnforcement: flags ? flags.authEnforcement : true,
|
|
2188
|
+
authConditions: flags?.authConditions === true,
|
|
2189
|
+
identity
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
/** Whom a run's created documents also grant: the host, as §8 grants it. */
|
|
2193
|
+
function hostPrincipalOf(access) {
|
|
2194
|
+
const identity = access.identity;
|
|
2195
|
+
if (!identity) return void 0;
|
|
2196
|
+
if (access.authConditions) return { match: { eq: [{ attr: "subject.key" }, { lit: identity.key }] } };
|
|
2197
|
+
return identity.address ? { address: identity.address } : void 0;
|
|
2198
|
+
}
|
|
2199
|
+
/** Who the host signs as, so a publish it signs makes no run user. */
|
|
2200
|
+
function hostIdentityOf(access) {
|
|
2201
|
+
const identity = access.identity;
|
|
2202
|
+
if (!identity) return void 0;
|
|
2203
|
+
return identity.address ? {
|
|
2204
|
+
address: identity.address,
|
|
2205
|
+
key: identity.key
|
|
2206
|
+
} : { key: identity.key };
|
|
2207
|
+
}
|
|
2208
|
+
const REFERENCE_INDEX_RETRIES = 8;
|
|
2209
|
+
const REFERENCE_INDEX_RETRY_MS = 250;
|
|
2210
|
+
/** A step reads an attachment the way the download route serves one: when the
|
|
2211
|
+
* run's user may read a document that references it. The candidates are the
|
|
2212
|
+
* documents the run was handed; the reference index can trail the operation
|
|
2213
|
+
* that fired the trigger, so a denial is retried briefly. */
|
|
2214
|
+
function attachmentReadPolicy(access, retryMs = REFERENCE_INDEX_RETRY_MS) {
|
|
2215
|
+
return async (request) => {
|
|
2216
|
+
try {
|
|
2217
|
+
if (parseRef(request.ref).version !== 1) return false;
|
|
2218
|
+
} catch {
|
|
2219
|
+
return false;
|
|
2220
|
+
}
|
|
2221
|
+
if (!access || request.documentIds.length === 0) return false;
|
|
2222
|
+
const subject = request.runUser?.subject;
|
|
2223
|
+
const caller = {
|
|
2224
|
+
...subject?.address ? { userAddress: subject.address } : {},
|
|
2225
|
+
...subject?.key ? { appKey: subject.key } : {}
|
|
2226
|
+
};
|
|
2227
|
+
for (let attempt = 0; attempt <= REFERENCE_INDEX_RETRIES; attempt++) {
|
|
2228
|
+
for (const documentId of request.documentIds) if ((await access.canReadAttachment({
|
|
2229
|
+
documentId,
|
|
2230
|
+
attachmentRef: request.ref,
|
|
2231
|
+
...caller
|
|
2232
|
+
})).kind === "allowed") return true;
|
|
2233
|
+
if (attempt < REFERENCE_INDEX_RETRIES) await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
2234
|
+
}
|
|
2235
|
+
return false;
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
async function registerWorkflowTriggersReadModel(engine, runtime, clientModule) {
|
|
2239
|
+
const reactorModule = clientModule?.reactorModule;
|
|
2240
|
+
if (!reactorModule) return {
|
|
2241
|
+
status: "unavailable",
|
|
2242
|
+
reason: "in-process-reactor-module-unavailable"
|
|
2243
|
+
};
|
|
2244
|
+
const coordinator = reactorModule.readModelCoordinator;
|
|
2245
|
+
if (!supportsLiveReadModelRegistration(coordinator)) return {
|
|
2246
|
+
status: "unavailable",
|
|
2247
|
+
reason: "live-read-model-registration-unsupported"
|
|
2248
|
+
};
|
|
2249
|
+
const readModel = new engine.WorkflowTriggersReadModel(reactorModule.database.withSchema(REACTOR_SCHEMA), reactorModule.operationIndex, reactorModule.writeCache, reactorModule.processorManagerConsistencyTracker, runtime);
|
|
2250
|
+
await readModel.init();
|
|
2251
|
+
coordinator.addReadModel(readModel, engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE);
|
|
2252
|
+
return { status: "available" };
|
|
2253
|
+
}
|
|
2254
|
+
function bindPackagePieces(registry, source) {
|
|
2255
|
+
const apply = (byPackage) => {
|
|
2256
|
+
registry.setPieces([...byPackage.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).flatMap(([, pieces]) => pieces));
|
|
2257
|
+
};
|
|
2258
|
+
apply(source.getPieces());
|
|
2259
|
+
source.onPiecesChange(apply);
|
|
2260
|
+
}
|
|
2261
|
+
async function composeWorkflowRuntime(deps) {
|
|
2262
|
+
const load = deps.load ?? (() => import("@powerhousedao/reactor-workflow"));
|
|
2263
|
+
let engine;
|
|
2264
|
+
try {
|
|
2265
|
+
engine = await load();
|
|
2266
|
+
} catch (error) {
|
|
2267
|
+
throw new Error("Workflows are enabled but @powerhousedao/reactor-workflow could not be loaded", { cause: error });
|
|
2268
|
+
}
|
|
2269
|
+
engine.setPieceRegistryUrl(deps.pieceRegistryUrl);
|
|
2270
|
+
if (deps.pieces) bindPackagePieces(engine.packagePieces, deps.pieces);
|
|
2271
|
+
const access = reactorAccessOf(deps.clientModule);
|
|
2272
|
+
const models = deps.models;
|
|
2273
|
+
const runtime = engine.createWorkflowRuntime({
|
|
2274
|
+
relationalDb: deps.relationalDb,
|
|
2275
|
+
secretsKeyFile: deps.secretsKeyFile,
|
|
2276
|
+
reactorClient: deps.reactorClient,
|
|
2277
|
+
assertCanRead: readAssertion(deps.authorizationService, deps.reactorClient),
|
|
2278
|
+
subjectOf: (caller) => callerSubject(caller.user),
|
|
2279
|
+
assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
|
|
2280
|
+
...deps.clientModule?.reactorModule ? { authEnforcement: access.authEnforcement } : {},
|
|
2281
|
+
hostPrincipal: hostPrincipalOf(access),
|
|
2282
|
+
hostIdentity: hostIdentityOf(access),
|
|
2283
|
+
...models ? {
|
|
2284
|
+
modelManifest: () => models.modelManifest(),
|
|
2285
|
+
modelEntries: (documentType) => models.modelEntries(documentType)
|
|
2286
|
+
} : {},
|
|
2287
|
+
webhooks: deps.webhooks,
|
|
2288
|
+
attachments: deps.attachments,
|
|
2289
|
+
canReadAttachmentRef: attachmentReadPolicy(deps.attachmentAccess),
|
|
2290
|
+
logger: deps.logger,
|
|
2291
|
+
...deps.telemetry ? { telemetry: deps.telemetry } : {}
|
|
2292
|
+
});
|
|
2293
|
+
const triggers = await registerWorkflowTriggersReadModel(engine, runtime, deps.clientModule);
|
|
2294
|
+
if (triggers.status === "available") deps.logger.info(`Workflow trigger read model registered (${engine.WORKFLOW_TRIGGERS_READ_MODEL}, ${engine.WORKFLOW_TRIGGERS_READ_MODEL_STAGE})`);
|
|
2295
|
+
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);
|
|
2296
|
+
let stopped = false;
|
|
2297
|
+
const oauthCallback = deps.http ? registerOAuthCallback(deps.http, runtime) : void 0;
|
|
2298
|
+
return {
|
|
2299
|
+
subgraph: createWorkflowRuntimeSubgraph(runtime, deps.http ? { callbackUrl: callbackUrlOf(deps.http) } : void 0, access),
|
|
2300
|
+
triggers,
|
|
2301
|
+
async start() {
|
|
2302
|
+
await runtime.registerWebhookEndpoint();
|
|
2303
|
+
runtime.startTriggerSupervisor();
|
|
2304
|
+
},
|
|
2305
|
+
stop() {
|
|
2306
|
+
if (stopped) return Promise.resolve();
|
|
2307
|
+
stopped = true;
|
|
2308
|
+
oauthCallback?.dispose();
|
|
2309
|
+
runtime.shutdown();
|
|
2310
|
+
return Promise.resolve();
|
|
2311
|
+
}
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
//#endregion
|
|
2315
|
+
//#region src/pglite-dialect.ts
|
|
2316
|
+
/** A poisoned store cannot recover in process, so it takes the fatal shutdown and a supervisor restart. */
|
|
2317
|
+
function reactorPgliteDialectOptions(logger, proc) {
|
|
2318
|
+
return {
|
|
2319
|
+
onDiagnostic: (message, error) => logger.error(`[pglite-dialect] ${message}: @error`, error),
|
|
2320
|
+
onPoisoned: (cause) => {
|
|
2321
|
+
if (!triggerFatalShutdown("PGlite session poisoned", cause, proc)) logger.error("PGlite session poisoned: @error", cause);
|
|
2322
|
+
}
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
var ClosablePGliteDialect = class {
|
|
2326
|
+
#inner;
|
|
2327
|
+
#pglite;
|
|
2328
|
+
constructor(pglite, options = {}) {
|
|
2329
|
+
this.#pglite = pglite;
|
|
2330
|
+
this.#inner = new HardenedPGliteDialect(pglite, options);
|
|
2331
|
+
}
|
|
2332
|
+
createAdapter() {
|
|
2333
|
+
return this.#inner.createAdapter();
|
|
2334
|
+
}
|
|
2335
|
+
createDriver() {
|
|
2336
|
+
const driver = this.#inner.createDriver();
|
|
2337
|
+
const pglite = this.#pglite;
|
|
2338
|
+
const innerDestroy = driver.destroy.bind(driver);
|
|
2339
|
+
driver.destroy = async () => {
|
|
2340
|
+
await innerDestroy();
|
|
2341
|
+
if (pglite.closed) return;
|
|
2342
|
+
try {
|
|
2343
|
+
await pglite.close();
|
|
2344
|
+
} catch (err) {
|
|
2345
|
+
if (!/PGlite aborted/.test(String(err))) throw err;
|
|
2346
|
+
}
|
|
2347
|
+
};
|
|
2348
|
+
return driver;
|
|
2349
|
+
}
|
|
2350
|
+
createQueryCompiler() {
|
|
2351
|
+
return this.#inner.createQueryCompiler();
|
|
2352
|
+
}
|
|
2353
|
+
createIntrospector(db) {
|
|
2354
|
+
return this.#inner.createIntrospector(db);
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
//#endregion
|
|
2358
|
+
//#region src/pglite-migration.ts
|
|
2359
|
+
function backupPath(dataDir, major) {
|
|
2360
|
+
return `${dataDir}.backup-pg${major}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
2361
|
+
}
|
|
2362
|
+
async function pathExists(p) {
|
|
2363
|
+
try {
|
|
2364
|
+
await promises.stat(p);
|
|
2365
|
+
return true;
|
|
2366
|
+
} catch {
|
|
2367
|
+
return false;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
function logRestoreFailure(dataDir, sql, err, logger) {
|
|
2371
|
+
const errObj = err;
|
|
2372
|
+
const position = typeof errObj.position === "string" ? parseInt(errObj.position, 10) : typeof errObj.position === "number" ? errObj.position : NaN;
|
|
2373
|
+
logger.error(`[pglite-migration] Restore failed for ${dataDir}: code=${errObj.code ?? ""} severity=${errObj.severity ?? ""} message=${errObj.message ?? ""} sqlLength=${sql.length}`);
|
|
2374
|
+
if (Number.isFinite(position) && position > 0) {
|
|
2375
|
+
const zeroBased = position - 1;
|
|
2376
|
+
const start = Math.max(0, zeroBased - 200);
|
|
2377
|
+
const end = Math.min(sql.length, zeroBased + 200);
|
|
2378
|
+
const before = sql.slice(start, zeroBased);
|
|
2379
|
+
const at = sql.slice(zeroBased, zeroBased + 1);
|
|
2380
|
+
const after = sql.slice(zeroBased + 1, end);
|
|
2381
|
+
logger.error(`[pglite-migration] SQL context around position ${position}:\n${before}»${at}«${after}`);
|
|
2382
|
+
} else logger.error(`[pglite-migration] No position info. First 2000 chars of dump:\n${sql.slice(0, 2e3)}`);
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Migrate a filesystem PGLite data directory from a legacy PG major to the
|
|
2386
|
+
* current one. Renames the existing dir to a timestamped backup, dumps via the
|
|
2387
|
+
* matching legacy `pg_dump`, restores into a fresh current-version PGLite at
|
|
2388
|
+
* the original path. On failure, the original dir is restored from the backup.
|
|
2389
|
+
*
|
|
2390
|
+
* No-op when the dir is missing or already at the current major.
|
|
2391
|
+
*/
|
|
2392
|
+
async function migratePgliteDir(dataDir, logger) {
|
|
2393
|
+
const major = await readPgVersionFile(dataDir);
|
|
2394
|
+
if (major === null) {
|
|
2395
|
+
logger.info(`[pglite-migration] No PG_VERSION at ${dataDir}; skipping migration`);
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
if (major === 17) return;
|
|
2399
|
+
if (!isSupportedMajor(major)) throw new Error(`Unsupported legacy PGlite data dir: PG_VERSION=${major} for ${dataDir}`);
|
|
2400
|
+
const backupDir = backupPath(dataDir, major);
|
|
2401
|
+
logger.info(`[pglite-migration] Migrating ${dataDir} from PG${major} to PG17; backup: ${backupDir}`);
|
|
2402
|
+
await promises.rename(dataDir, backupDir);
|
|
2403
|
+
let sql;
|
|
2404
|
+
try {
|
|
2405
|
+
const [legacyMod, pgDump] = await Promise.all([loadPGliteModule(major), loadPgDump(major)]);
|
|
2406
|
+
const LegacyPGlite = legacyMod.PGlite;
|
|
2407
|
+
const onDisk = new LegacyPGlite(backupDir);
|
|
2408
|
+
let tar;
|
|
2409
|
+
try {
|
|
2410
|
+
await onDisk.waitReady;
|
|
2411
|
+
tar = await onDisk.dumpDataDir("none");
|
|
2412
|
+
} finally {
|
|
2413
|
+
await onDisk.close();
|
|
2414
|
+
}
|
|
2415
|
+
const pg = new LegacyPGlite({ loadDataDir: tar });
|
|
2416
|
+
try {
|
|
2417
|
+
await pg.waitReady;
|
|
2418
|
+
sql = await (await pgDump({ pg })).text();
|
|
2419
|
+
} finally {
|
|
2420
|
+
await pg.close();
|
|
2421
|
+
}
|
|
2422
|
+
} catch (err) {
|
|
2423
|
+
await rollback(dataDir, backupDir, err, logger);
|
|
2424
|
+
throw err;
|
|
2425
|
+
}
|
|
2426
|
+
try {
|
|
2427
|
+
const CurrentPGlite = (await loadPGliteModule(17)).PGlite;
|
|
2428
|
+
const pg = new CurrentPGlite(dataDir, { relaxedDurability: false });
|
|
2429
|
+
try {
|
|
2430
|
+
await pg.waitReady;
|
|
2431
|
+
try {
|
|
2432
|
+
await pg.exec("SET standard_conforming_strings = off;");
|
|
2433
|
+
} catch (gucErr) {
|
|
2434
|
+
logger.warn(`[pglite-migration] Could not force standard_conforming_strings=off: ${String(gucErr)}`);
|
|
2435
|
+
}
|
|
2436
|
+
try {
|
|
2437
|
+
await pg.exec(sql);
|
|
2438
|
+
} catch (execErr) {
|
|
2439
|
+
logRestoreFailure(dataDir, sql, execErr, logger);
|
|
2440
|
+
throw execErr;
|
|
2441
|
+
}
|
|
2442
|
+
} finally {
|
|
2443
|
+
await pg.close();
|
|
2444
|
+
}
|
|
2445
|
+
syncTree(dataDir);
|
|
2446
|
+
} catch (err) {
|
|
2447
|
+
await rollback(dataDir, backupDir, err, logger);
|
|
2448
|
+
throw err;
|
|
2449
|
+
}
|
|
2450
|
+
logger.info(`[pglite-migration] Migration of ${dataDir} complete. Backup retained at ${backupDir}; remove it manually once you have verified the upgrade.`);
|
|
2451
|
+
}
|
|
2452
|
+
async function rollback(dataDir, backupDir, originalError, logger) {
|
|
2453
|
+
try {
|
|
2454
|
+
if (await pathExists(dataDir)) await promises.rm(dataDir, {
|
|
2455
|
+
recursive: true,
|
|
2456
|
+
force: true
|
|
2457
|
+
});
|
|
2458
|
+
if (await pathExists(backupDir)) await promises.rename(backupDir, dataDir);
|
|
2459
|
+
} catch (rollbackErr) {
|
|
2460
|
+
logger.error(`[pglite-migration] Migration AND rollback failed for ${dataDir}. Original error: ${String(originalError)}; rollback error: ${String(rollbackErr)}; backup may still exist at ${backupDir}.`);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
logger.error(`[pglite-migration] Migration failed for ${dataDir}; rolled back from ${backupDir}. Original error: ${String(originalError)}`);
|
|
2464
|
+
}
|
|
2465
|
+
//#endregion
|
|
2466
|
+
//#region src/pglite-preflight.ts
|
|
2467
|
+
const RM_OPTIONS = {
|
|
2468
|
+
recursive: true,
|
|
2469
|
+
force: true,
|
|
2470
|
+
maxRetries: 10,
|
|
2471
|
+
retryDelay: 100
|
|
2472
|
+
};
|
|
2473
|
+
/**
|
|
2474
|
+
* Prepares every local PGlite dir before the first open and returns the
|
|
2475
|
+
* PG major detected on disk per dir. Conversion of an AtomicNodeFs snapshot
|
|
2476
|
+
* runs for every dir, before the major is read, so a pure-snapshot PG16 dir
|
|
2477
|
+
* is converted and then migrated.
|
|
2478
|
+
*/
|
|
2479
|
+
async function runPglitePreflight(options) {
|
|
2480
|
+
const { dirs, logger } = options;
|
|
2481
|
+
const detectedMajors = /* @__PURE__ */ new Map();
|
|
2482
|
+
if (options.inMemory || dirs.length === 0) return detectedMajors;
|
|
2483
|
+
if (options.forcePgVersion !== void 0) {
|
|
2484
|
+
if (options.migratePglite) logger.warn("PH_FORCE_PG_VERSION is set; ignoring --migrate-pglite/PH_MIGRATE_PGLITE because the data dirs will be wiped.");
|
|
2485
|
+
logger.warn(`PH_FORCE_PG_VERSION=${options.forcePgVersion} set; wiping PGLite data dirs and re-initializing at PG${options.forcePgVersion}.`);
|
|
2486
|
+
for (const dir of dirs) {
|
|
2487
|
+
for (const p of [
|
|
2488
|
+
dir,
|
|
2489
|
+
`${dir}.converting`,
|
|
2490
|
+
`${dir}.old`
|
|
2491
|
+
]) await promises.rm(p, RM_OPTIONS);
|
|
2492
|
+
logger.info(`Wiped PGLite data dir ${dir}`);
|
|
2493
|
+
}
|
|
2494
|
+
return detectedMajors;
|
|
2495
|
+
}
|
|
2496
|
+
const deps = {
|
|
2497
|
+
openForVerify: options.openForVerify ?? openForVerify,
|
|
2498
|
+
logger
|
|
2499
|
+
};
|
|
2500
|
+
for (const dir of dirs) {
|
|
2501
|
+
await preparePgliteDataDir(dir, deps);
|
|
2502
|
+
const major = await readPgVersionFile(dir);
|
|
2503
|
+
if (major !== null) detectedMajors.set(dir, major);
|
|
2504
|
+
}
|
|
2505
|
+
if (options.migratePglite) for (const [dir, major] of detectedMajors) {
|
|
2506
|
+
if (major === 17) continue;
|
|
2507
|
+
await migratePgliteDir(dir, logger);
|
|
2508
|
+
await removeStalePgliteFiles(dir, logger);
|
|
2509
|
+
const after = await readPgVersionFile(dir);
|
|
2510
|
+
if (after !== null) detectedMajors.set(dir, after);
|
|
2511
|
+
}
|
|
2512
|
+
else for (const [dir, major] of detectedMajors) {
|
|
2513
|
+
if (major === 17) continue;
|
|
2514
|
+
logger.warn(`PGLite data dir at ${dir} was created with PG${major} but Switchboard ships PG17. Running on legacy PGLite. Re-start with --migrate-pglite (or PH_MIGRATE_PGLITE=true) to upgrade.`);
|
|
2515
|
+
}
|
|
2516
|
+
return detectedMajors;
|
|
2517
|
+
}
|
|
2518
|
+
//#endregion
|
|
2519
|
+
//#region src/reactor-feature-flags.mts
|
|
2520
|
+
/**
|
|
2521
|
+
* Enforcement flags from the REACTOR_* env vars. Each flag requires the ones
|
|
2522
|
+
* before it; the reactor rejects an inconsistent set rather than enforcing less
|
|
2523
|
+
* than the operator asked for, so this reports what was asked without
|
|
2524
|
+
* correcting it.
|
|
2525
|
+
*/
|
|
2526
|
+
function resolveReactorFeatureFlags(env) {
|
|
2527
|
+
const flags = {
|
|
2528
|
+
documentDecisions: env.REACTOR_DOCUMENT_DECISIONS === "true",
|
|
2529
|
+
authEnforcement: env.REACTOR_AUTH_ENFORCEMENT === "true",
|
|
2530
|
+
authGroups: env.REACTOR_AUTH_GROUPS === "true",
|
|
2531
|
+
authConditions: env.REACTOR_AUTH_CONDITIONS === "true"
|
|
2532
|
+
};
|
|
2533
|
+
return {
|
|
2534
|
+
flags,
|
|
2535
|
+
enabled: Object.entries(flags).filter(([, isEnabled]) => isEnabled).map(([name]) => name)
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
//#endregion
|
|
2539
|
+
//#region src/create-signature-policy.mts
|
|
2540
|
+
/**
|
|
2541
|
+
* What new documents are created as, from CREATE_SIGNATURE_POLICY. Without a
|
|
2542
|
+
* signer this switchboard cannot write to a v2-required document, so it falls
|
|
2543
|
+
* back to legacy and says so.
|
|
2544
|
+
*/
|
|
2545
|
+
function resolveCreateSignaturePolicy(env, options) {
|
|
2546
|
+
const raw = env.CREATE_SIGNATURE_POLICY?.trim();
|
|
2547
|
+
let policy = DEFAULT_SIGNATURE_POLICY;
|
|
2548
|
+
if (raw) {
|
|
2549
|
+
if (!isSignaturePolicy(raw)) throw new Error(`CREATE_SIGNATURE_POLICY must be "legacy" or "v2-required", got "${raw}"`);
|
|
2550
|
+
policy = raw;
|
|
2551
|
+
}
|
|
2552
|
+
if (policy === "v2-required" && !options.hasSigner) {
|
|
2553
|
+
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.");
|
|
2554
|
+
return "legacy";
|
|
2555
|
+
}
|
|
2556
|
+
return policy;
|
|
2557
|
+
}
|
|
2558
|
+
//#endregion
|
|
2559
|
+
//#region src/renown.ts
|
|
2560
|
+
const logger = childLogger(["switchboard", "renown"]);
|
|
2561
|
+
/**
|
|
2562
|
+
* Initialize Renown for the Switchboard instance.
|
|
2563
|
+
* This allows Switchboard to authenticate with remote services
|
|
2564
|
+
* using the same identity established during `ph login`.
|
|
2565
|
+
*/
|
|
2566
|
+
async function initRenown(options = {}) {
|
|
2567
|
+
const { keypairPath, requireExisting = false, baseUrl = DEFAULT_RENOWN_URL } = options;
|
|
2568
|
+
const keyStorage = new NodeKeyStorage(keypairPath, { logger });
|
|
2569
|
+
const existingKeyPair = await keyStorage.loadKeyPair();
|
|
2570
|
+
if (!existingKeyPair && requireExisting) throw new Error("No existing keypair found and requireExisting is true. Run \"ph login\" to create one.");
|
|
2571
|
+
if (!existingKeyPair) logger.info("No existing keypair found. A new one will be generated.");
|
|
2572
|
+
const renownCrypto = await new RenownCryptoBuilder().withKeyPairStorage(keyStorage).build();
|
|
2573
|
+
const renown = await new RenownBuilder("switchboard", {}).withCrypto(renownCrypto).withBaseUrl(baseUrl).build();
|
|
2574
|
+
logger.info("Switchboard identity initialized: @did", renownCrypto.did);
|
|
2575
|
+
return renown;
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Get the signer config for the given renown instance.
|
|
2579
|
+
*
|
|
2580
|
+
* @param renown - The renown instance
|
|
2581
|
+
* @param keypairPath - Where `initRenown` loaded the key from
|
|
2582
|
+
*/
|
|
2583
|
+
function getRenownSignerConfig(renown, keypairPath) {
|
|
2584
|
+
return {
|
|
2585
|
+
signer: renown.signer,
|
|
2586
|
+
workerSigner: getRenownWorkerSignerSpec(renown, keypairPath)
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
/**
|
|
2590
|
+
* What a pooled executor worker imports to sign as this switchboard: it
|
|
2591
|
+
* reloads the key `initRenown` stored, and takes the user known at boot.
|
|
2592
|
+
*/
|
|
2593
|
+
function getRenownWorkerSignerSpec(renown, keypairPath) {
|
|
2594
|
+
const { signer } = renown;
|
|
2595
|
+
const user = signer.user;
|
|
2596
|
+
return {
|
|
2597
|
+
module: {
|
|
2598
|
+
filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
|
|
2599
|
+
exportName: "createNodeRenownSigner"
|
|
2600
|
+
},
|
|
2601
|
+
initArgs: {
|
|
2602
|
+
appName: signer.app?.name ?? "switchboard",
|
|
2603
|
+
keypairPath: resolve(keypairPath ?? DEFAULT_KEYPAIR_PATH),
|
|
2604
|
+
...signer.app?.key ? { did: signer.app.key } : {},
|
|
2605
|
+
...user ? { user: {
|
|
2606
|
+
address: user.address,
|
|
2607
|
+
networkId: user.networkId,
|
|
2608
|
+
chainId: user.chainId
|
|
2609
|
+
} } : {}
|
|
2610
|
+
}
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* Admits a key as a signer for an address a Renown credential binds it to; a
|
|
2615
|
+
* `self` source has no worker spec. In process, the switchboard's own key is
|
|
2616
|
+
* matched against its current user; the worker spec carries the user known now.
|
|
2617
|
+
*/
|
|
2618
|
+
async function getRenownTrustPolicyConfig(trustSource, renown) {
|
|
2619
|
+
const ownSigner = renown?.signer;
|
|
2620
|
+
if (trustSource.source === "self") return { trustPolicy: createRenownTrustPolicy({
|
|
2621
|
+
switchboard: trustSource.request,
|
|
2622
|
+
ownSigner
|
|
2623
|
+
}) };
|
|
2624
|
+
const switchboardUrl = await resolveSwitchboardEndpoint({
|
|
2625
|
+
switchboardUrl: trustSource.switchboardUrl,
|
|
2626
|
+
baseUrl: trustSource.renownUrl
|
|
2627
|
+
});
|
|
2628
|
+
const options = {
|
|
2629
|
+
...switchboardUrl ? { switchboard: switchboardUrl } : {},
|
|
2630
|
+
...trustSource.renownUrl ? { renownUrl: trustSource.renownUrl } : {}
|
|
2631
|
+
};
|
|
2632
|
+
const self = ownIdentity(renown);
|
|
2633
|
+
return {
|
|
2634
|
+
trustPolicy: createRenownTrustPolicy({
|
|
2635
|
+
...options,
|
|
2636
|
+
ownSigner
|
|
2637
|
+
}),
|
|
2638
|
+
workerTrustPolicy: {
|
|
2639
|
+
module: {
|
|
2640
|
+
filePath: createRequire(import.meta.url).resolve("@renown/sdk/node"),
|
|
2641
|
+
exportName: "createRenownTrustPolicy"
|
|
2642
|
+
},
|
|
2643
|
+
initArgs: {
|
|
2644
|
+
...options,
|
|
2645
|
+
...self ? { self } : {}
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* Fails the boot when pooled workers would refuse every signed write the
|
|
2652
|
+
* in-process executor accepts: a `self` Renown source gives workers no trust
|
|
2653
|
+
* policy spec, so under authEnforcement they apply the default.
|
|
2654
|
+
*/
|
|
2655
|
+
function assertWorkerTrustPolicy(config) {
|
|
2656
|
+
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).");
|
|
2657
|
+
}
|
|
2658
|
+
function ownIdentity(renown) {
|
|
2659
|
+
const key = renown?.signer.app?.key;
|
|
2660
|
+
if (!key) return;
|
|
2661
|
+
const user = renown.signer.user;
|
|
2662
|
+
return {
|
|
2663
|
+
key,
|
|
2664
|
+
user: {
|
|
2665
|
+
address: user?.address ?? "",
|
|
2666
|
+
networkId: user?.networkId ?? "",
|
|
2667
|
+
chainId: user?.chainId ?? 0
|
|
2668
|
+
}
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
//#endregion
|
|
2672
|
+
//#region src/unsupported-stored-documents.mts
|
|
2673
|
+
const MODES = ["refuse", "read-only"];
|
|
2674
|
+
/** From REACTOR_UNSUPPORTED_STORED_DOCUMENTS; `input` wins when defined. */
|
|
2675
|
+
function resolveUnsupportedStoredDocuments(input, env) {
|
|
2676
|
+
if (input !== void 0) return input;
|
|
2677
|
+
const raw = env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS?.trim();
|
|
2678
|
+
if (!raw) return "refuse";
|
|
2679
|
+
if (!MODES.includes(raw)) throw new Error(`REACTOR_UNSUPPORTED_STORED_DOCUMENTS must be "refuse" or "read-only", got "${raw}"`);
|
|
2680
|
+
return raw;
|
|
2681
|
+
}
|
|
2682
|
+
/** The reactor refused to start over documents this build does not run. */
|
|
2683
|
+
var StoredDocumentsRefusedError = class extends Error {
|
|
2684
|
+
versions;
|
|
2685
|
+
documents;
|
|
2686
|
+
constructor(cause) {
|
|
2687
|
+
const named = cause.versions.map(({ protocol, version }) => `${protocol} ${version}`).join(", ");
|
|
2688
|
+
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 });
|
|
2689
|
+
this.name = "StoredDocumentsRefusedError";
|
|
2690
|
+
this.versions = cause.versions;
|
|
2691
|
+
this.documents = cause.documents;
|
|
2692
|
+
}
|
|
2693
|
+
static isError(error) {
|
|
2694
|
+
return Error.isError(error) && error.name === "StoredDocumentsRefusedError";
|
|
2695
|
+
}
|
|
2696
|
+
};
|
|
2697
|
+
//#endregion
|
|
2698
|
+
//#region src/server.mts
|
|
2699
|
+
const defaultLogger = childLogger(["switchboard"]);
|
|
2700
|
+
const LogLevel = process.env.LOG_LEVEL || "info";
|
|
2701
|
+
setLogLevel(LogLevel);
|
|
2702
|
+
dotenv.config();
|
|
2703
|
+
const DOCUMENT_MODEL_SUBGRAPHS_ENABLED = "DOCUMENT_MODEL_SUBGRAPHS_ENABLED";
|
|
2704
|
+
const DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT = true;
|
|
2705
|
+
const DEFAULT_PORT = process.env.PORT ? Number(process.env.PORT) : 4001;
|
|
2706
|
+
const PORT_FALLBACK_ATTEMPTS = 20;
|
|
2707
|
+
let flushIntervalWarned = false;
|
|
2708
|
+
function warnIgnoredFlushInterval(logger) {
|
|
2709
|
+
if (flushIntervalWarned) return;
|
|
2710
|
+
if (process.env.PGLITE_FLUSH_INTERVAL_MS === void 0) return;
|
|
2711
|
+
flushIntervalWarned = true;
|
|
2712
|
+
logger.warn("PGLITE_FLUSH_INTERVAL_MS is set but ignored: PGlite stores no longer snapshot on a timer.");
|
|
2713
|
+
}
|
|
2714
|
+
function onPgliteAbort(logger, what) {
|
|
2715
|
+
if (!triggerFatalShutdown("PGlite aborted", what)) logger.error("PGlite aborted: @error", what);
|
|
2716
|
+
}
|
|
2717
|
+
function pgliteFsyncFor(major, dir, logger) {
|
|
2718
|
+
const wanted = resolvePgliteFsync(process.env);
|
|
2719
|
+
if (wanted && major === 16) logger.warn(`PGlite data dir ${dir} is PG16 on pglite-legacy-02, which does not sync commits to disk; only checkpoints are synced until --migrate-pglite upgrades it.`);
|
|
2720
|
+
return wanted;
|
|
2721
|
+
}
|
|
2722
|
+
/** Preloads the module's NodeFS so the returned factory can be synchronous. */
|
|
2723
|
+
async function loadDurableFsFactory(major, logger) {
|
|
2724
|
+
const NodeFS = await loadNodeFsClass(major);
|
|
2725
|
+
return (dir) => createDurableNodeFs(NodeFS, dir, {
|
|
2726
|
+
fsync: pgliteFsyncFor(major, dir, logger),
|
|
2727
|
+
logger,
|
|
2728
|
+
onAbort: (what) => onPgliteAbort(logger, what)
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
const PGLITE_IN_MEMORY = process.env.PH_PGLITE_IN_MEMORY === "1";
|
|
2732
|
+
/**
|
|
2733
|
+
* Attempt to bind a throwaway TCP server to the given port. Resolves true if
|
|
2734
|
+
* the port is free, false if the OS reports it in use. Any other error is
|
|
2735
|
+
* surfaced so we don't silently mask real issues (permissions, bad host, …).
|
|
2736
|
+
*/
|
|
2737
|
+
function isPortAvailable(port) {
|
|
2738
|
+
return new Promise((resolve, reject) => {
|
|
2739
|
+
const tester = net.createServer();
|
|
2740
|
+
tester.once("error", (err) => {
|
|
2741
|
+
if (err.code === "EADDRINUSE" || err.code === "EACCES") resolve(false);
|
|
2742
|
+
else reject(err);
|
|
2743
|
+
});
|
|
2744
|
+
tester.once("listening", () => {
|
|
2745
|
+
tester.close(() => resolve(true));
|
|
2746
|
+
});
|
|
2747
|
+
tester.listen({
|
|
2748
|
+
port,
|
|
2749
|
+
host: "::"
|
|
2750
|
+
});
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
/** The powerhouse.config.json this run reads, defaulting to the cwd copy. */
|
|
2754
|
+
function resolveConfigPath(configFile) {
|
|
2755
|
+
return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
|
|
2756
|
+
}
|
|
2757
|
+
function readConfigWorkflowsEnabled(configPath) {
|
|
2758
|
+
try {
|
|
2759
|
+
return getConfig(configPath).workflows?.enabled ?? false;
|
|
2760
|
+
} catch {
|
|
2761
|
+
return false;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
async function resolveServerPort(requested, strictPort, logger) {
|
|
2765
|
+
if (strictPort) return requested;
|
|
2766
|
+
for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
|
|
2767
|
+
const candidate = requested + i;
|
|
2768
|
+
if (await isPortAvailable(candidate)) {
|
|
2769
|
+
if (candidate !== requested) logger.info(`Port ${requested} is in use. Falling back to port ${candidate}.`);
|
|
2770
|
+
return candidate;
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
return requested;
|
|
2774
|
+
}
|
|
2775
|
+
async function createReactorKysely(opts) {
|
|
2776
|
+
const { reactorDbUrl, reactorPgliteDir, reactorPgliteMajor, inMemory, hostPoolSize, logger } = opts;
|
|
2777
|
+
if (reactorDbUrl && isPostgresUrl(reactorDbUrl)) {
|
|
2778
|
+
const connectionString = reactorDbUrl.includes("?") ? reactorDbUrl : `${reactorDbUrl}?sslmode=disable`;
|
|
2779
|
+
const poolSize = hostPoolSize();
|
|
2780
|
+
const pool = new Pool({
|
|
2781
|
+
connectionString,
|
|
2782
|
+
max: poolSize
|
|
2783
|
+
});
|
|
2784
|
+
const poolInstrumentation = instrumentPgPool(pool, "reactor-host");
|
|
2785
|
+
logger.info(`Using PostgreSQL for reactor storage (host pool max ${poolSize})`);
|
|
2786
|
+
return {
|
|
2787
|
+
kysely: new Kysely({ dialect: new PostgresDialect({ pool }) }),
|
|
2788
|
+
poolInstrumentation
|
|
2789
|
+
};
|
|
2790
|
+
}
|
|
2791
|
+
if (!reactorPgliteDir || reactorPgliteMajor === null) throw new Error("Reactor PGLite directory not resolved");
|
|
2792
|
+
const { PGlite } = await loadPGliteModule(reactorPgliteMajor);
|
|
2793
|
+
const pglite = inMemory ? new PGlite() : new PGlite({ fs: (await loadDurableFsFactory(reactorPgliteMajor, logger))(reactorPgliteDir) });
|
|
2794
|
+
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}`);
|
|
2795
|
+
return {
|
|
2796
|
+
kysely: new Kysely({ dialect: new ClosablePGliteDialect(pglite, reactorPgliteDialectOptions(logger)) }),
|
|
2797
|
+
poolInstrumentation: void 0
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
/** Derive the remote attachment service config for switchboard's own `/attachments/*` API. */
|
|
2801
|
+
function deriveAttachmentServiceConfig(options, serverPort, renown) {
|
|
2802
|
+
const protocol = options.https ? "https" : "http";
|
|
2803
|
+
return {
|
|
2804
|
+
remoteUrl: options.attachmentServiceUrl ?? process.env.PH_SWITCHBOARD_PUBLIC_URL ?? `${protocol}://localhost:${serverPort}`,
|
|
2805
|
+
jwtHandler: renown ? async (url) => renown.user ? renown.getBearerToken({
|
|
2806
|
+
expiresIn: 10,
|
|
2807
|
+
aud: url
|
|
2808
|
+
}) : void 0 : void 0
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
async function initServer(serverPort, options, renown, renownConfig) {
|
|
2812
|
+
const { dev, packages = [], remoteDrives = [], logger = defaultLogger } = options;
|
|
2813
|
+
logger.level = LogLevel;
|
|
2814
|
+
const dbPath = options.dbPath ?? process.env.DATABASE_URL ?? process.env.PH_SWITCHBOARD_DATABASE_URL;
|
|
2815
|
+
const readModelPath = dbPath || ".ph/read-storage";
|
|
2816
|
+
const reactorDbUrl = options.dbPath ?? process.env.PH_REACTOR_DATABASE_URL ?? process.env.PH_SWITCHBOARD_DATABASE_URL;
|
|
2817
|
+
const reactorPath = reactorDbUrl || "./.ph/reactor-storage";
|
|
2818
|
+
const reactorPgliteDir = options.reactor ? null : !reactorDbUrl || !isPostgresUrl(reactorDbUrl) ? reactorPath : null;
|
|
2819
|
+
const readModelPgliteDir = !dbPath || !isPostgresUrl(dbPath) ? readModelPath : null;
|
|
2820
|
+
const pgliteDirs = [reactorPgliteDir, readModelPgliteDir].filter((d) => d !== null);
|
|
2821
|
+
warnIgnoredFlushInterval(logger);
|
|
2822
|
+
const detectedMajors = await runPglitePreflight({
|
|
2823
|
+
dirs: pgliteDirs,
|
|
2824
|
+
forcePgVersion: options.forcePgVersion,
|
|
2825
|
+
migratePglite: options.migratePglite,
|
|
2826
|
+
inMemory: PGLITE_IN_MEMORY,
|
|
2827
|
+
logger
|
|
2828
|
+
});
|
|
2829
|
+
function resolvePgliteMajorForDir(dir) {
|
|
2830
|
+
if (options.forcePgVersion !== void 0) return options.forcePgVersion;
|
|
2831
|
+
const detected = detectedMajors.get(dir);
|
|
2832
|
+
if (detected === void 0) return 17;
|
|
2833
|
+
if (!isSupportedMajor(detected)) throw new Error(`Unsupported PGLite data dir at ${dir}: PG_VERSION=${detected}`);
|
|
2834
|
+
return detected;
|
|
2835
|
+
}
|
|
2836
|
+
const reactorPgliteMajor = reactorPgliteDir ? resolvePgliteMajorForDir(reactorPgliteDir) : null;
|
|
2837
|
+
const readModelPgliteMajor = readModelPgliteDir ? resolvePgliteMajorForDir(readModelPgliteDir) : null;
|
|
2838
|
+
let workerPool = resolveWorkerPoolOptions(options.workerPool, process.env);
|
|
2839
|
+
if (workerPool && options.reactor) {
|
|
2840
|
+
logger.warn("Worker pool configuration ignored: the caller-provided reactor owns its own executor");
|
|
2841
|
+
workerPool = null;
|
|
2842
|
+
}
|
|
2843
|
+
if (workerPool) {
|
|
2844
|
+
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");
|
|
2845
|
+
assertWorkerTrustPolicy({
|
|
2846
|
+
workers: workerPool.numWorkers,
|
|
2847
|
+
authEnforcement: resolveReactorFeatureFlags(process.env).flags.authEnforcement === true,
|
|
2848
|
+
renownSource: renownConfig.source
|
|
2849
|
+
});
|
|
2850
|
+
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.");
|
|
2851
|
+
}
|
|
2852
|
+
const unsupportedStoredDocuments = resolveUnsupportedStoredDocuments(options.unsupportedStoredDocuments, process.env);
|
|
2853
|
+
let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
|
|
2854
|
+
if (projectionWorker && options.reactor) {
|
|
2855
|
+
logger.warn("Projection worker configuration ignored: the caller-provided reactor owns its own read-model coordinator");
|
|
2856
|
+
projectionWorker = null;
|
|
2857
|
+
}
|
|
2858
|
+
if (projectionWorker) assertProjectionWorkerSupported({
|
|
2859
|
+
dev: dev === true,
|
|
2860
|
+
reactorDbUrl
|
|
2861
|
+
});
|
|
2862
|
+
const apiRef = { current: void 0 };
|
|
2863
|
+
let driveNodeView;
|
|
2864
|
+
const configPath = resolveConfigPath(options.configFile);
|
|
2865
|
+
const config = getConfig(configPath);
|
|
2866
|
+
const registryUrl = options.registryUrl ?? process.env.PH_REGISTRY_URL ?? config.packageRegistryUrl;
|
|
2867
|
+
const registryPackages = process.env.PH_REGISTRY_PACKAGES;
|
|
2868
|
+
const dynamicModelLoading = options.dynamicModelLoading ?? process.env.DYNAMIC_MODEL_LOADING === "true";
|
|
2869
|
+
let httpLoader;
|
|
2870
|
+
if (registryUrl) {
|
|
2871
|
+
register(httpsHooksPath, import.meta.url);
|
|
2872
|
+
httpLoader = new HttpPackageLoader({ registryUrl });
|
|
2873
|
+
registryPackages?.split(",").forEach((p) => {
|
|
2874
|
+
const name = p.trim();
|
|
2875
|
+
if (!packages.includes(name)) packages.push(name);
|
|
2876
|
+
});
|
|
2877
|
+
}
|
|
2878
|
+
const registryPackageNames = new Set((registryPackages ?? "").split(",").map((p) => p.trim()).filter(Boolean));
|
|
2879
|
+
const resolveRegistryModels = async (identifier) => httpLoader && registryPackageNames.has(identifier) ? (await httpLoader.cachePackage(identifier)).entries.documentModels ?? null : null;
|
|
2880
|
+
const reactorLogger = logger.child(["reactor"]);
|
|
2881
|
+
let projectionWorkerFatalFired = false;
|
|
2882
|
+
const onProjectionWorkerFatal = (shardId, reason) => {
|
|
2883
|
+
if (projectionWorkerFatalFired) return;
|
|
2884
|
+
projectionWorkerFatalFired = true;
|
|
2885
|
+
reactorLogger.error(`Projection worker ${shardId} died; shutting down so the supervisor restarts a healthy process`, reason);
|
|
2886
|
+
process.kill(process.pid, "SIGTERM");
|
|
2887
|
+
};
|
|
2888
|
+
const workflowsEnabled = options.workflows?.enabled === true;
|
|
2889
|
+
const privacyConfig = resolvePrivacy(options.privacy, process.env);
|
|
2890
|
+
if (workflowsEnabled) {
|
|
2891
|
+
await assertWorkflowPackageLoadable();
|
|
2892
|
+
if (!packages.includes("@powerhousedao/workflow")) packages.push(WORKFLOW_PACKAGE_NAME);
|
|
2893
|
+
}
|
|
2894
|
+
let ownedReactorModule;
|
|
2895
|
+
let workerModels;
|
|
2896
|
+
let localRenownRequest;
|
|
2897
|
+
const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
|
|
2898
|
+
if (options.reactor) {
|
|
2899
|
+
const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
|
|
2900
|
+
if (options.reactor.reactorModule) {
|
|
2901
|
+
new ReactorInstrumentation(options.reactor.reactorModule).start();
|
|
2902
|
+
reactorLogger.info("Reactor metrics instrumentation started (using caller-provided reactor)");
|
|
2903
|
+
}
|
|
2904
|
+
return {
|
|
2905
|
+
module: options.reactor,
|
|
2906
|
+
attachmentReferenceProjection
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
const { kysely: baseKysely, poolInstrumentation } = await createReactorKysely({
|
|
2910
|
+
reactorDbUrl,
|
|
2911
|
+
reactorPgliteDir,
|
|
2912
|
+
reactorPgliteMajor,
|
|
2913
|
+
inMemory: PGLITE_IN_MEMORY,
|
|
2914
|
+
hostPoolSize: () => resolveHostPoolSize(process.env),
|
|
2915
|
+
logger
|
|
2916
|
+
});
|
|
2917
|
+
const maxSkipThreshold = parseInt(process.env.MAX_SKIP_THRESHOLD ?? "", 10);
|
|
2918
|
+
const hasSkipThreshold = !isNaN(maxSkipThreshold) && maxSkipThreshold > 0;
|
|
2919
|
+
if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
|
|
2920
|
+
const { flags: reactorFeatureFlags, enabled: enabledFeatureFlags } = resolveReactorFeatureFlags(process.env);
|
|
2921
|
+
if (enabledFeatureFlags.length > 0) logger.info(`Reactor feature flags enabled: ${enabledFeatureFlags.join(", ")}`);
|
|
2922
|
+
const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" }).withUnsupportedStoredDocuments(unsupportedStoredDocuments);
|
|
2923
|
+
if (poolInstrumentation) reactorBuilder.withInstrumentedPool(poolInstrumentation);
|
|
2924
|
+
const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
|
|
2925
|
+
const vetraDocumentModels = dev ? Object.values(await import("@powerhousedao/vetra/document-models")).filter((m) => typeof m === "object" && m !== null && "documentModel" in m && "reducer" in m) : [];
|
|
2926
|
+
applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, {
|
|
2927
|
+
documentModels: [...documentModels, ...vetraDocumentModels],
|
|
2928
|
+
upgradeManifests,
|
|
2929
|
+
executorConfig: hasSkipThreshold || enabledFeatureFlags.length > 0 ? {
|
|
2930
|
+
...hasSkipThreshold ? { maxSkipThreshold } : {},
|
|
2931
|
+
...enabledFeatureFlags.length > 0 ? { featureFlags: reactorFeatureFlags } : {}
|
|
2932
|
+
} : void 0,
|
|
2933
|
+
documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
|
|
2934
|
+
logger: reactorLogger,
|
|
2935
|
+
signer: renown ? getRenownSignerConfig(renown, options.identity?.keypairPath) : void 0,
|
|
2936
|
+
createSignaturePolicy: resolveCreateSignaturePolicy(process.env, {
|
|
2937
|
+
hasSigner: renown !== null,
|
|
2938
|
+
logger
|
|
2939
|
+
}),
|
|
2940
|
+
trustPolicy: reactorFeatureFlags.authEnforcement ? await getRenownTrustPolicyConfig(renownConfig.source === "self" ? {
|
|
2941
|
+
source: "self",
|
|
2942
|
+
request: (query, variables) => localRenownRequest ? localRenownRequest(query, variables) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet"))
|
|
2943
|
+
} : {
|
|
2944
|
+
source: "remote",
|
|
2945
|
+
renownUrl: renownConfig.url,
|
|
2946
|
+
switchboardUrl: renownConfig.switchboardUrl
|
|
2947
|
+
}, renown) : void 0
|
|
2948
|
+
});
|
|
2949
|
+
if (workerPool || projectionWorker || workflowsEnabled) {
|
|
2950
|
+
const sources = await resolveWorkerModelSources(packages, reactorLogger, { resolveRemote: resolveRegistryModels });
|
|
2951
|
+
reactorBuilder.withDocumentModelSources(workerPool || projectionWorker ? sources : await keepImportableSources(sources, reactorLogger));
|
|
2952
|
+
}
|
|
2953
|
+
workerModels = modelManifestSource(reactorBuilder);
|
|
2954
|
+
if (workerPool) {
|
|
2955
|
+
if (!reactorDbUrl) throw new Error("unreachable: worker pool enabled without a reactor database URL");
|
|
2956
|
+
reactorBuilder.withWorkerPool({
|
|
2957
|
+
numWorkers: workerPool.numWorkers,
|
|
2958
|
+
db: buildWorkerDbConfig(reactorDbUrl, workerPool)
|
|
2959
|
+
});
|
|
2960
|
+
reactorLogger.info(`Executor worker pool enabled: ${workerPool.numWorkers} worker threads${workerPool.mode === "auto" ? " (auto-sized from cores)" : ""}`);
|
|
2961
|
+
}
|
|
2962
|
+
reactorBuilder.withReadModelFactory(async ({ operationIndex, writeCache, processorManagerConsistencyTracker }) => {
|
|
2963
|
+
const nodeProcessor = new NodeProcessor(baseKysely, REACTOR_SCHEMA, operationIndex, writeCache, processorManagerConsistencyTracker);
|
|
2964
|
+
await nodeProcessor.init();
|
|
2965
|
+
return nodeProcessor;
|
|
2966
|
+
});
|
|
2967
|
+
registerAttachmentReferenceReadModel(reactorBuilder, {
|
|
2968
|
+
baseKysely,
|
|
2969
|
+
attachmentReferenceWriter
|
|
2970
|
+
});
|
|
2971
|
+
if (projectionWorker) {
|
|
2972
|
+
if (!reactorDbUrl) throw new Error("unreachable: projection worker enabled without a reactor database URL");
|
|
2973
|
+
const db = {
|
|
2974
|
+
...buildWorkerDbConfig(reactorDbUrl, {
|
|
2975
|
+
dbPoolSizePerWorker: projectionWorker.dbPoolSize,
|
|
2976
|
+
acquireTimeoutMs: workerPool?.acquireTimeoutMs ?? 5e3
|
|
2977
|
+
}),
|
|
2978
|
+
applicationName: "switchboard-projection"
|
|
2979
|
+
};
|
|
2980
|
+
reactorBuilder.withReadModelCoordinatorFactory(createHybridProjectionCoordinatorFactory({
|
|
2981
|
+
shardCount: 1,
|
|
2982
|
+
poolSize: projectionWorker.dbPoolSize,
|
|
2983
|
+
db,
|
|
2984
|
+
onFatal: onProjectionWorkerFatal
|
|
2985
|
+
}));
|
|
2986
|
+
reactorLogger.info(`Projection worker enabled: 1 worker thread, pool size ${projectionWorker.dbPoolSize}`);
|
|
2987
|
+
}
|
|
2988
|
+
reactorBuilder.withShutdownHook(async () => {
|
|
2989
|
+
if (apiRef.current) await apiRef.current.dispose();
|
|
2990
|
+
});
|
|
2991
|
+
let module;
|
|
2992
|
+
try {
|
|
2993
|
+
module = await clientBuilder.buildModule();
|
|
2994
|
+
} catch (error) {
|
|
2995
|
+
try {
|
|
2996
|
+
await baseKysely.destroy();
|
|
2997
|
+
} catch (destroyError) {
|
|
2998
|
+
logger.error("Aborting boot: reactor database destroy failed: @error", destroyError);
|
|
2999
|
+
}
|
|
3000
|
+
if (UnsupportedStoredProtocolError.isError(error)) throw new StoredDocumentsRefusedError(error);
|
|
3001
|
+
throw error;
|
|
3002
|
+
}
|
|
3003
|
+
if (module.reactorModule) {
|
|
3004
|
+
new ReactorInstrumentation(module.reactorModule).start();
|
|
3005
|
+
reactorLogger.info("Reactor metrics instrumentation started");
|
|
3006
|
+
}
|
|
3007
|
+
const reactorDriveSchemaDb = baseKysely.withSchema(REACTOR_SCHEMA);
|
|
3008
|
+
driveNodeView = new DriveNodeView(reactorDriveSchemaDb);
|
|
3009
|
+
const reactorDriveClient = new ReactorDriveClient({
|
|
3010
|
+
reactor: module.client,
|
|
3011
|
+
readModel: driveNodeView
|
|
3012
|
+
});
|
|
3013
|
+
ownedReactorModule = module;
|
|
3014
|
+
if (options.fatalErrorShutdown) installFatalErrorShutdown(logger);
|
|
3015
|
+
return {
|
|
3016
|
+
module,
|
|
3017
|
+
reactorDriveClient,
|
|
3018
|
+
attachmentReferenceProjection: { status: "available" }
|
|
3019
|
+
};
|
|
3020
|
+
};
|
|
3021
|
+
const abortBoot = async (api) => {
|
|
3022
|
+
const owned = ownedReactorModule;
|
|
3023
|
+
if (owned) try {
|
|
3024
|
+
await owned.reactor.kill().completed;
|
|
3025
|
+
} catch (error) {
|
|
3026
|
+
logger.error("Aborting boot: reactor shutdown failed: @error", error);
|
|
3027
|
+
}
|
|
3028
|
+
try {
|
|
3029
|
+
await api.dispose();
|
|
3030
|
+
} catch (error) {
|
|
3031
|
+
logger.error("Aborting boot: api dispose failed: @error", error);
|
|
3032
|
+
}
|
|
3033
|
+
if (owned?.reactorModule) try {
|
|
3034
|
+
await owned.reactorModule.database.destroy();
|
|
3035
|
+
} catch (error) {
|
|
3036
|
+
logger.error("Aborting boot: database destroy failed: @error", error);
|
|
3037
|
+
}
|
|
3038
|
+
};
|
|
3039
|
+
let localCredentialCheck;
|
|
3040
|
+
const verifyCredential = renownConfig.source === "self" ? (params) => localCredentialCheck ? localCredentialCheck(params) : Promise.reject(/* @__PURE__ */ new Error("The local renown read model is not bound yet")) : void 0;
|
|
3041
|
+
let defaultDriveUrl = void 0;
|
|
3042
|
+
const basePath = process.cwd();
|
|
3043
|
+
let vite;
|
|
3044
|
+
let viteLoader;
|
|
3045
|
+
if (dev) {
|
|
3046
|
+
const { VitePackageLoader, createViteLogger, startViteServer } = await import("@powerhousedao/reactor-api/vite");
|
|
3047
|
+
vite = await startViteServer(process.cwd(), createViteLogger(logger));
|
|
3048
|
+
viteLoader = VitePackageLoader.build(vite, { resolveFrom: [import.meta.url] });
|
|
3049
|
+
}
|
|
3050
|
+
const vetraProcessorFactory = dev ? (await import("@powerhousedao/vetra/processors")).processorFactory : void 0;
|
|
3051
|
+
if (!options.disableLocalPackages) packages.push(basePath);
|
|
3052
|
+
const packageLoaders = [];
|
|
3053
|
+
if (viteLoader) packageLoaders.push(viteLoader);
|
|
3054
|
+
else packageLoaders.push(new ImportPackageLoader());
|
|
3055
|
+
if (httpLoader) {
|
|
3056
|
+
packageLoaders.push(httpLoader);
|
|
3057
|
+
registryPackages?.split(",").forEach((p) => {
|
|
3058
|
+
const name = p.trim();
|
|
3059
|
+
if (!packages.includes(name)) packages.push(name);
|
|
3060
|
+
});
|
|
3061
|
+
}
|
|
3062
|
+
const apiLogger = logger.child(["reactor-api"]);
|
|
3063
|
+
let pgliteFactory;
|
|
3064
|
+
if (readModelPgliteDir && readModelPgliteMajor !== null) {
|
|
3065
|
+
const { PGlite: ReadModelPGlite } = await loadPGliteModule(readModelPgliteMajor);
|
|
3066
|
+
const durableFs = await loadDurableFsFactory(readModelPgliteMajor, logger);
|
|
3067
|
+
pgliteFactory = PGLITE_IN_MEMORY ? () => new ReadModelPGlite({ parsers: PGLITE_UTC_PARSERS }) : (connectionString) => new ReadModelPGlite({
|
|
3068
|
+
fs: durableFs(connectionString ?? readModelPgliteDir),
|
|
3069
|
+
parsers: PGLITE_UTC_PARSERS
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
const api = await initializeAndStartAPI(initializeClient, {
|
|
3073
|
+
port: serverPort,
|
|
3074
|
+
dbPath: readModelPath,
|
|
3075
|
+
pgliteFactory,
|
|
3076
|
+
https: options.https,
|
|
3077
|
+
packageLoaders: packageLoaders.length > 0 ? packageLoaders : void 0,
|
|
3078
|
+
packages,
|
|
3079
|
+
processorConfig: options.processorConfig,
|
|
3080
|
+
processors: vetraProcessorFactory ? { "@powerhousedao/vetra": [vetraProcessorFactory] } : {},
|
|
3081
|
+
configFile: configPath,
|
|
3082
|
+
mcp: options.mcp ?? true,
|
|
3083
|
+
logger: apiLogger,
|
|
3084
|
+
enableDocumentModelSubgraphs: options.enableDocumentModelSubgraphs,
|
|
3085
|
+
renown: renownConfig,
|
|
3086
|
+
verifyCredential
|
|
3087
|
+
}, "switchboard");
|
|
3088
|
+
apiRef.current = api;
|
|
3089
|
+
if (renownConfig.source === "self") {
|
|
3090
|
+
const { graphqlManager: manager } = api;
|
|
3091
|
+
if (!manager.hasSubgraphHandler(RENOWN_READ_MODEL_SUBGRAPH)) {
|
|
3092
|
+
await abortBoot(api);
|
|
3093
|
+
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.`);
|
|
3094
|
+
}
|
|
3095
|
+
localRenownRequest = (query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables);
|
|
3096
|
+
localCredentialCheck = createLocalCredentialVerifier(localRenownRequest, { onError: (error) => logger.error("Renown read model query failed: @error", error) });
|
|
3097
|
+
logger.info("Renown credentials will be verified against this switchboard's own renown read model");
|
|
3098
|
+
}
|
|
3099
|
+
registerAttachmentRoutes(api);
|
|
3100
|
+
const attachmentService = createRemoteAttachmentService(deriveAttachmentServiceConfig(options, serverPort, renown));
|
|
3101
|
+
if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
|
|
3102
|
+
const { client, graphqlManager, documentModelRegistry } = api;
|
|
3103
|
+
const lateSubgraphs = [];
|
|
3104
|
+
let workflows;
|
|
3105
|
+
if (workflowsEnabled) {
|
|
3106
|
+
workflows = await composeWorkflowRuntime({
|
|
3107
|
+
reactorClient: client,
|
|
3108
|
+
clientModule: options.reactor ?? ownedReactorModule,
|
|
3109
|
+
relationalDb: api.relationalDb,
|
|
3110
|
+
secretsKeyFile: readModelPgliteDir === null ? false : void 0,
|
|
3111
|
+
attachments: createAttachmentClient(api.attachments.service),
|
|
3112
|
+
attachmentAccess: api.attachmentAccess,
|
|
3113
|
+
webhooks: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME).webhooks,
|
|
3114
|
+
http: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME),
|
|
3115
|
+
authorizationService: api.authorizationService,
|
|
3116
|
+
pieces: api.packageManager,
|
|
3117
|
+
pieceRegistryUrl: registryUrl,
|
|
3118
|
+
models: workerModels,
|
|
3119
|
+
logger: logger.child(["workflow-runtime"]),
|
|
3120
|
+
telemetry: {
|
|
3121
|
+
tracer: trace.getTracer(WORKFLOW_TELEMETRY_SCOPE),
|
|
3122
|
+
meter: metrics.getMeter(WORKFLOW_TELEMETRY_SCOPE)
|
|
3123
|
+
}
|
|
3124
|
+
});
|
|
3125
|
+
const WorkflowRuntimeSubgraph = workflows.subgraph;
|
|
3126
|
+
const workflowSubgraph = new WorkflowRuntimeSubgraph({
|
|
3127
|
+
reactorClient: client,
|
|
3128
|
+
http: graphqlManager.scopeForPackage(WORKFLOW_PACKAGE_NAME),
|
|
3129
|
+
relationalDb: api.relationalDb,
|
|
3130
|
+
analyticsStore: void 0,
|
|
3131
|
+
graphqlManager,
|
|
3132
|
+
syncManager: api.syncManager,
|
|
3133
|
+
authorizationService: graphqlManager.getAuthorizationService(),
|
|
3134
|
+
path: graphqlManager.getBasePath(),
|
|
3135
|
+
attachments: api.attachmentClientProvider
|
|
3136
|
+
});
|
|
3137
|
+
lateSubgraphs.push(graphqlManager.registerSubgraphInstance(workflowSubgraph, "graphql", false).catch((error) => {
|
|
3138
|
+
logger.error("Failed to register workflow-runtime subgraph: @error", error);
|
|
3139
|
+
}));
|
|
3140
|
+
await workflows.start();
|
|
3141
|
+
logger.info("Workflow runtime started");
|
|
3142
|
+
}
|
|
3143
|
+
let privacy;
|
|
3144
|
+
if (privacyConfig.enabled) {
|
|
3145
|
+
const signer = options.reactor ? options.privacy?.signer : options.privacy?.signer ?? renown?.signer;
|
|
3146
|
+
try {
|
|
3147
|
+
privacy = await startPrivacy({
|
|
3148
|
+
config: privacyConfig,
|
|
3149
|
+
reactorModule: (options.reactor ?? ownedReactorModule)?.reactorModule,
|
|
3150
|
+
signer,
|
|
3151
|
+
authorizationService: api.authorizationService,
|
|
3152
|
+
documentPermissionService: api.documentPermissionService,
|
|
3153
|
+
graphqlManager,
|
|
3154
|
+
reactorClient: client,
|
|
3155
|
+
logger: logger.child(["privacy"])
|
|
3156
|
+
});
|
|
3157
|
+
} catch (error) {
|
|
3158
|
+
await workflows?.stop();
|
|
3159
|
+
await abortBoot(api);
|
|
3160
|
+
throw error;
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
const shutdown = async () => {
|
|
3164
|
+
await privacy?.stop();
|
|
3165
|
+
await workflows?.stop();
|
|
3166
|
+
await api.dispose();
|
|
3167
|
+
};
|
|
3168
|
+
apiRef.current = { dispose: shutdown };
|
|
3169
|
+
if (httpLoader) {
|
|
3170
|
+
const packageManagementService = new PackageManagementService({
|
|
3171
|
+
defaultRegistryUrl: registryUrl,
|
|
3172
|
+
httpLoader,
|
|
3173
|
+
documentModelRegistry,
|
|
3174
|
+
packageManager: api.packageManager
|
|
3175
|
+
});
|
|
3176
|
+
packageManagementService.setOnModelsChanged(() => {
|
|
3177
|
+
graphqlManager.regenerateDocumentModelSubgraphs().catch((error) => {
|
|
3178
|
+
logger.error("Failed to regenerate document model subgraphs: @error", error);
|
|
3179
|
+
});
|
|
3180
|
+
});
|
|
3181
|
+
const packagesSubgraph = new PackagesSubgraph({
|
|
3182
|
+
relationalDb: void 0,
|
|
3183
|
+
analyticsStore: void 0,
|
|
3184
|
+
reactorClient: client,
|
|
3185
|
+
graphqlManager,
|
|
3186
|
+
syncManager: api.syncManager,
|
|
3187
|
+
path: graphqlManager.getBasePath(),
|
|
3188
|
+
authorizationService: graphqlManager.getAuthorizationService(),
|
|
3189
|
+
packageManagementService,
|
|
3190
|
+
http: graphqlManager.scopeForPackage("@powerhousedao/switchboard"),
|
|
3191
|
+
attachments: api.attachmentClientProvider
|
|
3192
|
+
});
|
|
3193
|
+
lateSubgraphs.push(graphqlManager.registerSubgraphInstance(packagesSubgraph, "graphql", false).catch((error) => {
|
|
3194
|
+
logger.error("Failed to register packages subgraph: @error", error);
|
|
3195
|
+
}));
|
|
3196
|
+
}
|
|
3197
|
+
if (driveNodeView) {
|
|
3198
|
+
graphqlManager.setAdditionalContextFields({ readModel: driveNodeView });
|
|
3199
|
+
const authorizationService = graphqlManager.getAuthorizationService();
|
|
3200
|
+
const reactorDriveSubgraph = {
|
|
3201
|
+
name: "reactor-drive",
|
|
3202
|
+
path: graphqlManager.getBasePath(),
|
|
3203
|
+
resolvers: createReactorDriveResolvers({
|
|
3204
|
+
reactorClient: client,
|
|
3205
|
+
readModel: driveNodeView,
|
|
3206
|
+
hostCanRead: (documentId, address) => authorizationService.canRead(documentId, address)
|
|
3207
|
+
}),
|
|
3208
|
+
typeDefs: reactorDriveSubgraphTypeDefs,
|
|
3209
|
+
reactorClient: client,
|
|
3210
|
+
relationalDb: void 0
|
|
3211
|
+
};
|
|
3212
|
+
lateSubgraphs.push(graphqlManager.registerSubgraphInstance(reactorDriveSubgraph, "graphql", false).catch((error) => {
|
|
3213
|
+
logger.error("Failed to register reactor-drive subgraph: @error", error);
|
|
3214
|
+
}));
|
|
3215
|
+
}
|
|
3216
|
+
(async () => {
|
|
3217
|
+
await Promise.all(lateSubgraphs);
|
|
3218
|
+
try {
|
|
3219
|
+
await graphqlManager.updateRouter(true);
|
|
3220
|
+
} catch (error) {
|
|
3221
|
+
logger.error("Final router update before readiness failed: @error", error);
|
|
3222
|
+
}
|
|
3223
|
+
api.readiness.markReady();
|
|
3224
|
+
})();
|
|
3225
|
+
if (options.drive) {
|
|
3226
|
+
if (!renown) throw new Error("Cannot create default drive without Renown identity");
|
|
3227
|
+
if ((options.drive.documentType ?? "powerhouse/document-drive") === "powerhouse/reactor-drive") defaultDriveUrl = await addDefaultReactorDrive(client, options.drive, serverPort);
|
|
3228
|
+
else defaultDriveUrl = await addDefaultDrive(client, options.drive, serverPort);
|
|
3229
|
+
}
|
|
3230
|
+
if (vite) api.httpAdapter.mountRawMiddleware(vite.middlewares);
|
|
3231
|
+
if (remoteDrives.length > 0) for (const remoteDriveUrl of remoteDrives) {
|
|
3232
|
+
let driveId;
|
|
3233
|
+
try {
|
|
3234
|
+
const { syncManager } = api;
|
|
3235
|
+
const parsed = parseDriveUrl(remoteDriveUrl);
|
|
3236
|
+
driveId = parsed.driveId;
|
|
3237
|
+
const remoteName = `remote-drive-${driveId}-${crypto.randomUUID()}`;
|
|
3238
|
+
await syncManager.add(remoteName, DriveCollectionId.forDrive(driveId), {
|
|
3239
|
+
type: "gql",
|
|
3240
|
+
parameters: { url: parsed.graphqlEndpoint }
|
|
3241
|
+
});
|
|
3242
|
+
logger.debug("Remote drive @remoteDriveUrl synced", remoteDriveUrl);
|
|
3243
|
+
} catch (error) {
|
|
3244
|
+
if (error instanceof Error && error.message.includes("already exists")) {
|
|
3245
|
+
logger.debug("Remote drive already added: @remoteDriveUrl", remoteDriveUrl);
|
|
3246
|
+
driveId = remoteDriveUrl.split("/").pop();
|
|
3247
|
+
} else logger.error("Failed to connect to remote drive @remoteDriveUrl: @error", remoteDriveUrl, error);
|
|
3248
|
+
} finally {
|
|
3249
|
+
if (!defaultDriveUrl && driveId) defaultDriveUrl = `${options.https ? "https" : "http"}://localhost:${serverPort}/d/${driveId}`;
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
return {
|
|
3253
|
+
defaultDriveUrl,
|
|
3254
|
+
api,
|
|
3255
|
+
reactor: client,
|
|
3256
|
+
attachmentService,
|
|
3257
|
+
attachmentReferenceProjection: api.attachmentReferenceProjection,
|
|
3258
|
+
workflowTriggers: workflows?.triggers,
|
|
3259
|
+
workflowsEnabled,
|
|
3260
|
+
modelManifest: () => workerModels?.modelManifest() ?? [],
|
|
3261
|
+
privacy: privacy ? { erasure: privacy.erasure } : void 0,
|
|
3262
|
+
mcpEnabled: options.mcp !== false,
|
|
3263
|
+
renown,
|
|
3264
|
+
port: serverPort,
|
|
3265
|
+
shutdown
|
|
3266
|
+
};
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* Boot the switchboard HTTP/GraphQL/MCP stack on top of a reactor.
|
|
3270
|
+
*
|
|
3271
|
+
* If `options.reactor` is provided, the switchboard reuses it instead of
|
|
3272
|
+
* building its own — the caller then owns the reactor's lifecycle and is
|
|
3273
|
+
* responsible for invoking `SwitchboardReactor.shutdown()` from their own
|
|
3274
|
+
* teardown / SIGINT path. The switchboard will not reach into the caller's
|
|
3275
|
+
* reactor; killing the reactor alone leaves the api/GraphQL/MCP resources
|
|
3276
|
+
* dangling until the process exits.
|
|
3277
|
+
*
|
|
3278
|
+
* When `options.reactor` is omitted, the switchboard builds and owns the
|
|
3279
|
+
* reactor. `shutdown()` on the returned handle only drains the api (HTTP
|
|
3280
|
+
* server, GraphQL, MCP, attachments); the reactor itself is torn down by
|
|
3281
|
+
* its own signal handlers (`withSignalHandlers`), which call `kill()` and
|
|
3282
|
+
* trigger the `withShutdownHook` chain that disposes the api. Programmatic
|
|
3283
|
+
* full teardown isn't currently exposed — wire it via SIGINT/SIGTERM.
|
|
3284
|
+
*/
|
|
3285
|
+
const startSwitchboard = async (options = {}) => {
|
|
3286
|
+
const requestedPort = options.port ?? DEFAULT_PORT;
|
|
3287
|
+
const logger = options.logger ?? defaultLogger;
|
|
3288
|
+
if (process.env.PYROSCOPE_SERVER_ADDRESS) await initProfilerFromEnv(process.env).catch((error) => logger.error("Error starting profiler: @error", error));
|
|
3289
|
+
const serverPort = await resolveServerPort(requestedPort, options.strictPort ?? false, logger);
|
|
3290
|
+
const featureFlags = await initFeatureFlags();
|
|
3291
|
+
const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
|
|
3292
|
+
options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
|
|
3293
|
+
options.mcp = await resolveMcpEnabled({
|
|
3294
|
+
featureFlags,
|
|
3295
|
+
option: options.mcp
|
|
3296
|
+
});
|
|
3297
|
+
const configPathForFlags = resolveConfigPath(options.configFile);
|
|
3298
|
+
const workflowsEnabled = await resolveWorkflowsEnabled({
|
|
3299
|
+
featureFlags,
|
|
3300
|
+
override: options.workflows?.enabled,
|
|
3301
|
+
configEnabled: readConfigWorkflowsEnabled(configPathForFlags)
|
|
3302
|
+
});
|
|
3303
|
+
options.workflows = { enabled: workflowsEnabled };
|
|
3304
|
+
const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
|
|
3305
|
+
options.identity = {
|
|
3306
|
+
...options.identity,
|
|
3307
|
+
baseUrl: options.identity?.baseUrl ?? renownConfig.url
|
|
3308
|
+
};
|
|
3309
|
+
logger.info("Feature flags: @flags", JSON.stringify({
|
|
3310
|
+
DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
|
|
3311
|
+
MCP_ENABLED: options.mcp,
|
|
3312
|
+
PH_WORKFLOWS_ENABLED: workflowsEnabled
|
|
3313
|
+
}, null, 2));
|
|
3314
|
+
let renown = null;
|
|
3315
|
+
try {
|
|
3316
|
+
renown = await initRenown(options.identity);
|
|
3317
|
+
} catch (e) {
|
|
3318
|
+
logger.warn("Failed to initialize ConnectCrypto: @error", e);
|
|
3319
|
+
if (options.identity.requireExisting) throw new Error("Identity required but failed to initialize. Run \"ph login\" first.", { cause: e });
|
|
3320
|
+
}
|
|
3321
|
+
try {
|
|
3322
|
+
return await initServer(serverPort, options, renown, renownConfig);
|
|
3323
|
+
} catch (e) {
|
|
3324
|
+
Sentry.captureException(e);
|
|
3325
|
+
if (StoredDocumentsRefusedError.isError(e)) logger.error(e.message);
|
|
3326
|
+
else logger.error("App crashed: @error", e);
|
|
3327
|
+
throw e;
|
|
3328
|
+
}
|
|
3329
|
+
};
|
|
3330
|
+
if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
|
|
3331
|
+
//#endregion
|
|
3332
|
+
export { StoredDocumentsRefusedError as a, initProfilerFromEnv as c, startSwitchboard as i, parseForcePgVersion as l, deriveAttachmentServiceConfig as n, resolveUnsupportedStoredDocuments as o, isPortAvailable as r, applySwitchboardReactorDefaults as s, createReactorKysely as t };
|
|
3333
|
+
|
|
3334
|
+
//# sourceMappingURL=server-Zz7SqTwO.mjs.map
|
|
3335
|
+
//# debugId=12676ab9-2bd3-55f1-9a41-559509adace0
|