@powerhousedao/switchboard 6.2.2-dev.9 → 6.2.2-staging.0

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.
@@ -1,25 +1,27 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="5eb0a978-689a-51dd-978a-cd3d7fe5df4b")}catch(e){}}();
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]="65ab8387-7c36-5ebd-b971-913ce2e772b3")}catch(e){}}();
3
3
  import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-Baw7rThP.mjs";
4
4
  import { register } from "node:module";
5
+ import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
5
6
  import * as Sentry from "@sentry/node";
6
7
  import { childLogger, documentModelDocumentModelModule, setLogLevel } from "document-model";
7
8
  import dotenv from "dotenv";
8
9
  import { getConfig } from "@powerhousedao/config/node";
9
10
  import { existsSync, promises, realpathSync } from "node:fs";
10
11
  import path from "node:path";
11
- import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
12
12
  import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
13
- import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, parseDriveUrl } from "@powerhousedao/reactor";
14
- import { HttpPackageLoader, ImportPackageLoader, PackageManagementService, PackagesSubgraph, getUniqueDocumentModels, initializeAndStartAPI } from "@powerhousedao/reactor-api";
13
+ import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
14
+ import { HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
15
15
  import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
16
- import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRemoteAttachmentService } from "@powerhousedao/reactor-attachments";
16
+ import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget } from "@powerhousedao/reactor-attachments";
17
17
  import { DriveNodeView, NodeProcessor, ReactorDriveClient, createReactorDriveResolvers, reactorDriveDocumentModelModule, reactorDriveSubgraphTypeDefs } from "@powerhousedao/reactor-drive";
18
+ import { DEFAULT_RENOWN_URL, NodeKeyStorage, RENOWN_READ_MODEL_SUBGRAPH, RenownBuilder, RenownCryptoBuilder, createLocalCredentialVerifier, createSignatureVerifier } from "@renown/sdk/node";
18
19
  import { Kysely, PostgresDialect } from "kysely";
19
20
  import net from "node:net";
20
21
  import path$1 from "path";
21
22
  import { Pool } from "pg";
22
23
  import { Readable } from "node:stream";
24
+ import { ReactorGroupV1 } from "@powerhousedao/reactor-group";
23
25
  import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
24
26
  import { readFile } from "node:fs/promises";
25
27
  import os from "node:os";
@@ -27,7 +29,6 @@ import { fileURLToPath } from "node:url";
27
29
  import { EnvVarProvider } from "@openfeature/env-var-provider";
28
30
  import { OpenFeature } from "@openfeature/server-sdk";
29
31
  import { PGliteDialect } from "kysely-pglite-dialect";
30
- import { DEFAULT_RENOWN_URL, NodeKeyStorage, RenownBuilder, RenownCryptoBuilder, createSignatureVerifier } from "@renown/sdk/node";
31
32
  //#region src/pglite-version.ts
32
33
  const SUPPORTED_PG_MAJORS = [16, 17];
