@powerhousedao/switchboard 6.2.3-dev.32 → 6.2.3-dev.34

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/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## 6.2.3-dev.34 (2026-09-30)
2
+
3
+ ### 🚀 Features
4
+
5
+ - **switchboard:** let MCP_ENABLED=false keep the MCP server unmounted ([#3146](https://github.com/powerhouse-inc/powerhouse/pull/3146))
6
+
7
+ ### ❤️ Thank You
8
+
9
+ - Guillermo Puente Sandoval @gpuente
10
+
11
+ ## 6.2.3-dev.33 (2026-09-30)
12
+
13
+ ### 🚀 Features
14
+
15
+ - **switchboard:** expose the stored-protocol startup check ([de2bf4836](https://github.com/powerhouse-inc/powerhouse/commit/de2bf4836))
16
+ - **reactor:** add catch-up operator tools and observability ([5722d7836](https://github.com/powerhouse-inc/powerhouse/commit/5722d7836))
17
+
18
+ ### 🩹 Fixes
19
+
20
+ - **reactor:** claim a batch for each read model as it queues ([c09b1de2a](https://github.com/powerhouse-inc/powerhouse/commit/c09b1de2a))
21
+ - **reactor:** settle a probe on its own xid, not on an empty xip ([4e0f0dab9](https://github.com/powerhouse-inc/powerhouse/commit/4e0f0dab9))
22
+ - **reactor:** leave a stream to the next sweep while its live pass runs ([53112bad4](https://github.com/powerhouse-inc/powerhouse/commit/53112bad4))
23
+ - **reactor:** start a head registration at the sequence head ([e06581849](https://github.com/powerhouse-inc/powerhouse/commit/e06581849))
24
+ - **switchboard:** resolve packages it brings from its own dependencies in dev ([7c9c55d98](https://github.com/powerhouse-inc/powerhouse/commit/7c9c55d98))
25
+
26
+ ### ❤️ Thank You
27
+
28
+ - acaldas
29
+ - Benjamin Jordan
30
+ - Claude Opus 5.5
31
+
1
32
  ## 6.2.3-dev.32 (2026-09-29)
2
33
 
3
34
  This was a version bump only for @powerhousedao/switchboard to align it with other projects, there were no code changes.
package/README.md CHANGED
@@ -110,6 +110,7 @@ pnpm add -g @powerhousedao/switchboard
110
110
  | `REDIS_TLS_URL` | Redis TLS connection URL | - |
111
111
  | `PYROSCOPE_SERVER_ADDRESS` | Pyroscope server address | - |
112
112
  | `FEATURE_REACTORV2_ENABLED` | Enable Reactor v2 subgraph feature | `false` |
113
+ | `MCP_ENABLED` | `false` keeps `/mcp` unmounted | `true` |
113
114
 
114
115
  See [Observability](#observability) below for Sentry and OpenTelemetry variables.
115
116
 
@@ -200,6 +201,26 @@ switchboard accepts. Without a Renown identity the switchboard has no signer,
200
201
  so it creates legacy documents and logs a warning. A default drive configured
201
202
  with a fixed `id` is always created legacy.
202
203
 
204
+ ### Stored Documents This Build Does Not Run
205
+
206
+ | Variable | Description | Default |
207
+ | -------------------------------------- | -------------------------------------------------------------------------------------- | -------- |
208
+ | `REACTOR_UNSUPPORTED_STORED_DOCUMENTS` | Stored documents at protocol versions this build does not run: `refuse` or `read-only` | `refuse` |
209
+
210
+ Before it starts, the reactor reads the protocol versions its stored documents
211
+ were created with. If this build does not run one of them, for example after a
212
+ rollback, switchboard logs this and exits with code 1:
213
+
214
+ ```
215
+ Refusing to start: 12 stored document(s) require base-reducer 3, which this switchboard does not run. Either start a switchboard build that runs base-reducer 3, or set REACTOR_UNSUPPORTED_STORED_DOCUMENTS=read-only to start with those documents read-only.
216
+ ```
217
+
218
+ With `read-only` it starts and logs a warning. Every write into those documents
219
+ and every operation received for them is refused; the rest of the store works
220
+ as usual. The check runs once on the host, so it covers the executor worker
221
+ pool too. Any other value fails the boot. Programmatically:
222
+ `startSwitchboard({ unsupportedStoredDocuments: "read-only" })`.
223
+
203
224
  ### Reactor Enforcement Flags
204
225
 
205
226
  | Variable | Description | Default |
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- !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]="f6ffead6-4742-59d9-948c-1c651716e65b")}catch(e){}}();
4
- import { a as parseForcePgVersion, r as startSwitchboard } from "./server-CKKn0aXV.mjs";
3
+ !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]="691165f8-a729-5658-bf22-e7b2440e6156")}catch(e){}}();
4
+ import { i as StoredDocumentsRefusedError, r as startSwitchboard, s as parseForcePgVersion } from "./server-sQoWp21G.mjs";
5
5
  import { metrics } from "@opentelemetry/api";
6
6
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
7
7
  import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express";
@@ -249,8 +249,10 @@ try {
249
249
  fatalErrorShutdown: true
250
250
  });
251
251
  } catch (e) {
252
- Sentry.captureException(e);
253
- logger.error("Switchboard failed to start: @error", e);
252
+ if (!StoredDocumentsRefusedError.isError(e)) {
253
+ Sentry.captureException(e);
254
+ logger.error("Switchboard failed to start: @error", e);
255
+ }
254
256
  await Sentry.flush(2e3).catch(() => void 0);
255
257
  process.exit(1);
256
258
  }
@@ -258,4 +260,4 @@ try {
258
260
  export {};
259
261
 
260
262
  //# sourceMappingURL=index.mjs.map
261
- //# debugId=f6ffead6-4742-59d9-948c-1c651716e65b
263
+ //# debugId=691165f8-a729-5658-bf22-e7b2440e6156
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","sources":["../src/metrics.ts","../src/redact-webhook-path.ts","../src/observability.mts","../src/config.ts","../src/profiler.ts","../src/index.mts"],"sourcesContent":["import { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-http\";\nimport { resourceFromAttributes } from \"@opentelemetry/resources\";\nimport {\n MeterProvider,\n PeriodicExportingMetricReader,\n} from \"@opentelemetry/sdk-metrics\";\nimport { childLogger } from \"document-model\";\n\nconst logger = childLogger([\"switchboard\", \"metrics\"]);\n\nexport function createMeterProviderFromEnv(env: {\n OTEL_EXPORTER_OTLP_ENDPOINT?: string;\n OTEL_METRIC_EXPORT_INTERVAL?: string;\n OTEL_SERVICE_NAME?: string;\n}): MeterProvider | undefined {\n const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;\n if (!endpoint) return undefined;\n\n const parsed = parseInt(env.OTEL_METRIC_EXPORT_INTERVAL ?? \"\", 10);\n const exportIntervalMillis =\n Number.isFinite(parsed) && parsed > 0 ? parsed : 5_000;\n\n const base = endpoint.replace(/\\/$/, \"\");\n const exporterUrl = base.endsWith(\"/v1/metrics\")\n ? base\n : `${base}/v1/metrics`;\n\n logger.info(`Initializing OpenTelemetry metrics exporter at: ${endpoint}`);\n const meterProvider = new MeterProvider({\n resource: resourceFromAttributes({\n \"service.name\": env.OTEL_SERVICE_NAME ?? \"switchboard\",\n }),\n readers: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({\n url: exporterUrl,\n }),\n exportIntervalMillis,\n exportTimeoutMillis: Math.max(exportIntervalMillis - 250, 1),\n }),\n ],\n });\n logger.info(`Metrics export enabled (interval: ${exportIntervalMillis}ms)`);\n return meterProvider;\n}\n","const WEBHOOK_SEGMENT = \"/webhooks/\";\n\n/**\n * Strips a webhook's delivery token out of a request path.\n *\n * The token is the endpoint's whole credential and it sits in the path, so it\n * must not reach a log line or a span attribute that ships off-box. The family\n * mounts under the host's base path, so the segment can sit at any depth.\n */\nexport function redactWebhookPath(url: string | undefined): string {\n if (!url) return \"\";\n // Dropped with the token: `http.route` is meant to be a template, and a\n // query string is another place a sender can put a credential.\n const path = url.split(\"?\")[0] ?? url;\n const at = path.indexOf(WEBHOOK_SEGMENT);\n if (at === -1) return url;\n // Nothing after the segment is nothing to redact.\n if (path.length === at + WEBHOOK_SEGMENT.length) return url;\n return `${path.slice(0, at)}${WEBHOOK_SEGMENT}[redacted]`;\n}\n","// Single observability bootstrap: Sentry + OpenTelemetry (tracing + metrics).\n//\n// MUST be imported as the very first module in apps/switchboard/src/index.mts.\n// OpenTelemetry instrumentations register require-time hooks at module load,\n// so http/express/pg/graphql must not be imported (transitively) before this\n// file runs.\n//\n// Replaces three legacy bootstrap sites:\n// - apps/switchboard/src/server.mts top-level Sentry.init\n// - apps/switchboard/src/metrics.ts standalone MeterProvider (still exported\n// here via createMeterProviderFromEnv so its tests keep passing)\n// - packages/reactor-api/src/tracing.ts side-effect NodeSDK\nimport { metrics } from \"@opentelemetry/api\";\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-http\";\nimport { ExpressInstrumentation } from \"@opentelemetry/instrumentation-express\";\nimport { GraphQLInstrumentation } from \"@opentelemetry/instrumentation-graphql\";\nimport { HttpInstrumentation } from \"@opentelemetry/instrumentation-http\";\nimport { PgInstrumentation } from \"@opentelemetry/instrumentation-pg\";\nimport { UndiciInstrumentation } from \"@opentelemetry/instrumentation-undici\";\nimport { resourceFromAttributes } from \"@opentelemetry/resources\";\nimport type { MeterProvider } from \"@opentelemetry/sdk-metrics\";\nimport { NodeSDK } from \"@opentelemetry/sdk-node\";\nimport {\n BatchSpanProcessor,\n type SpanProcessor,\n} from \"@opentelemetry/sdk-trace-base\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport {\n registerEventLoopInstrumentation,\n type EventLoopInstrumentation,\n} from \"@powerhousedao/opentelemetry-instrumentation-reactor\";\nimport * as Sentry from \"@sentry/node\";\nimport { SentryPropagator, SentrySpanProcessor } from \"@sentry/opentelemetry\";\nimport { childLogger } from \"document-model\";\nimport type { IncomingMessage } from \"node:http\";\nimport { createMeterProviderFromEnv } from \"./metrics.js\";\nimport { redactWebhookPath } from \"./redact-webhook-path.js\";\n\nconst logger = childLogger([\"switchboard\", \"observability\"]);\n\nconst SERVICE_NAME = process.env.OTEL_SERVICE_NAME || \"switchboard\";\nconst SERVICE_VERSION = process.env.npm_package_version || \"unknown\";\nconst TENANT_ID = process.env.TENANT_ID || \"default\";\nconst DEPLOY_ENV = process.env.NODE_ENV || \"development\";\n\nconst TEMPO_ENDPOINT = process.env.TEMPO_ENDPOINT;\nconst SENTRY_DSN = process.env.SENTRY_DSN;\n\n// Whether to forward TRANSACTIONS (spans) to Sentry. Errors are always sent\n// when SENTRY_DSN is set — this flag only gates APM/tracing into Sentry.\n// Default on (back-compat). Set SENTRY_TRACING_ENABLED=false for an\n// \"errors-only\" deployment: errors still go to Sentry, traces still go to\n// Tempo, but no transactions hit Sentry (no Kafka/ClickHouse/nodestore\n// cost). This is the recommended mode for tenant workloads at scale —\n// Sentry's value there is error grouping; traces live in Tempo.\nconst SENTRY_TRACING_TO_SENTRY =\n Boolean(SENTRY_DSN) && process.env.SENTRY_TRACING_ENABLED !== \"false\";\n\nconst TRACING_REQUESTED =\n process.env.ENABLE_TRACING === \"true\" ||\n process.env.NODE_ENV === \"production\";\nconst HAS_TRACE_DESTINATION = Boolean(TEMPO_ENDPOINT) || Boolean(SENTRY_DSN);\nconst TRACING_ENABLED = TRACING_REQUESTED && HAS_TRACE_DESTINATION;\n\nif (TRACING_REQUESTED && !HAS_TRACE_DESTINATION) {\n logger.warn(\n \"Tracing was requested (NODE_ENV=production or ENABLE_TRACING=true) but \" +\n \"no destination is configured — instrumentation will not run. Set \" +\n \"TEMPO_ENDPOINT (e.g. http://tempo.monitoring.svc.cluster.local:4318/v1/traces) \" +\n \"to export OTLP spans, and/or SENTRY_DSN to forward spans to Sentry.\",\n );\n}\n\n// APM sampling for the Sentry-SDK-managed path (i.e. when TRACING is OFF and\n// @sentry/node runs its own bundled OTel — see skipOpenTelemetrySetup below).\n//\n// IMPORTANT: when TRACING_ENABLED, this value does NOT govern span volume.\n// Our NodeSDK (below) owns the pipeline and is constructed with no explicit\n// `sampler`, so @opentelemetry/sdk-node falls back to buildSamplerFromEnv()\n// and the REAL head-sampling knob is the standard OTEL_TRACES_SAMPLER /\n// OTEL_TRACES_SAMPLER_ARG env (set per-deploy in the k8s chart). That head\n// decision gates spans before any processor runs, so it bounds BOTH the\n// SentrySpanProcessor (→ Sentry transactions) and the Tempo OTLP export.\n// (Wiring @sentry/opentelemetry's SentrySampler here would let this rate\n// drive both backends and make DSC/sample_rand propagation spec-correct, but\n// that only matters for Sentry server-side dynamic sampling — a SaaS feature\n// our self-hosted install doesn't run — so it's intentionally deferred.)\nconst SENTRY_TRACES_SAMPLE_RATE = parseFloat(\n process.env.SENTRY_TRACES_SAMPLE_RATE ?? \"0.1\",\n);\n\nif (SENTRY_DSN) {\n logger.info(\"Initialized Sentry with env: @env\", process.env.SENTRY_ENV);\n Sentry.init({\n dsn: SENTRY_DSN,\n environment: process.env.SENTRY_ENV,\n // Match the version tag uploaded by release-branch.yml so source maps\n // resolve. Populated by the CI (WORKSPACE_VERSION) or npm at runtime.\n release:\n process.env.SENTRY_RELEASE ||\n (process.env.npm_package_version\n ? `v${process.env.npm_package_version}`\n : undefined),\n // 0 in errors-only mode so even @sentry/node's bundled auto-OTel path\n // (used when TRACING_ENABLED is false) produces no transactions.\n tracesSampleRate: SENTRY_TRACING_TO_SENTRY ? SENTRY_TRACES_SAMPLE_RATE : 0,\n // When tracing is on, our NodeSDK below owns the OTel globals and Sentry\n // receives spans via SentrySpanProcessor. Skipping Sentry's bundled OTel\n // setup avoids two TracerProviders fighting over setGlobalTracerProvider.\n // When tracing is off, leave the flag unset so @sentry/node's default\n // auto-OTel still records HTTP transactions for the APM dashboard.\n skipOpenTelemetrySetup: TRACING_ENABLED,\n });\n}\n\nconst meterProvider: MeterProvider | undefined = createMeterProviderFromEnv({\n OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,\n OTEL_METRIC_EXPORT_INTERVAL: process.env.OTEL_METRIC_EXPORT_INTERVAL,\n OTEL_SERVICE_NAME: process.env.OTEL_SERVICE_NAME,\n});\nlet eventLoopInstrumentation: EventLoopInstrumentation | undefined;\nif (meterProvider) {\n // One-way door: must register before any code calls metrics.getMeter() —\n // most notably ReactorInstrumentation inside the reactor module.\n metrics.setGlobalMeterProvider(meterProvider);\n // Host event-loop and CPU gauges (reactor.host.eventloop.*,\n // reactor.host.cpu.utilization). The bench sweep identified the host event\n // loop, not the executor, as the binding constraint on write throughput\n // (bench/test/integration/BASELINE.md, Run 10) — these are the production\n // counterpart of the metrics that finding was made from. Must come after\n // setGlobalMeterProvider or the gauges bind to a no-op meter.\n eventLoopInstrumentation = registerEventLoopInstrumentation();\n}\n\nlet sdk: NodeSDK | undefined;\n\nif (TRACING_ENABLED) {\n logger.info(`Initializing OpenTelemetry tracing for ${SERVICE_NAME}`);\n if (TEMPO_ENDPOINT) logger.info(` Tempo endpoint: ${TEMPO_ENDPOINT}`);\n if (SENTRY_DSN) logger.info(` Sentry span forwarding: enabled`);\n logger.info(` Tenant: ${TENANT_ID}`);\n\n const resource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: SERVICE_NAME,\n [ATTR_SERVICE_VERSION]: SERVICE_VERSION,\n \"tenant.id\": TENANT_ID,\n \"deployment.environment\": DEPLOY_ENV,\n });\n\n const spanProcessors: SpanProcessor[] = [];\n if (TEMPO_ENDPOINT) {\n spanProcessors.push(\n new BatchSpanProcessor(new OTLPTraceExporter({ url: TEMPO_ENDPOINT })),\n );\n }\n if (SENTRY_TRACING_TO_SENTRY) {\n // Fan the same OTel spans into Sentry — same trace IDs as Tempo, so\n // Sentry transactions cross-link to traces in Grafana. Skipped in\n // errors-only mode (SENTRY_TRACING_ENABLED=false): spans still flow to\n // Tempo via the BatchSpanProcessor above, just not to Sentry.\n spanProcessors.push(new SentrySpanProcessor());\n }\n\n sdk = new NodeSDK({\n resource,\n spanProcessors,\n textMapPropagator: SENTRY_TRACING_TO_SENTRY\n ? new SentryPropagator()\n : undefined,\n instrumentations: [\n new HttpInstrumentation({\n ignoreIncomingRequestHook: (req) =>\n req.url === \"/health\" || req.url === \"/ready\",\n requireParentforIncomingSpans: false,\n requireParentforOutgoingSpans: false,\n requestHook: (span, request) => {\n span.setAttribute(\n \"http.route\",\n redactWebhookPath((request as IncomingMessage).url),\n );\n },\n responseHook: (span, response) => {\n if (response.statusCode) {\n span.setAttribute(\"http.status_code\", response.statusCode);\n }\n },\n }),\n new ExpressInstrumentation({\n requestHook: (span, info) => {\n if (info.route) span.setAttribute(\"http.route\", info.route);\n },\n }),\n // HttpInstrumentation only patches node:http/https; outbound global\n // fetch() goes through undici and is otherwise untraced — e.g. the\n // per-request Renown credential check in AuthService.verifyCredentialExists.\n new UndiciInstrumentation(),\n new GraphQLInstrumentation({ mergeItems: true, allowValues: true }),\n // requireParentSpan: only trace DB queries that run inside a request\n // (HTTP/GraphQL) span. Parentless queries — the management\n // switchboard's background polling loops (vetra-cloud-observability\n // reconcile @60s + clint pull-worker @15s, each writing\n // environment_pods / clint_runtime_endpoints) — would otherwise each\n // become a standalone root transaction. That volume scales O(tenant\n // count) and was ~70% of all Sentry transactions before this change.\n // Dropping it at the instrumentation layer (no span created at all) is\n // cheaper and cleaner than sampling it away downstream.\n new PgInstrumentation({\n enhancedDatabaseReporting: true,\n requireParentSpan: true,\n }),\n ],\n });\n sdk.start();\n if (\n SENTRY_TRACING_TO_SENTRY &&\n typeof Sentry.validateOpenTelemetrySetup === \"function\"\n ) {\n Sentry.validateOpenTelemetrySetup();\n }\n logger.info(\"OpenTelemetry tracing initialized\");\n}\n\nasync function shutdown() {\n // Detach the observable callback before the provider drains, so the final\n // export does not fire a gauge callback against a shutting-down meter.\n eventLoopInstrumentation?.stop();\n await Promise.race([\n Promise.all([\n meterProvider?.shutdown().catch(() => undefined),\n sdk?.shutdown().catch(() => undefined),\n ]),\n new Promise<void>((resolve) => setTimeout(resolve, 5_000)),\n ]);\n}\n\nprocess.on(\"SIGINT\", () => {\n void shutdown().finally(() => process.exit(0));\n});\nprocess.on(\"SIGTERM\", () => {\n void shutdown().finally(() => process.exit(0));\n});\n\nexport { meterProvider, sdk };\n","import dotenv from \"dotenv\";\ndotenv.config();\n\nimport { getConfig } from \"@powerhousedao/config/node\";\nimport { parseForcePgVersion } from \"./pglite-version.js\";\nimport type {\n SwitchboardDriveDocumentType,\n SwitchboardDriveInput,\n} from \"./types.js\";\nconst phConfig = getConfig();\nconst { switchboard } = phConfig;\ninterface Config {\n database: {\n url: string;\n };\n port: number;\n mcp: boolean;\n migratePglite: boolean;\n forcePgVersion: 16 | 17 | null;\n drive: SwitchboardDriveInput;\n}\n\nfunction parseDriveType(\n raw: string | undefined,\n): SwitchboardDriveDocumentType | undefined {\n if (!raw) return undefined;\n if (raw === \"powerhouse/document-drive\" || raw === \"powerhouse/reactor-drive\")\n return raw;\n throw new Error(\n `Invalid PH_DEFAULT_DRIVE_TYPE: ${raw}. Expected \"powerhouse/document-drive\" or \"powerhouse/reactor-drive\".`,\n );\n}\n\nexport const config: Config = {\n database: {\n // url: process.env.PH_SWITCHBOARD_DATABASE_URL ?? switchboard?.database?.url ?? \"dev.db\",\n url:\n process.env.PH_SWITCHBOARD_DATABASE_URL ??\n switchboard?.database?.url ??\n \"dev.db\",\n },\n port:\n process.env.PH_SWITCHBOARD_PORT &&\n !isNaN(Number(process.env.PH_SWITCHBOARD_PORT))\n ? Number(process.env.PH_SWITCHBOARD_PORT)\n : (switchboard?.port ?? 4001),\n mcp: true,\n migratePglite: process.env.PH_MIGRATE_PGLITE === \"true\",\n forcePgVersion: parseForcePgVersion(process.env.PH_FORCE_PG_VERSION),\n drive: {\n id: \"powerhouse\",\n slug: \"powerhouse\",\n documentType: parseDriveType(process.env.PH_DEFAULT_DRIVE_TYPE),\n global: {\n name: \"Powerhouse\",\n icon: \"https://ipfs.io/ipfs/QmcaTDBYn8X2psGaXe7iQ6qd8q6oqHLgxvMX9yXf7f9uP7\",\n },\n local: {\n availableOffline: true,\n listeners: [],\n sharingType: \"public\",\n triggers: [],\n },\n },\n};\n","import type { PyroscopeConfig } from \"@pyroscope/nodejs\";\n\nexport async function initProfilerFromEnv(env: typeof process.env) {\n const {\n PYROSCOPE_SERVER_ADDRESS: serverAddress,\n PYROSCOPE_APPLICATION_NAME: appName,\n PYROSCOPE_USER: basicAuthUser,\n PYROSCOPE_PASSWORD: basicAuthPassword,\n PYROSCOPE_WALL_ENABLED: wallEnabled,\n PYROSCOPE_HEAP_ENABLED: heapEnabled,\n } = env;\n\n const options: PyroscopeConfig = {\n serverAddress,\n appName,\n basicAuthUser,\n basicAuthPassword,\n // Wall profiling captures wall-clock time (includes async I/O waits)\n // This shows GraphQL resolvers even when waiting for database\n wall: {\n samplingDurationMs: 10000, // 10 second sampling windows\n samplingIntervalMicros: 10000, // 10ms sampling interval (100 samples/sec)\n collectCpuTime: true, // Also collect CPU time alongside wall time\n },\n // Heap profiling for memory allocation tracking\n heap: {\n samplingIntervalBytes: 512 * 1024, // Sample every 512KB allocated\n stackDepth: 64, // Capture deeper stacks for better context\n },\n };\n return initProfiler(options, {\n wallEnabled: wallEnabled !== \"false\",\n heapEnabled: heapEnabled === \"true\",\n });\n}\n\ninterface ProfilerFlags {\n wallEnabled?: boolean;\n heapEnabled?: boolean;\n}\n\nexport async function initProfiler(\n options?: PyroscopeConfig,\n flags: ProfilerFlags = { wallEnabled: true, heapEnabled: false },\n) {\n console.log(\"Initializing Pyroscope profiler at:\", options?.serverAddress);\n console.log(\" Wall profiling:\", flags.wallEnabled ? \"enabled\" : \"disabled\");\n console.log(\" Heap profiling:\", flags.heapEnabled ? \"enabled\" : \"disabled\");\n\n const { default: Pyroscope } = await import(\"@pyroscope/nodejs\");\n Pyroscope.init(options);\n\n // Start wall profiling (captures async I/O time - shows resolvers)\n if (flags.wallEnabled) {\n Pyroscope.startWallProfiling();\n }\n\n // Start CPU profiling (captures CPU-bound work)\n Pyroscope.startCpuProfiling();\n\n // Optionally start heap profiling (memory allocations)\n if (flags.heapEnabled) {\n Pyroscope.startHeapProfiling();\n }\n}\n","#!/usr/bin/env node\n// Observability MUST load before any module that imports http/express/pg/graphql\n// so OpenTelemetry's require-time hooks can patch them. It also owns Sentry\n// init and the SIGINT/SIGTERM flush.\nimport \"./observability.mjs\";\n\nimport * as Sentry from \"@sentry/node\";\nimport { childLogger } from \"document-model\";\nimport { config } from \"./config.js\";\nimport { initProfilerFromEnv } from \"./profiler.js\";\nimport { startSwitchboard } from \"./server.mjs\";\n\nconst logger = childLogger([\"switchboard\"]);\n\nfunction ensureNodeVersion(minVersion = \"24\") {\n const version = process.versions.node;\n if (!version) {\n return;\n }\n\n if (version < minVersion) {\n console.error(\n `Node version ${minVersion} or higher is required. Current version: ${version}`,\n );\n process.exit(1);\n }\n}\n// Ensure minimum Node.js version\nensureNodeVersion(\"24\");\n\n// Each subgraph registers its own SIGINT/SIGTERM listeners, and the count\n// scales with dynamically-loaded document models beyond the default cap of 10.\nprocess.setMaxListeners(0);\n\nif (process.env.PYROSCOPE_SERVER_ADDRESS) {\n try {\n await initProfilerFromEnv(process.env);\n } catch (e) {\n Sentry.captureException(e);\n logger.error(\"Error starting profiler: @error\", e);\n }\n}\n\nconst cliMigratePglite = process.argv.slice(2).includes(\"--migrate-pglite\");\n\n// A boot failure leaves no serving process behind, so it must exit non-zero:\n// logging and continuing hides a dead switchboard from the supervisor, which\n// would otherwise never restart it.\ntry {\n await startSwitchboard({\n ...config,\n migratePglite: cliMigratePglite || config.migratePglite,\n forcePgVersion: config.forcePgVersion ?? undefined,\n fatalErrorShutdown: true,\n });\n} catch (e) {\n Sentry.captureException(e);\n logger.error(\"Switchboard failed to start: @error\", e);\n await Sentry.flush(2000).catch(() => undefined);\n process.exit(1);\n}\n"],"names":["logger","logger"],"mappings":"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","debug_id":"f6ffead6-4742-59d9-948c-1c651716e65b"}
1
+ {"version":3,"file":"index.mjs","sources":["../src/metrics.ts","../src/redact-webhook-path.ts","../src/observability.mts","../src/config.ts","../src/profiler.ts","../src/index.mts"],"sourcesContent":["import { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-http\";\nimport { resourceFromAttributes } from \"@opentelemetry/resources\";\nimport {\n MeterProvider,\n PeriodicExportingMetricReader,\n} from \"@opentelemetry/sdk-metrics\";\nimport { childLogger } from \"document-model\";\n\nconst logger = childLogger([\"switchboard\", \"metrics\"]);\n\nexport function createMeterProviderFromEnv(env: {\n OTEL_EXPORTER_OTLP_ENDPOINT?: string;\n OTEL_METRIC_EXPORT_INTERVAL?: string;\n OTEL_SERVICE_NAME?: string;\n}): MeterProvider | undefined {\n const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;\n if (!endpoint) return undefined;\n\n const parsed = parseInt(env.OTEL_METRIC_EXPORT_INTERVAL ?? \"\", 10);\n const exportIntervalMillis =\n Number.isFinite(parsed) && parsed > 0 ? parsed : 5_000;\n\n const base = endpoint.replace(/\\/$/, \"\");\n const exporterUrl = base.endsWith(\"/v1/metrics\")\n ? base\n : `${base}/v1/metrics`;\n\n logger.info(`Initializing OpenTelemetry metrics exporter at: ${endpoint}`);\n const meterProvider = new MeterProvider({\n resource: resourceFromAttributes({\n \"service.name\": env.OTEL_SERVICE_NAME ?? \"switchboard\",\n }),\n readers: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({\n url: exporterUrl,\n }),\n exportIntervalMillis,\n exportTimeoutMillis: Math.max(exportIntervalMillis - 250, 1),\n }),\n ],\n });\n logger.info(`Metrics export enabled (interval: ${exportIntervalMillis}ms)`);\n return meterProvider;\n}\n","const WEBHOOK_SEGMENT = \"/webhooks/\";\n\n/**\n * Strips a webhook's delivery token out of a request path.\n *\n * The token is the endpoint's whole credential and it sits in the path, so it\n * must not reach a log line or a span attribute that ships off-box. The family\n * mounts under the host's base path, so the segment can sit at any depth.\n */\nexport function redactWebhookPath(url: string | undefined): string {\n if (!url) return \"\";\n // Dropped with the token: `http.route` is meant to be a template, and a\n // query string is another place a sender can put a credential.\n const path = url.split(\"?\")[0] ?? url;\n const at = path.indexOf(WEBHOOK_SEGMENT);\n if (at === -1) return url;\n // Nothing after the segment is nothing to redact.\n if (path.length === at + WEBHOOK_SEGMENT.length) return url;\n return `${path.slice(0, at)}${WEBHOOK_SEGMENT}[redacted]`;\n}\n","// Single observability bootstrap: Sentry + OpenTelemetry (tracing + metrics).\n//\n// MUST be imported as the very first module in apps/switchboard/src/index.mts.\n// OpenTelemetry instrumentations register require-time hooks at module load,\n// so http/express/pg/graphql must not be imported (transitively) before this\n// file runs.\n//\n// Replaces three legacy bootstrap sites:\n// - apps/switchboard/src/server.mts top-level Sentry.init\n// - apps/switchboard/src/metrics.ts standalone MeterProvider (still exported\n// here via createMeterProviderFromEnv so its tests keep passing)\n// - packages/reactor-api/src/tracing.ts side-effect NodeSDK\nimport { metrics } from \"@opentelemetry/api\";\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-http\";\nimport { ExpressInstrumentation } from \"@opentelemetry/instrumentation-express\";\nimport { GraphQLInstrumentation } from \"@opentelemetry/instrumentation-graphql\";\nimport { HttpInstrumentation } from \"@opentelemetry/instrumentation-http\";\nimport { PgInstrumentation } from \"@opentelemetry/instrumentation-pg\";\nimport { UndiciInstrumentation } from \"@opentelemetry/instrumentation-undici\";\nimport { resourceFromAttributes } from \"@opentelemetry/resources\";\nimport type { MeterProvider } from \"@opentelemetry/sdk-metrics\";\nimport { NodeSDK } from \"@opentelemetry/sdk-node\";\nimport {\n BatchSpanProcessor,\n type SpanProcessor,\n} from \"@opentelemetry/sdk-trace-base\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport {\n registerEventLoopInstrumentation,\n type EventLoopInstrumentation,\n} from \"@powerhousedao/opentelemetry-instrumentation-reactor\";\nimport * as Sentry from \"@sentry/node\";\nimport { SentryPropagator, SentrySpanProcessor } from \"@sentry/opentelemetry\";\nimport { childLogger } from \"document-model\";\nimport type { IncomingMessage } from \"node:http\";\nimport { createMeterProviderFromEnv } from \"./metrics.js\";\nimport { redactWebhookPath } from \"./redact-webhook-path.js\";\n\nconst logger = childLogger([\"switchboard\", \"observability\"]);\n\nconst SERVICE_NAME = process.env.OTEL_SERVICE_NAME || \"switchboard\";\nconst SERVICE_VERSION = process.env.npm_package_version || \"unknown\";\nconst TENANT_ID = process.env.TENANT_ID || \"default\";\nconst DEPLOY_ENV = process.env.NODE_ENV || \"development\";\n\nconst TEMPO_ENDPOINT = process.env.TEMPO_ENDPOINT;\nconst SENTRY_DSN = process.env.SENTRY_DSN;\n\n// Whether to forward TRANSACTIONS (spans) to Sentry. Errors are always sent\n// when SENTRY_DSN is set — this flag only gates APM/tracing into Sentry.\n// Default on (back-compat). Set SENTRY_TRACING_ENABLED=false for an\n// \"errors-only\" deployment: errors still go to Sentry, traces still go to\n// Tempo, but no transactions hit Sentry (no Kafka/ClickHouse/nodestore\n// cost). This is the recommended mode for tenant workloads at scale —\n// Sentry's value there is error grouping; traces live in Tempo.\nconst SENTRY_TRACING_TO_SENTRY =\n Boolean(SENTRY_DSN) && process.env.SENTRY_TRACING_ENABLED !== \"false\";\n\nconst TRACING_REQUESTED =\n process.env.ENABLE_TRACING === \"true\" ||\n process.env.NODE_ENV === \"production\";\nconst HAS_TRACE_DESTINATION = Boolean(TEMPO_ENDPOINT) || Boolean(SENTRY_DSN);\nconst TRACING_ENABLED = TRACING_REQUESTED && HAS_TRACE_DESTINATION;\n\nif (TRACING_REQUESTED && !HAS_TRACE_DESTINATION) {\n logger.warn(\n \"Tracing was requested (NODE_ENV=production or ENABLE_TRACING=true) but \" +\n \"no destination is configured — instrumentation will not run. Set \" +\n \"TEMPO_ENDPOINT (e.g. http://tempo.monitoring.svc.cluster.local:4318/v1/traces) \" +\n \"to export OTLP spans, and/or SENTRY_DSN to forward spans to Sentry.\",\n );\n}\n\n// APM sampling for the Sentry-SDK-managed path (i.e. when TRACING is OFF and\n// @sentry/node runs its own bundled OTel — see skipOpenTelemetrySetup below).\n//\n// IMPORTANT: when TRACING_ENABLED, this value does NOT govern span volume.\n// Our NodeSDK (below) owns the pipeline and is constructed with no explicit\n// `sampler`, so @opentelemetry/sdk-node falls back to buildSamplerFromEnv()\n// and the REAL head-sampling knob is the standard OTEL_TRACES_SAMPLER /\n// OTEL_TRACES_SAMPLER_ARG env (set per-deploy in the k8s chart). That head\n// decision gates spans before any processor runs, so it bounds BOTH the\n// SentrySpanProcessor (→ Sentry transactions) and the Tempo OTLP export.\n// (Wiring @sentry/opentelemetry's SentrySampler here would let this rate\n// drive both backends and make DSC/sample_rand propagation spec-correct, but\n// that only matters for Sentry server-side dynamic sampling — a SaaS feature\n// our self-hosted install doesn't run — so it's intentionally deferred.)\nconst SENTRY_TRACES_SAMPLE_RATE = parseFloat(\n process.env.SENTRY_TRACES_SAMPLE_RATE ?? \"0.1\",\n);\n\nif (SENTRY_DSN) {\n logger.info(\"Initialized Sentry with env: @env\", process.env.SENTRY_ENV);\n Sentry.init({\n dsn: SENTRY_DSN,\n environment: process.env.SENTRY_ENV,\n // Match the version tag uploaded by release-branch.yml so source maps\n // resolve. Populated by the CI (WORKSPACE_VERSION) or npm at runtime.\n release:\n process.env.SENTRY_RELEASE ||\n (process.env.npm_package_version\n ? `v${process.env.npm_package_version}`\n : undefined),\n // 0 in errors-only mode so even @sentry/node's bundled auto-OTel path\n // (used when TRACING_ENABLED is false) produces no transactions.\n tracesSampleRate: SENTRY_TRACING_TO_SENTRY ? SENTRY_TRACES_SAMPLE_RATE : 0,\n // When tracing is on, our NodeSDK below owns the OTel globals and Sentry\n // receives spans via SentrySpanProcessor. Skipping Sentry's bundled OTel\n // setup avoids two TracerProviders fighting over setGlobalTracerProvider.\n // When tracing is off, leave the flag unset so @sentry/node's default\n // auto-OTel still records HTTP transactions for the APM dashboard.\n skipOpenTelemetrySetup: TRACING_ENABLED,\n });\n}\n\nconst meterProvider: MeterProvider | undefined = createMeterProviderFromEnv({\n OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,\n OTEL_METRIC_EXPORT_INTERVAL: process.env.OTEL_METRIC_EXPORT_INTERVAL,\n OTEL_SERVICE_NAME: process.env.OTEL_SERVICE_NAME,\n});\nlet eventLoopInstrumentation: EventLoopInstrumentation | undefined;\nif (meterProvider) {\n // One-way door: must register before any code calls metrics.getMeter() —\n // most notably ReactorInstrumentation inside the reactor module.\n metrics.setGlobalMeterProvider(meterProvider);\n // Host event-loop and CPU gauges (reactor.host.eventloop.*,\n // reactor.host.cpu.utilization). The bench sweep identified the host event\n // loop, not the executor, as the binding constraint on write throughput\n // (bench/test/integration/BASELINE.md, Run 10) — these are the production\n // counterpart of the metrics that finding was made from. Must come after\n // setGlobalMeterProvider or the gauges bind to a no-op meter.\n eventLoopInstrumentation = registerEventLoopInstrumentation();\n}\n\nlet sdk: NodeSDK | undefined;\n\nif (TRACING_ENABLED) {\n logger.info(`Initializing OpenTelemetry tracing for ${SERVICE_NAME}`);\n if (TEMPO_ENDPOINT) logger.info(` Tempo endpoint: ${TEMPO_ENDPOINT}`);\n if (SENTRY_DSN) logger.info(` Sentry span forwarding: enabled`);\n logger.info(` Tenant: ${TENANT_ID}`);\n\n const resource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: SERVICE_NAME,\n [ATTR_SERVICE_VERSION]: SERVICE_VERSION,\n \"tenant.id\": TENANT_ID,\n \"deployment.environment\": DEPLOY_ENV,\n });\n\n const spanProcessors: SpanProcessor[] = [];\n if (TEMPO_ENDPOINT) {\n spanProcessors.push(\n new BatchSpanProcessor(new OTLPTraceExporter({ url: TEMPO_ENDPOINT })),\n );\n }\n if (SENTRY_TRACING_TO_SENTRY) {\n // Fan the same OTel spans into Sentry — same trace IDs as Tempo, so\n // Sentry transactions cross-link to traces in Grafana. Skipped in\n // errors-only mode (SENTRY_TRACING_ENABLED=false): spans still flow to\n // Tempo via the BatchSpanProcessor above, just not to Sentry.\n spanProcessors.push(new SentrySpanProcessor());\n }\n\n sdk = new NodeSDK({\n resource,\n spanProcessors,\n textMapPropagator: SENTRY_TRACING_TO_SENTRY\n ? new SentryPropagator()\n : undefined,\n instrumentations: [\n new HttpInstrumentation({\n ignoreIncomingRequestHook: (req) =>\n req.url === \"/health\" || req.url === \"/ready\",\n requireParentforIncomingSpans: false,\n requireParentforOutgoingSpans: false,\n requestHook: (span, request) => {\n span.setAttribute(\n \"http.route\",\n redactWebhookPath((request as IncomingMessage).url),\n );\n },\n responseHook: (span, response) => {\n if (response.statusCode) {\n span.setAttribute(\"http.status_code\", response.statusCode);\n }\n },\n }),\n new ExpressInstrumentation({\n requestHook: (span, info) => {\n if (info.route) span.setAttribute(\"http.route\", info.route);\n },\n }),\n // HttpInstrumentation only patches node:http/https; outbound global\n // fetch() goes through undici and is otherwise untraced — e.g. the\n // per-request Renown credential check in AuthService.verifyCredentialExists.\n new UndiciInstrumentation(),\n new GraphQLInstrumentation({ mergeItems: true, allowValues: true }),\n // requireParentSpan: only trace DB queries that run inside a request\n // (HTTP/GraphQL) span. Parentless queries — the management\n // switchboard's background polling loops (vetra-cloud-observability\n // reconcile @60s + clint pull-worker @15s, each writing\n // environment_pods / clint_runtime_endpoints) — would otherwise each\n // become a standalone root transaction. That volume scales O(tenant\n // count) and was ~70% of all Sentry transactions before this change.\n // Dropping it at the instrumentation layer (no span created at all) is\n // cheaper and cleaner than sampling it away downstream.\n new PgInstrumentation({\n enhancedDatabaseReporting: true,\n requireParentSpan: true,\n }),\n ],\n });\n sdk.start();\n if (\n SENTRY_TRACING_TO_SENTRY &&\n typeof Sentry.validateOpenTelemetrySetup === \"function\"\n ) {\n Sentry.validateOpenTelemetrySetup();\n }\n logger.info(\"OpenTelemetry tracing initialized\");\n}\n\nasync function shutdown() {\n // Detach the observable callback before the provider drains, so the final\n // export does not fire a gauge callback against a shutting-down meter.\n eventLoopInstrumentation?.stop();\n await Promise.race([\n Promise.all([\n meterProvider?.shutdown().catch(() => undefined),\n sdk?.shutdown().catch(() => undefined),\n ]),\n new Promise<void>((resolve) => setTimeout(resolve, 5_000)),\n ]);\n}\n\nprocess.on(\"SIGINT\", () => {\n void shutdown().finally(() => process.exit(0));\n});\nprocess.on(\"SIGTERM\", () => {\n void shutdown().finally(() => process.exit(0));\n});\n\nexport { meterProvider, sdk };\n","import dotenv from \"dotenv\";\ndotenv.config();\n\nimport { getConfig } from \"@powerhousedao/config/node\";\nimport { parseForcePgVersion } from \"./pglite-version.js\";\nimport type {\n SwitchboardDriveDocumentType,\n SwitchboardDriveInput,\n} from \"./types.js\";\nconst phConfig = getConfig();\nconst { switchboard } = phConfig;\ninterface Config {\n database: {\n url: string;\n };\n port: number;\n mcp: boolean;\n migratePglite: boolean;\n forcePgVersion: 16 | 17 | null;\n drive: SwitchboardDriveInput;\n}\n\nfunction parseDriveType(\n raw: string | undefined,\n): SwitchboardDriveDocumentType | undefined {\n if (!raw) return undefined;\n if (raw === \"powerhouse/document-drive\" || raw === \"powerhouse/reactor-drive\")\n return raw;\n throw new Error(\n `Invalid PH_DEFAULT_DRIVE_TYPE: ${raw}. Expected \"powerhouse/document-drive\" or \"powerhouse/reactor-drive\".`,\n );\n}\n\nexport const config: Config = {\n database: {\n // url: process.env.PH_SWITCHBOARD_DATABASE_URL ?? switchboard?.database?.url ?? \"dev.db\",\n url:\n process.env.PH_SWITCHBOARD_DATABASE_URL ??\n switchboard?.database?.url ??\n \"dev.db\",\n },\n port:\n process.env.PH_SWITCHBOARD_PORT &&\n !isNaN(Number(process.env.PH_SWITCHBOARD_PORT))\n ? Number(process.env.PH_SWITCHBOARD_PORT)\n : (switchboard?.port ?? 4001),\n mcp: true,\n migratePglite: process.env.PH_MIGRATE_PGLITE === \"true\",\n forcePgVersion: parseForcePgVersion(process.env.PH_FORCE_PG_VERSION),\n drive: {\n id: \"powerhouse\",\n slug: \"powerhouse\",\n documentType: parseDriveType(process.env.PH_DEFAULT_DRIVE_TYPE),\n global: {\n name: \"Powerhouse\",\n icon: \"https://ipfs.io/ipfs/QmcaTDBYn8X2psGaXe7iQ6qd8q6oqHLgxvMX9yXf7f9uP7\",\n },\n local: {\n availableOffline: true,\n listeners: [],\n sharingType: \"public\",\n triggers: [],\n },\n },\n};\n","import type { PyroscopeConfig } from \"@pyroscope/nodejs\";\n\nexport async function initProfilerFromEnv(env: typeof process.env) {\n const {\n PYROSCOPE_SERVER_ADDRESS: serverAddress,\n PYROSCOPE_APPLICATION_NAME: appName,\n PYROSCOPE_USER: basicAuthUser,\n PYROSCOPE_PASSWORD: basicAuthPassword,\n PYROSCOPE_WALL_ENABLED: wallEnabled,\n PYROSCOPE_HEAP_ENABLED: heapEnabled,\n } = env;\n\n const options: PyroscopeConfig = {\n serverAddress,\n appName,\n basicAuthUser,\n basicAuthPassword,\n // Wall profiling captures wall-clock time (includes async I/O waits)\n // This shows GraphQL resolvers even when waiting for database\n wall: {\n samplingDurationMs: 10000, // 10 second sampling windows\n samplingIntervalMicros: 10000, // 10ms sampling interval (100 samples/sec)\n collectCpuTime: true, // Also collect CPU time alongside wall time\n },\n // Heap profiling for memory allocation tracking\n heap: {\n samplingIntervalBytes: 512 * 1024, // Sample every 512KB allocated\n stackDepth: 64, // Capture deeper stacks for better context\n },\n };\n return initProfiler(options, {\n wallEnabled: wallEnabled !== \"false\",\n heapEnabled: heapEnabled === \"true\",\n });\n}\n\ninterface ProfilerFlags {\n wallEnabled?: boolean;\n heapEnabled?: boolean;\n}\n\nexport async function initProfiler(\n options?: PyroscopeConfig,\n flags: ProfilerFlags = { wallEnabled: true, heapEnabled: false },\n) {\n console.log(\"Initializing Pyroscope profiler at:\", options?.serverAddress);\n console.log(\" Wall profiling:\", flags.wallEnabled ? \"enabled\" : \"disabled\");\n console.log(\" Heap profiling:\", flags.heapEnabled ? \"enabled\" : \"disabled\");\n\n const { default: Pyroscope } = await import(\"@pyroscope/nodejs\");\n Pyroscope.init(options);\n\n // Start wall profiling (captures async I/O time - shows resolvers)\n if (flags.wallEnabled) {\n Pyroscope.startWallProfiling();\n }\n\n // Start CPU profiling (captures CPU-bound work)\n Pyroscope.startCpuProfiling();\n\n // Optionally start heap profiling (memory allocations)\n if (flags.heapEnabled) {\n Pyroscope.startHeapProfiling();\n }\n}\n","#!/usr/bin/env node\n// Observability MUST load before any module that imports http/express/pg/graphql\n// so OpenTelemetry's require-time hooks can patch them. It also owns Sentry\n// init and the SIGINT/SIGTERM flush.\nimport \"./observability.mjs\";\n\nimport * as Sentry from \"@sentry/node\";\nimport { childLogger } from \"document-model\";\nimport { config } from \"./config.js\";\nimport { initProfilerFromEnv } from \"./profiler.js\";\nimport { startSwitchboard } from \"./server.mjs\";\nimport { StoredDocumentsRefusedError } from \"./unsupported-stored-documents.mjs\";\n\nconst logger = childLogger([\"switchboard\"]);\n\nfunction ensureNodeVersion(minVersion = \"24\") {\n const version = process.versions.node;\n if (!version) {\n return;\n }\n\n if (version < minVersion) {\n console.error(\n `Node version ${minVersion} or higher is required. Current version: ${version}`,\n );\n process.exit(1);\n }\n}\n// Ensure minimum Node.js version\nensureNodeVersion(\"24\");\n\n// Each subgraph registers its own SIGINT/SIGTERM listeners, and the count\n// scales with dynamically-loaded document models beyond the default cap of 10.\nprocess.setMaxListeners(0);\n\nif (process.env.PYROSCOPE_SERVER_ADDRESS) {\n try {\n await initProfilerFromEnv(process.env);\n } catch (e) {\n Sentry.captureException(e);\n logger.error(\"Error starting profiler: @error\", e);\n }\n}\n\nconst cliMigratePglite = process.argv.slice(2).includes(\"--migrate-pglite\");\n\n// A boot failure leaves no serving process behind, so it must exit non-zero:\n// logging and continuing hides a dead switchboard from the supervisor, which\n// would otherwise never restart it.\ntry {\n await startSwitchboard({\n ...config,\n migratePglite: cliMigratePglite || config.migratePglite,\n forcePgVersion: config.forcePgVersion ?? undefined,\n fatalErrorShutdown: true,\n });\n} catch (e) {\n // startSwitchboard already reported a refusal to the operator.\n if (!StoredDocumentsRefusedError.isError(e)) {\n Sentry.captureException(e);\n logger.error(\"Switchboard failed to start: @error\", e);\n }\n await Sentry.flush(2000).catch(() => undefined);\n process.exit(1);\n}\n"],"names":["logger","logger"],"mappings":"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","debug_id":"691165f8-a729-5658-bf22-e7b2440e6156"}
@@ -1,5 +1,5 @@
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]="ec5120f6-92b3-53b4-bab1-94d88478784d")}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]="66287c02-682f-57d0-8260-a1005691b2bc")}catch(e){}}();
3
3
  import { n as addDefaultReactorDrive, r as isPostgresUrl, t as addDefaultDrive } from "./utils-uaBlzyjS.mjs";