33
34
  async function readPgVersionFile(dataDir) {
@@ -64,13 +65,20 @@ async function loadPgDump(major) {
64
65
  }
65
66
  //#endregion
66
67
  //#region src/attachments/auth.ts
68
+ const ANONYMOUS_ACTOR = {
69
+ user: void 0,
70
+ authEnabled: false
71
+ };
67
72
  /**
68
73
  * Wrap a Node-style handler so that, when `authService` is provided and auth is
69
- * enabled, the request must carry a verifiable Bearer token.
74
+ * enabled, the request must carry a verifiable Bearer token. The handler always
75
+ * receives an actor context: the verified bearer user when auth is enabled, or
76
+ * the anonymous context when it is disabled. With `allowAnonymous`, a missing
77
+ * bearer yields an anonymous actor with `authEnabled: true` instead of a 401.
70
78
  */
71
- function requireAuth(authService, handler) {
72
- if (!authService) return handler;
73
- return async (req, res) => {
79
+ function requireAuth(authService, handler, options) {
80
+ if (!authService) return (req, res, body) => handler(req, res, body, ANONYMOUS_ACTOR);
81
+ return async (req, res, body) => {
74
82
  let result;
75
83
  try {
76
84
  result = await authService.verifyBearer(req.headers.authorization);
@@ -88,25 +96,32 @@ function requireAuth(authService, handler) {
88
96
  res.end(body);
89
97
  return;
90
98
  }
91
- if (result.auth_enabled && !result.user) {
99
+ if (result.auth_enabled && !result.user && !options?.allowAnonymous) {
92
100
  res.statusCode = 401;
93
101
  res.setHeader("Content-Type", "application/json");
94
102
  res.end(JSON.stringify({ error: "Authentication required" }));
95
103
  return;
96
104
  }
97
- await handler(req, res);
105
+ await handler(req, res, body, {
106
+ user: result.user,
107
+ authEnabled: result.auth_enabled
108
+ });
98
109
  };
99
110
  }
100
111
  //#endregion
101
112
  //#region src/attachments/mount-auth.ts
102
113
  /**
103
114
  * Mount a Node-style attachment route with `requireAuth` applied unconditionally.
104
- * When `api.authService` is undefined (auth disabled), `requireAuth` returns the
105
- * handler unchanged that is the only way to opt out. To register a route
106
- * without auth wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
115
+ * When `api.authService` is undefined (auth disabled), the handler still runs
116
+ * through `requireAuth` and receives the anonymous actor context that is the
117
+ * only way to opt out of bearer verification. To register a route without auth
118
+ * wrapping you must call `api.httpAdapter.mountNodeRoute` directly.
119
+ *
120
+ * `allowAnonymous` is reserved for routes whose handlers make a per-document
121
+ * authorization decision themselves; identity-only routes must not use it.
107
122
  */
108
- function mountAuthenticatedNodeRoute(api, method, path, handler) {
109
- api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler));
123
+ function mountAuthenticatedNodeRoute(api, method, path, handler, options) {
124
+ api.httpAdapter.mountNodeRoute(method, path, requireAuth(api.authService, handler, options));
110
125
  }
111
126
  //#endregion
112
127
  //#region src/attachments/routes.ts
@@ -216,7 +231,8 @@ function makeReserveHandler(attachments) {
216
231
  sendJson(res, 201, {
217
232
  reservationId: upload.reservationId,
218
233
  ref: upload.ref,
219
- expiresAtUtc: upload.expiresAtUtc
234
+ expiresAtUtc: upload.expiresAtUtc,
235
+ ...upload.uploadTarget ? { uploadTarget: upload.uploadTarget } : {}
220
236
  });
221
237
  };
222
238
  }
@@ -227,6 +243,10 @@ function makeUploadHandler(attachments) {
227
243
  sendError(res, 400, "Missing reservationId");
228
244
  return;
229
245
  }
246
+ if (attachments.backend?.kind === "s3") {
247
+ sendError(res, 405, "Use the reservation uploadTarget for S3 uploads");
248
+ return;
249
+ }
230
250
  let reservation;
231
251
  try {
232
252
  reservation = await attachments.reservations.get(reservationId);
@@ -373,6 +393,144 @@ function makeDeleteReservationHandler(attachments) {
373
393
  function extractParam(req, name) {
374
394
  return req.params?.[name];
375
395
  }
396
+ const MAX_DOCUMENT_ID_LEN = 512;
397
+ const ATTACHMENT_NOT_FOUND_BODY = { error: "Attachment not found" };
398
+ const MAX_DOWNLOAD_TARGET_TTL_SECONDS = 10080 * 60;
399
+ /**
400
+ * Returns the single `documentId` query value, or null when it is missing,
401
+ * duplicated, blank, or oversized. Validation happens before authorization so
402
+ * malformed requests never reach the access service.
403
+ */
404
+ function extractSingleDocumentId(req) {
405
+ if (!req.url) return null;
406
+ let url;
407
+ try {
408
+ url = new URL(req.url, "http://switchboard.invalid");
409
+ } catch {
410
+ return null;
411
+ }
412
+ const values = url.searchParams.getAll("documentId");
413
+ if (values.length !== 1) return null;
414
+ const value = values[0];
415
+ if (value.trim().length === 0 || value.length > MAX_DOCUMENT_ID_LEN) return null;
416
+ return value;
417
+ }
418
+ /**
419
+ * Returns the requested target TTL in seconds: undefined when absent,
420
+ * "invalid" when malformed (duplicated, non-integer, or non-positive), and
421
+ * otherwise the value clamped to the presigning ceiling.
422
+ */
423
+ function extractExpiresIn(req) {
424
+ if (!req.url) return void 0;
425
+ let url;
426
+ try {
427
+ url = new URL(req.url, "http://switchboard.invalid");
428
+ } catch {
429
+ return;
430
+ }
431
+ const values = url.searchParams.getAll("expiresIn");
432
+ if (values.length === 0) return void 0;
433
+ if (values.length > 1) return "invalid";
434
+ const parsed = Number(values[0]);
435
+ if (!Number.isInteger(parsed) || parsed <= 0) return "invalid";
436
+ return Math.min(parsed, MAX_DOWNLOAD_TARGET_TTL_SECONDS);
437
+ }
438
+ /**
439
+ * Base URL of this Switchboard as seen by the caller, used to build
440
+ * filesystem `switchboard` download targets that point back at the existing
441
+ * authenticated byte route.
442
+ */
443
+ function requestBaseUrl(req) {
444
+ const forwardedProto = req.headers["x-forwarded-proto"];
445
+ const proto = (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0]?.trim() || (req.socket.encrypted ? "https" : "http");
446
+ const forwardedHost = req.headers["x-forwarded-host"];
447
+ const host = (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? req.headers.host;
448
+ if (!host) return null;
449
+ return `${proto}://${host}`;
450
+ }
451
+ function makeDownloadTargetHandler(attachments, attachmentAccess) {
452
+ return async (req, res, _body, actor) => {
453
+ res.setHeader("Cache-Control", "no-store");
454
+ const hash = extractParam(req, "hash");
455
+ if (!hash || !HASH_PATTERN.test(hash)) {
456
+ sendError(res, 400, "Invalid attachment hash");
457
+ return;
458
+ }
459
+ const documentId = extractSingleDocumentId(req);
460
+ if (documentId === null) {
461
+ sendError(res, 400, "documentId is required exactly once as a non-empty query parameter");
462
+ return;
463
+ }
464
+ const expiresIn = extractExpiresIn(req);
465
+ if (expiresIn === "invalid") {
466
+ sendError(res, 400, "expiresIn must be a single positive integer number of seconds");
467
+ return;
468
+ }
469
+ const canonicalHash = hash.toLowerCase();
470
+ let decision;
471
+ try {
472
+ decision = await attachmentAccess.canReadAttachment({
473
+ documentId,
474
+ attachmentRef: createRef(canonicalHash),
475
+ userAddress: actor?.user?.address
476
+ });
477
+ } catch (err) {
478
+ logger$1.error("Attachment access decision failed: @error", err);
479
+ sendError(res, 500, "Internal error");
480
+ return;
481
+ }
482
+ if (decision.kind === "projection-unavailable") {
483
+ sendError(res, 503, "Attachment downloads are temporarily unavailable");
484
+ return;
485
+ }
486
+ if (decision.kind === "denied") {
487
+ sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
488
+ return;
489
+ }
490
+ let header;
491
+ try {
492
+ header = await attachments.store.stat(canonicalHash);
493
+ } catch (err) {
494
+ if (err instanceof AttachmentNotFound) {
495
+ sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
496
+ return;
497
+ }
498
+ logger$1.error("Attachment metadata lookup failed: @error", err);
499
+ sendError(res, 500, "Internal error");
500
+ return;
501
+ }
502
+ if (header.status !== "available") {
503
+ sendJson(res, 404, ATTACHMENT_NOT_FOUND_BODY);
504
+ return;
505
+ }
506
+ let target;
507
+ if (attachments.backend && attachments.backend.kind !== "filesystem") try {
508
+ target = await attachments.backend.prepareDownloadTarget(canonicalHash, expiresIn);
509
+ } catch {
510
+ sendError(res, 502, "Attachment download target unavailable");
511
+ return;
512
+ }
513
+ else {
514
+ const base = requestBaseUrl(req);
515
+ if (!base) {
516
+ sendError(res, 500, "Internal error");
517
+ return;
518
+ }
519
+ try {
520
+ target = parseAttachmentDownloadTarget({
521
+ kind: "switchboard",
522
+ method: "GET",
523
+ url: `${base}/attachments/${canonicalHash}`,
524
+ headers: {}
525
+ });
526
+ } catch {
527
+ sendError(res, 500, "Internal error");
528
+ return;
529
+ }
530
+ }
531
+ sendJson(res, 200, target);
532
+ };
533
+ }
376
534
  //#endregion
377
535
  //#region src/attachments/index.ts
378
536
  function registerAttachmentRoutes(api) {
@@ -382,9 +540,50 @@ function registerAttachmentRoutes(api) {
382
540
  mountAuthenticatedNodeRoute(api, "DELETE", "/attachments/reservations/:reservationId", makeDeleteReservationHandler(attachments));
383
541
  mountAuthenticatedNodeRoute(api, "PUT", "/attachments/reservations/:reservationId", makeUploadHandler(attachments));
384
542
  mountAuthenticatedNodeRoute(api, "HEAD", "/attachments/:hash", makeStatHandler(attachments));
543
+ mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash/download-target", makeDownloadTargetHandler(attachments, api.attachmentAccess), { allowAnonymous: true });
385
544
  mountAuthenticatedNodeRoute(api, "GET", "/attachments/:hash", makeDownloadHandler(attachments));
386
545
  }
387
546
  //#endregion
547
+ //#region src/attachment-reference-read-model.mts
548
+ const attachmentSchemaCompiler = new AttachmentSchemaCompiler();
549
+ function createAttachmentReferenceReadModel(baseKysely, dependencies, attachmentReferenceWriter) {
550
+ return new AttachmentReferenceReadModel(baseKysely.withSchema(REACTOR_SCHEMA), dependencies.operationIndex, dependencies.writeCache, dependencies.processorManagerConsistencyTracker, dependencies.documentModelRegistry, attachmentSchemaCompiler, attachmentReferenceWriter);
551
+ }
552
+ function registerAttachmentReferenceReadModel(reactorBuilder, registration) {
553
+ reactorBuilder.withReadModelFactory(async ({ documentModelRegistry, operationIndex, writeCache, processorManagerConsistencyTracker }) => {
554
+ const readModel = createAttachmentReferenceReadModel(registration.baseKysely, {
555
+ operationIndex,
556
+ writeCache,
557
+ processorManagerConsistencyTracker,
558
+ documentModelRegistry
559
+ }, registration.attachmentReferenceWriter);
560
+ await readModel.init();
561
+ return readModel;
562
+ });
563
+ }
564
+ async function registerAttachmentReferenceReadModelOnModule(clientModule, attachmentReferenceWriter) {
565
+ const reactorModule = clientModule.reactorModule;
566
+ if (!reactorModule) return {
567
+ status: "unavailable",
568
+ reason: "in-process-reactor-module-unavailable"
569
+ };
570
+ const coordinator = reactorModule.readModelCoordinator;
571
+ if (!supportsLiveReadModelRegistration(coordinator)) return {
572
+ status: "unavailable",
573
+ reason: "live-read-model-registration-unsupported"
574
+ };
575
+ const readModel = createAttachmentReferenceReadModel(reactorModule.database, {
576
+ operationIndex: reactorModule.operationIndex,
577
+ writeCache: reactorModule.writeCache,
578
+ processorManagerConsistencyTracker: reactorModule.processorManagerConsistencyTracker,
579
+ documentModelRegistry: reactorModule.documentModelRegistry
580
+ }, attachmentReferenceWriter);
581
+ await readModel.init();
582
+ coordinator.addReadModel(readModel, "pre_ready");
583
+ await readModel.init();
584
+ return { status: "available" };
585
+ }
586
+ //#endregion
388
587
  //#region src/builder-defaults.mts
389
588
  /**
390
589
  * Apply switchboard's standard configuration to a reactor + client builder
@@ -400,17 +599,22 @@ function switchboardBaseDocumentModels() {
400
599
  return [
401
600
  documentModelDocumentModelModule,
402
601
  driveDocumentModelModule,
403
- reactorDriveDocumentModelModule
602
+ reactorDriveDocumentModelModule,
603
+ ReactorGroupV1
404
604
  ];
405
605
  }
406
606
  function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options = {}) {
407
607
  const baseModels = options.includeBaseModels !== false ? switchboardBaseDocumentModels() : [];
408
608
  const extra = options.documentModels ?? [];
409
609
  if (baseModels.length || extra.length) reactorBuilder.withDocumentModelSources(getUniqueDocumentModels(baseModels, extra));
610
+ if (options.upgradeManifests?.length) reactorBuilder.withUpgradeManifests(options.upgradeManifests);
410
611
  const scheme = options.channelScheme === void 0 ? ChannelScheme.SWITCHBOARD : options.channelScheme;
411
612
  if (scheme !== false) reactorBuilder.withChannelScheme(scheme);
412
613
  if (options.signalHandlers !== false) reactorBuilder.withSignalHandlers();
413
- if (options.executorConfig?.maxSkipThreshold !== void 0) reactorBuilder.withExecutorConfig({ maxSkipThreshold: options.executorConfig.maxSkipThreshold });
614
+ if (options.executorConfig?.maxSkipThreshold !== void 0 || options.executorConfig?.featureFlags !== void 0) reactorBuilder.withExecutorConfig({
615
+ ...options.executorConfig.maxSkipThreshold !== void 0 ? { maxSkipThreshold: options.executorConfig.maxSkipThreshold } : {},
616
+ ...options.executorConfig.featureFlags !== void 0 ? { featureFlags: options.executorConfig.featureFlags } : {}
617
+ });
414
618
  if (options.documentModelLoader) reactorBuilder.withDocumentModelLoader(options.documentModelLoader);
415
619
  if (options.logger) reactorBuilder.withLogger(options.logger);
416
620
  if (options.signer) clientBuilder.withSigner(options.signer);
@@ -419,6 +623,7 @@ function applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, options
419
623
  //#region src/worker-pool.mts
420
624
  const DEFAULT_DB_POOL_SIZE_PER_WORKER = 2;
421
625
  const DEFAULT_ACQUIRE_TIMEOUT_MS = 5e3;
626
+ const DEFAULT_DB_POOL_SIZE_HOST = 16;
422
627
  const AUTO_RESERVED_CORES = 2;
423
628
  const AUTO_WORKER_CAP = 8;
424
629
  /** Worker count for "auto": always at least 1 — auto sizes the pool, it does not turn worker mode off. */
@@ -444,6 +649,23 @@ function resolveWorkerPoolOptions(input, env, availableCores = os.availableParal
444
649
  };
445
650
  }
446
651
  /**
652
+ * Size of the reactor's own (host) Postgres pool. Unlike the worker pool there
653
+ * is no "disabled" state — the host pool is the only Postgres pool in the
654
+ * server path, backing both reactor storage and the read models that reach it
655
+ * via `withSchema` — so 0 is rejected rather than read as "off".
656
+ *
657
+ * The acquire timeout is deliberately not configurable here: the host pool
658
+ * waits indefinitely today, and making it finite converts saturation from a
659
+ * latency problem into a thrown error on the read-model path. That is a
660
+ * behavior change worth making separately, once the retry semantics of a
661
+ * rejected `pool.connect()` inside the read-model coordinator are settled.
662
+ */
663
+ function resolveHostPoolSize(env) {
664
+ const poolSize = parseNonNegativeInt(env.REACTOR_DB_POOL_SIZE_HOST, "REACTOR_DB_POOL_SIZE_HOST") ?? DEFAULT_DB_POOL_SIZE_HOST;
665
+ if (poolSize < 1) throw new Error("REACTOR_DB_POOL_SIZE_HOST must be at least 1; the host pool cannot be disabled");
666
+ return poolSize;
667
+ }
668
+ /**
447
669
  * DbConfig for each worker's own pool, parsed from a postgres:// URL.
448
670
  * Explicit host, database, and user are required — no pg-side defaults.
449
671
  */
@@ -723,6 +945,26 @@ async function rollback(dataDir, backupDir, originalError, logger) {
723
945
  logger.error(`[pglite-migration] Migration failed for ${dataDir}; rolled back from ${backupDir}. Original error: ${String(originalError)}`);
724
946
  }
725
947
  //#endregion
948
+ //#region src/reactor-feature-flags.mts
949
+ /**
950
+ * Enforcement flags from the REACTOR_* env vars. Each flag requires the ones
951
+ * before it; the reactor rejects an inconsistent set rather than enforcing less
952
+ * than the operator asked for, so this reports what was asked without
953
+ * correcting it.
954
+ */
955
+ function resolveReactorFeatureFlags(env) {
956
+ const flags = {
957
+ documentDecisions: env.REACTOR_DOCUMENT_DECISIONS === "true",
958
+ authEnforcement: env.REACTOR_AUTH_ENFORCEMENT === "true",
959
+ authGroups: env.REACTOR_AUTH_GROUPS === "true",
960
+ authConditions: env.REACTOR_AUTH_CONDITIONS === "true"
961
+ };
962
+ return {
963
+ flags,
964
+ enabled: Object.entries(flags).filter(([, isEnabled]) => isEnabled).map(([name]) => name)
965
+ };
966
+ }
967
+ //#endregion
726
968
  //#region src/renown.ts
727
969
  const logger = childLogger(["switchboard", "renown"]);
728
970
  /**
@@ -793,6 +1035,10 @@ function isPortAvailable(port) {
793
1035
  });
794
1036
  });
795
1037
  }
1038
+ /** The powerhouse.config.json this run reads, defaulting to the cwd copy. */
1039
+ function resolveConfigPath(configFile) {
1040
+ return configFile ?? path$1.join(process.cwd(), "powerhouse.config.json");
1041
+ }
796
1042
  async function resolveServerPort(requested, strictPort, logger) {
797
1043
  if (strictPort) return requested;
798
1044
  for (let i = 0; i < PORT_FALLBACK_ATTEMPTS; i++) {
@@ -805,11 +1051,20 @@ async function resolveServerPort(requested, strictPort, logger) {
805
1051
  return requested;
806
1052
  }
807
1053
  async function createReactorKysely(opts) {
808
- const { reactorDbUrl, reactorPgliteDir, reactorPgliteMajor, inMemory, flushIntervalMs, logger } = opts;
1054
+ const { reactorDbUrl, reactorPgliteDir, reactorPgliteMajor, inMemory, flushIntervalMs, hostPoolSize, logger } = opts;
809
1055
  if (reactorDbUrl && isPostgresUrl(reactorDbUrl)) {
810
- const pool = new Pool({ connectionString: reactorDbUrl.includes("?") ? reactorDbUrl : `${reactorDbUrl}?sslmode=disable` });
811
- logger.info("Using PostgreSQL for reactor storage");
812
- return new Kysely({ dialect: new PostgresDialect({ pool }) });
1056
+ const connectionString = reactorDbUrl.includes("?") ? reactorDbUrl : `${reactorDbUrl}?sslmode=disable`;
1057
+ const poolSize = hostPoolSize();
1058
+ const pool = new Pool({
1059
+ connectionString,
1060
+ max: poolSize
1061
+ });
1062
+ const poolInstrumentation = instrumentPgPool(pool, "reactor-host");
1063
+ logger.info(`Using PostgreSQL for reactor storage (host pool max ${poolSize})`);
1064
+ return {
1065
+ kysely: new Kysely({ dialect: new PostgresDialect({ pool }) }),
1066
+ poolInstrumentation
1067
+ };
813
1068
  }
814
1069
  if (!reactorPgliteDir || reactorPgliteMajor === null) throw new Error("Reactor PGLite directory not resolved");
815
1070
  const { PGlite } = await loadPGliteModule(reactorPgliteMajor);
@@ -818,7 +1073,10 @@ async function createReactorKysely(opts) {
818
1073
  flushIntervalMs
819
1074
  }) });
820
1075
  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}`);
821
- return new Kysely({ dialect: new ClosablePGliteDialect(pglite) });
1076
+ return {
1077
+ kysely: new Kysely({ dialect: new ClosablePGliteDialect(pglite) }),
1078
+ poolInstrumentation: void 0
1079
+ };
822
1080
  }
823
1081
  /** Derive the remote attachment service config for switchboard's own `/attachments/*` API. */
824
1082
  function deriveAttachmentServiceConfig(options, serverPort, renown) {
@@ -831,7 +1089,7 @@ function deriveAttachmentServiceConfig(options, serverPort, renown) {
831
1089
  }) : void 0 : void 0
832
1090
  };
833
1091
  }
834
- async function initServer(serverPort, options, renown) {
1092
+ async function initServer(serverPort, options, renown, renownConfig) {
835
1093
  const { dev, packages = [], remoteDrives = [], logger = defaultLogger } = options;
836
1094
  logger.level = LogLevel;
837
1095
  const dbPath = options.dbPath ?? process.env.DATABASE_URL ?? process.env.PH_SWITCHBOARD_DATABASE_URL;
@@ -897,7 +1155,8 @@ async function initServer(serverPort, options, renown) {
897
1155
  }
898
1156
  const apiRef = { current: void 0 };
899
1157
  let driveNodeView;
900
- const config = getConfig(options.configFile ?? path$1.join(process.cwd(), "powerhouse.config.json"));
1158
+ const configPath = resolveConfigPath(options.configFile);
1159
+ const config = getConfig(configPath);
901
1160
  const registryUrl = options.registryUrl ?? process.env.PH_REGISTRY_URL ?? config.packageRegistryUrl;
902
1161
  const registryPackages = process.env.PH_REGISTRY_PACKAGES;
903
1162
  const dynamicModelLoading = options.dynamicModelLoading ?? process.env.DYNAMIC_MODEL_LOADING === "true";
@@ -911,32 +1170,44 @@ async function initServer(serverPort, options, renown) {
911
1170
  });
912
1171
  }
913
1172
  const reactorLogger = logger.child(["reactor"]);
914
- const initializeClient = async (documentModels) => {
1173
+ let ownedReactorModule;
1174
+ const initializeClient = async (documentModels, { attachmentReferenceWriter, upgradeManifests }) => {
915
1175
  if (options.reactor) {
1176
+ const attachmentReferenceProjection = await registerAttachmentReferenceReadModelOnModule(options.reactor, attachmentReferenceWriter);
916
1177
  if (options.reactor.reactorModule) {
917
1178
  new ReactorInstrumentation(options.reactor.reactorModule).start();
918
1179
  reactorLogger.info("Reactor metrics instrumentation started (using caller-provided reactor)");
919
1180
  }
920
- return { module: options.reactor };
1181
+ return {
1182
+ module: options.reactor,
1183
+ attachmentReferenceProjection
1184
+ };
921
1185
  }
922
- const baseKysely = await createReactorKysely({
1186
+ const { kysely: baseKysely, poolInstrumentation } = await createReactorKysely({
923
1187
  reactorDbUrl,
924
1188
  reactorPgliteDir,
925
1189
  reactorPgliteMajor,
926
1190
  inMemory: PGLITE_IN_MEMORY,
927
1191
  flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS,
1192
+ hostPoolSize: () => resolveHostPoolSize(process.env),
928
1193
  logger
929
1194
  });
930
1195
  const maxSkipThreshold = parseInt(process.env.MAX_SKIP_THRESHOLD ?? "", 10);
931
1196
  const hasSkipThreshold = !isNaN(maxSkipThreshold) && maxSkipThreshold > 0;
932
1197
  if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
933
- if (hasSkipThreshold && workerPool) logger.warn("MAX_SKIP_THRESHOLD is not forwarded to executor workers and has no effect in worker-pool mode");
934
- const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely);
1198
+ const { flags: reactorFeatureFlags, enabled: enabledFeatureFlags } = resolveReactorFeatureFlags(process.env);
1199
+ if (enabledFeatureFlags.length > 0) logger.info(`Reactor feature flags enabled: ${enabledFeatureFlags.join(", ")}`);
1200
+ const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" });
1201
+ if (poolInstrumentation) reactorBuilder.withInstrumentedPool(poolInstrumentation);
935
1202
  const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
936
1203
  const vetraDocumentModels = dev ? Object.values(await import("@powerhousedao/vetra/document-models")).filter((m) => typeof m === "object" && m !== null && "documentModel" in m && "reducer" in m) : [];
937
1204
  applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, {
938
1205
  documentModels: [...documentModels, ...vetraDocumentModels],
939
- executorConfig: hasSkipThreshold ? { maxSkipThreshold } : void 0,
1206
+ upgradeManifests,
1207
+ executorConfig: hasSkipThreshold || enabledFeatureFlags.length > 0 ? {
1208
+ ...hasSkipThreshold ? { maxSkipThreshold } : {},
1209
+ ...enabledFeatureFlags.length > 0 ? { featureFlags: reactorFeatureFlags } : {}
1210
+ } : void 0,
940
1211
  documentModelLoader: httpLoader && dynamicModelLoading ? httpLoader.documentModelLoader : void 0,
941
1212
  logger: reactorLogger,
942
1213
  signer: renown ? getRenownSignerConfig(renown, options.identity?.requireSignatures) : void 0
@@ -955,6 +1226,10 @@ async function initServer(serverPort, options, renown) {
955
1226
  await nodeProcessor.init();
956
1227
  return nodeProcessor;
957
1228
  });
1229
+ registerAttachmentReferenceReadModel(reactorBuilder, {
1230
+ baseKysely,
1231
+ attachmentReferenceWriter
1232
+ });
958
1233
  reactorBuilder.withShutdownHook(async () => {
959
1234
  if (apiRef.current) await apiRef.current.dispose();
960
1235
  });
@@ -964,14 +1239,37 @@ async function initServer(serverPort, options, renown) {
964
1239
  reactorLogger.info("Reactor metrics instrumentation started");
965
1240
  }
966
1241
  driveNodeView = new DriveNodeView(baseKysely.withSchema(REACTOR_SCHEMA));
1242
+ const reactorDriveClient = new ReactorDriveClient({
1243
+ reactor: module.client,
1244
+ readModel: driveNodeView
1245
+ });
1246
+ ownedReactorModule = module;
967
1247
  return {
968
1248
  module,
969
- reactorDriveClient: new ReactorDriveClient({
970
- reactor: module.client,
971
- readModel: driveNodeView
972
- })
1249
+ reactorDriveClient,
1250
+ attachmentReferenceProjection: { status: "available" }
973
1251
  };
974
1252
  };
1253
+ const abortBoot = async (api) => {
1254
+ const owned = ownedReactorModule;
1255
+ if (owned) try {
1256
+ await owned.reactor.kill().completed;
1257
+ } catch (error) {
1258
+ logger.error("Aborting boot: reactor shutdown failed: @error", error);
1259
+ }
1260
+ try {
1261
+ await api.dispose();
1262
+ } catch (error) {
1263
+ logger.error("Aborting boot: api dispose failed: @error", error);
1264
+ }
1265
+ if (owned?.reactorModule) try {
1266
+ await owned.reactorModule.database.destroy();
1267
+ } catch (error) {
1268
+ logger.error("Aborting boot: database destroy failed: @error", error);
1269
+ }
1270
+ };
1271
+ let localCredentialCheck;
1272
+ 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;
975
1273
  let defaultDriveUrl = void 0;
976
1274
  const basePath = process.cwd();
977
1275
  let vite;
@@ -997,10 +1295,13 @@ async function initServer(serverPort, options, renown) {
997
1295
  let pgliteFactory;
998
1296
  if (readModelPgliteDir && readModelPgliteMajor !== null) {
999
1297
  const { PGlite: ReadModelPGlite } = await loadPGliteModule(readModelPgliteMajor);
1000
- pgliteFactory = PGLITE_IN_MEMORY ? () => new ReadModelPGlite() : (connectionString) => new ReadModelPGlite({ fs: new AtomicNodeFs(connectionString ?? readModelPgliteDir, {
1001
- logger,
1002
- flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS
1003
- }) });
1298
+ pgliteFactory = PGLITE_IN_MEMORY ? () => new ReadModelPGlite({ parsers: PGLITE_UTC_PARSERS }) : (connectionString) => new ReadModelPGlite({
1299
+ fs: new AtomicNodeFs(connectionString ?? readModelPgliteDir, {
1300
+ logger,
1301
+ flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS
1302
+ }),
1303
+ parsers: PGLITE_UTC_PARSERS
1304
+ });
1004
1305
  }
1005
1306
  const api = await initializeAndStartAPI(initializeClient, {
1006
1307
  port: serverPort,
@@ -1011,12 +1312,23 @@ async function initServer(serverPort, options, renown) {
1011
1312
  packages,
1012
1313
  processorConfig: options.processorConfig,
1013
1314
  processors: vetraProcessorFactory ? { "@powerhousedao/vetra": [vetraProcessorFactory] } : {},
1014
- configFile: options.configFile ?? path$1.join(process.cwd(), "powerhouse.config.json"),
1315
+ configFile: configPath,
1015
1316
  mcp: options.mcp ?? true,
1016
1317
  logger: apiLogger,
1017
- enableDocumentModelSubgraphs: options.enableDocumentModelSubgraphs
1318
+ enableDocumentModelSubgraphs: options.enableDocumentModelSubgraphs,
1319
+ renown: renownConfig,
1320
+ verifyCredential
1018
1321
  }, "switchboard");
1019
1322
  apiRef.current = api;
1323
+ if (renownConfig.source === "self") {
1324
+ const { graphqlManager: manager } = api;
1325
+ if (!manager.hasSubgraphHandler(RENOWN_READ_MODEL_SUBGRAPH)) {
1326
+ await abortBoot(api);
1327
+ 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.`);
1328
+ }
1329
+ localCredentialCheck = createLocalCredentialVerifier((query, variables) => manager.executeSubgraphQuery(RENOWN_READ_MODEL_SUBGRAPH, query, variables), { onError: (error) => logger.error("Renown read model query failed: @error", error) });
1330
+ logger.info("Renown credentials will be verified against this switchboard's own renown read model");
1331
+ }
1020
1332
  registerAttachmentRoutes(api);
1021
1333
  const attachmentService = createRemoteAttachmentService(deriveAttachmentServiceConfig(options, serverPort, renown));
1022
1334
  if (process.env.SENTRY_DSN) api.httpAdapter.setupSentryErrorHandler(Sentry);
@@ -1026,7 +1338,8 @@ async function initServer(serverPort, options, renown) {
1026
1338
  const packageManagementService = new PackageManagementService({
1027
1339
  defaultRegistryUrl: registryUrl,
1028
1340
  httpLoader,
1029
- documentModelRegistry
1341
+ documentModelRegistry,
1342
+ packageManager: api.packageManager
1030
1343
  });
1031
1344
  packageManagementService.setOnModelsChanged(() => {
1032
1345
  graphqlManager.regenerateDocumentModelSubgraphs().catch(logger.error);
@@ -1100,6 +1413,7 @@ async function initServer(serverPort, options, renown) {
1100
1413
  api,
1101
1414
  reactor: client,
1102
1415
  attachmentService,
1416
+ attachmentReferenceProjection: api.attachmentReferenceProjection,
1103
1417
  renown,
1104
1418
  port: serverPort,
1105
1419
  shutdown: () => api.dispose()
@@ -1130,9 +1444,11 @@ const startSwitchboard = async (options = {}) => {
1130
1444
  const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
1131
1445
  options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
1132
1446
  const requireSignatures = options.identity?.requireSignatures ?? await featureFlags.getBooleanValue(REQUIRE_SIGNATURES, REQUIRE_SIGNATURES_DEFAULT);
1447
+ const renownConfig = resolveRenownConfig(getConfig(resolveConfigPath(options.configFile)).auth?.renown, process.env, logger);
1133
1448
  options.identity = {
1134
1449
  ...options.identity,
1135
- requireSignatures
1450
+ requireSignatures,
1451
+ baseUrl: options.identity?.baseUrl ?? renownConfig.url
1136
1452
  };
1137
1453
  logger.info("Feature flags: @flags", JSON.stringify({
1138
1454
  DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
@@ -1146,7 +1462,7 @@ const startSwitchboard = async (options = {}) => {
1146
1462
  if (options.identity.requireExisting) throw new Error("Identity required but failed to initialize. Run \"ph login\" first.", { cause: e });
1147
1463
  }
1148
1464
  try {
1149
- return await initServer(serverPort, options, renown);
1465
+ return await initServer(serverPort, options, renown, renownConfig);
1150
1466
  } catch (e) {
1151
1467
  Sentry.captureException(e);
1152
1468
  logger.error("App crashed: @error", e);
@@ -1157,5 +1473,5 @@ if (import.meta.main) await startSwitchboard();
1157
1473
  //#endregion
1158
1474
  export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
1159
1475
 
1160
- //# sourceMappingURL=server-Cgud_ONM.mjs.map
1161
- //# debugId=5eb0a978-689a-51dd-978a-cd3d7fe5df4b
1476
+ //# sourceMappingURL=server-DYraD11D.mjs.map
1477
+ //# debugId=65ab8387-7c36-5ebd-b971-913ce2e772b3