4
4
  import { createRequire, register } from "node:module";
5
5
  import { ReactorInstrumentation } from "@powerhousedao/opentelemetry-instrumentation-reactor";
@@ -10,7 +10,7 @@ import { getConfig } from "@powerhousedao/config/node";
10
10
  import { existsSync, promises, realpathSync } from "node:fs";
11
11
  import path, { resolve } from "node:path";
12
12
  import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
13
- import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
13
+ import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, UnsupportedStoredProtocolError, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
14
14
  import { AuthorizationPolicy, BaseSubgraph, ForbiddenError, HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, callerSubject, createCanonicalDocumentIdResolver, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
15
15
  import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
16
16
  import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget, parseRef } from "@powerhousedao/reactor-attachments";
@@ -1112,6 +1112,18 @@ async function initFeatureFlags() {
1112
1112
  return OpenFeature.getClient();
1113
1113
  }
1114
1114
  //#endregion
1115
+ //#region src/mcp-flag.mts
1116
+ /** The env var and OpenFeature flag key that can keep the MCP server unmounted. */
1117
+ const MCP_ENABLED = "MCP_ENABLED";
1118
+ /** Whether to mount the MCP server at `/mcp`. On unless the host passes
1119
+ * `mcp: false` or MCP_ENABLED is "false": the env var can turn a host's MCP
1120
+ * off, never on, so a deployment that serves no MCP client can close the
1121
+ * endpoint without the bin growing a flag of its own. */
1122
+ async function resolveMcpEnabled({ featureFlags, option }) {
1123
+ if (option === false) return false;
1124
+ return featureFlags.getBooleanValue(MCP_ENABLED, true);
1125
+ }
1126
+ //#endregion
1115
1127
  //#region src/workflow/oauth-callback.ts
1116
1128
  const OAUTH_CALLBACK_PATH = "oauth/callback";
1117
1129
  function escapeHtml(text) {
@@ -2279,6 +2291,32 @@ function ownIdentity(renown) {
2279
2291
  };
2280
2292
  }
2281
2293
  //#endregion
2294
+ //#region src/unsupported-stored-documents.mts
2295
+ const MODES = ["refuse", "read-only"];
2296
+ /** From REACTOR_UNSUPPORTED_STORED_DOCUMENTS; `input` wins when defined. */
2297
+ function resolveUnsupportedStoredDocuments(input, env) {
2298
+ if (input !== void 0) return input;
2299
+ const raw = env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS?.trim();
2300
+ if (!raw) return "refuse";
2301
+ if (!MODES.includes(raw)) throw new Error(`REACTOR_UNSUPPORTED_STORED_DOCUMENTS must be "refuse" or "read-only", got "${raw}"`);
2302
+ return raw;
2303
+ }
2304
+ /** The reactor refused to start over documents this build does not run. */
2305
+ var StoredDocumentsRefusedError = class extends Error {
2306
+ versions;
2307
+ documents;
2308
+ constructor(cause) {
2309
+ const named = cause.versions.map(({ protocol, version }) => `${protocol} ${version}`).join(", ");
2310
+ super(`Refusing to start: ${cause.documents} stored document(s) require ${named}, which this switchboard does not run. Either start a switchboard build that runs ${named}, or set REACTOR_UNSUPPORTED_STORED_DOCUMENTS=read-only to start with those documents read-only.`, { cause });
2311
+ this.name = "StoredDocumentsRefusedError";
2312
+ this.versions = cause.versions;
2313
+ this.documents = cause.documents;
2314
+ }
2315
+ static isError(error) {
2316
+ return Error.isError(error) && error.name === "StoredDocumentsRefusedError";
2317
+ }
2318
+ };
2319
+ //#endregion
2282
2320
  //#region src/server.mts
2283
2321
  const defaultLogger = childLogger(["switchboard"]);
2284
2322
  const LogLevel = process.env.LOG_LEVEL || "info";
@@ -2446,6 +2484,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
2446
2484
  });
2447
2485
  if (!reactorDbUrl || !isPostgresUrl(reactorDbUrl)) throw new Error("The executor worker pool (REACTOR_WORKERS) requires a Postgres reactor database — set PH_REACTOR_DATABASE_URL or PH_SWITCHBOARD_DATABASE_URL. PGlite cannot be shared across worker threads.");
2448
2486
  }
2487
+ const unsupportedStoredDocuments = resolveUnsupportedStoredDocuments(options.unsupportedStoredDocuments, process.env);
2449
2488
  let projectionWorker = resolveProjectionWorkerOptions(options.projectionWorker, process.env);
2450
2489
  if (projectionWorker && options.reactor) {
2451
2490
  logger.warn("Projection worker configuration ignored: the caller-provided reactor owns its own read-model coordinator");
@@ -2512,7 +2551,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
2512
2551
  if (hasSkipThreshold) logger.info(`Reactor maxSkipThreshold set to ${maxSkipThreshold}`);
2513
2552
  const { flags: reactorFeatureFlags, enabled: enabledFeatureFlags } = resolveReactorFeatureFlags(process.env);
2514
2553
  if (enabledFeatureFlags.length > 0) logger.info(`Reactor feature flags enabled: ${enabledFeatureFlags.join(", ")}`);
2515
- const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" });
2554
+ const reactorBuilder = new ReactorBuilder().withEventBus(new EventBus()).withKysely(baseKysely).withFeatures({ legacyProcessorIds: process.env.REACTOR_LEGACY_PROCESSOR_IDS !== "false" }).withUnsupportedStoredDocuments(unsupportedStoredDocuments);
2516
2555
  if (poolInstrumentation) reactorBuilder.withInstrumentedPool(poolInstrumentation);
2517
2556
  const clientBuilder = new ReactorClientBuilder().withReactorBuilder(reactorBuilder);
2518
2557
  const vetraDocumentModels = dev ? Object.values(await import("@powerhousedao/vetra/document-models")).filter((m) => typeof m === "object" && m !== null && "documentModel" in m && "reducer" in m) : [];
@@ -2581,7 +2620,18 @@ async function initServer(serverPort, options, renown, renownConfig) {
2581
2620
  reactorBuilder.withShutdownHook(async () => {
2582
2621
  if (apiRef.current) await apiRef.current.dispose();
2583
2622
  });
2584
- const module = await clientBuilder.buildModule();
2623
+ let module;
2624
+ try {
2625
+ module = await clientBuilder.buildModule();
2626
+ } catch (error) {
2627
+ try {
2628
+ await baseKysely.destroy();
2629
+ } catch (destroyError) {
2630
+ logger.error("Aborting boot: reactor database destroy failed: @error", destroyError);
2631
+ }
2632
+ if (UnsupportedStoredProtocolError.isError(error)) throw new StoredDocumentsRefusedError(error);
2633
+ throw error;
2634
+ }
2585
2635
  if (module.reactorModule) {
2586
2636
  new ReactorInstrumentation(module.reactorModule).start();
2587
2637
  reactorLogger.info("Reactor metrics instrumentation started");
@@ -2627,7 +2677,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
2627
2677
  if (dev) {
2628
2678
  const { VitePackageLoader, createViteLogger, startViteServer } = await import("@powerhousedao/reactor-api/vite");
2629
2679
  vite = await startViteServer(process.cwd(), createViteLogger(logger));
2630
- viteLoader = VitePackageLoader.build(vite);
2680
+ viteLoader = VitePackageLoader.build(vite, { resolveFrom: [import.meta.url] });
2631
2681
  }
2632
2682
  const vetraProcessorFactory = dev ? (await import("@powerhousedao/vetra/processors")).processorFactory : void 0;
2633
2683
  if (!options.disableLocalPackages) packages.push(basePath);
@@ -2814,6 +2864,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
2814
2864
  attachmentReferenceProjection: api.attachmentReferenceProjection,
2815
2865
  workflowTriggers: workflows?.triggers,
2816
2866
  workflowsEnabled,
2867
+ mcpEnabled: options.mcp !== false,
2817
2868
  renown,
2818
2869
  port: serverPort,
2819
2870
  shutdown
@@ -2843,6 +2894,10 @@ const startSwitchboard = async (options = {}) => {
2843
2894
  const featureFlags = await initFeatureFlags();
2844
2895
  const enableDocumentModelSubgraphs = await featureFlags.getBooleanValue(DOCUMENT_MODEL_SUBGRAPHS_ENABLED, options.enableDocumentModelSubgraphs ?? DOCUMENT_MODEL_SUBGRAPHS_ENABLED_DEFAULT);
2845
2896
  options.enableDocumentModelSubgraphs = enableDocumentModelSubgraphs;
2897
+ options.mcp = await resolveMcpEnabled({
2898
+ featureFlags,
2899
+ option: options.mcp
2900
+ });
2846
2901
  const configPathForFlags = resolveConfigPath(options.configFile);
2847
2902
  const workflowsEnabled = await resolveWorkflowsEnabled({
2848
2903
  featureFlags,
@@ -2857,6 +2912,7 @@ const startSwitchboard = async (options = {}) => {
2857
2912
  };
2858
2913
  logger.info("Feature flags: @flags", JSON.stringify({
2859
2914
  DOCUMENT_MODEL_SUBGRAPHS_ENABLED: enableDocumentModelSubgraphs,
2915
+ MCP_ENABLED: options.mcp,
2860
2916
  PH_WORKFLOWS_ENABLED: workflowsEnabled
2861
2917
  }, null, 2));
2862
2918
  let renown = null;
@@ -2870,13 +2926,14 @@ const startSwitchboard = async (options = {}) => {
2870
2926
  return await initServer(serverPort, options, renown, renownConfig);
2871
2927
  } catch (e) {
2872
2928
  Sentry.captureException(e);
2873
- logger.error("App crashed: @error", e);
2929
+ if (StoredDocumentsRefusedError.isError(e)) logger.error(e.message);
2930
+ else logger.error("App crashed: @error", e);
2874
2931
  throw e;
2875
2932
  }
2876
2933
  };
2877
2934
  if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
2878
2935
  //#endregion
2879
- export { parseForcePgVersion as a, applySwitchboardReactorDefaults as i, isPortAvailable as n, startSwitchboard as r, deriveAttachmentServiceConfig as t };
2936
+ export { resolveUnsupportedStoredDocuments as a, StoredDocumentsRefusedError as i, isPortAvailable as n, applySwitchboardReactorDefaults as o, startSwitchboard as r, parseForcePgVersion as s, deriveAttachmentServiceConfig as t };
2880
2937
 
2881
- //# sourceMappingURL=server-CKKn0aXV.mjs.map
2882
- //# debugId=ec5120f6-92b3-53b4-bab1-94d88478784d
2938
+ //# sourceMappingURL=server-sQoWp21G.mjs.map
2939
+ //# debugId=66287c02-682f-57d0-8260-a1005691b2bc