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

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,43 @@
1
+ ## 6.2.3-dev.35 (2026-10-01)
2
+
3
+ ### 🚀 Features
4
+
5
+ - ⚠️ **registry:** serve every replica from Postgres and S3, processing publishes in a worker ([0d25d5fe35](https://github.com/powerhouse-inc/powerhouse/commit/0d25d5fe35))
6
+ - **switchboard:** configure the erasure purge timeout ([354375ea61](https://github.com/powerhouse-inc/powerhouse/commit/354375ea61))
7
+ - **switchboard:** run the privacy add-on behind PH_PRIVACY_ENABLED ([38c9a43856](https://github.com/powerhouse-inc/powerhouse/commit/38c9a43856))
8
+ - **reactor-privacy:** erase requested documents on a serial schedule ([f4f31289a1](https://github.com/powerhouse-inc/powerhouse/commit/f4f31289a1))
9
+ - **reactor-privacy:** serve disclosure and erasure to supreme admins only ([7a6b5232cb](https://github.com/powerhouse-inc/powerhouse/commit/7a6b5232cb))
10
+ - **reactor-privacy:** scaffold the package and its migration ledger ([152ecff77b](https://github.com/powerhouse-inc/powerhouse/commit/152ecff77b))
11
+ - **reactor-drive:** erase a purged document's nodes in NodeProcessor ([5a608e73bf](https://github.com/powerhouse-inc/powerhouse/commit/5a608e73bf))
12
+ - **reactor-attachments:** delete a purged document's attachment references ([4121c83347](https://github.com/powerhouse-inc/powerhouse/commit/4121c83347))
13
+
14
+ ### 🩹 Fixes
15
+
16
+ - **switchboard:** let workflow steps read attachments as they read documents ([b8c861360b](https://github.com/powerhouse-inc/powerhouse/commit/b8c861360b))
17
+ - **switchboard:** shut down when a PGlite store cannot flush ([42ea6fd893](https://github.com/powerhouse-inc/powerhouse/commit/42ea6fd893))
18
+ - **reactor:** a drive's purge does not require a live former member ([e8266b1035](https://github.com/powerhouse-inc/powerhouse/commit/e8266b1035))
19
+ - **reactor-privacy:** warn and record lastError while a leaked purge blocks dispatch ([cd6fe4f022](https://github.com/powerhouse-inc/powerhouse/commit/cd6fe4f022))
20
+ - **reactor-privacy:** reopen every failed request with no failed item each tick ([d9c19c3837](https://github.com/powerhouse-inc/powerhouse/commit/d9c19c3837))
21
+ - **reactor-api:** ignore polled refusals of an unknown kind ([aac4a3e752](https://github.com/powerhouse-inc/powerhouse/commit/aac4a3e752))
22
+ - **reactor-api:** fail a poll with a recoverable code when a refusal is not recorded ([3ab1c92f7b](https://github.com/powerhouse-inc/powerhouse/commit/3ab1c92f7b))
23
+ - **reactor:** keep a polled marker refusal only when the marker was owed ([07cae595da](https://github.com/powerhouse-inc/powerhouse/commit/07cae595da))
24
+ - **reactor:** persist a purge refusal only from a MARKER_REFUSED dead letter ([ef2c3f4729](https://github.com/powerhouse-inc/powerhouse/commit/ef2c3f4729))
25
+
26
+ ### 🔥 Performance
27
+
28
+ - **reactor-privacy:** dispatch the next purge without rescanning the backlog ([5de3703ebf](https://github.com/powerhouse-inc/powerhouse/commit/5de3703ebf))
29
+
30
+ ### ⚠️ Breaking Changes
31
+
32
+ - **registry:** serve every replica from Postgres and S3, processing publishes in a worker ([0d25d5fe35](https://github.com/powerhouse-inc/powerhouse/commit/0d25d5fe35))
33
+ /packages and /pieces return pages ({items,total,limit,offset,hasMore}) instead of full arrays, and piece bundles move to /-/pieces/bundled/<name>/<version>.tgz.
34
+
35
+ ### ❤️ Thank You
36
+
37
+ - acaldas
38
+ - Benjamin Jordan
39
+ - Claude Opus 5.5
40
+
1
41
  ## 6.2.3-dev.34 (2026-09-30)
2
42
 
3
43
  ### 🚀 Features
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
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";
4
+ import { i as StoredDocumentsRefusedError, r as startSwitchboard, s as parseForcePgVersion } from "./server-CXAp5TOI.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";
@@ -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]="66287c02-682f-57d0-8260-a1005691b2bc")}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]="bf12fafa-a066-5f83-98b1-3dddd60afc6a")}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";
@@ -11,7 +11,7 @@ import { existsSync, promises, realpathSync } from "node:fs";
11
11
  import path, { resolve } from "node:path";
12
12
  import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
13
13
  import { ChannelScheme, DriveCollectionId, EventBus, REACTOR_SCHEMA, ReactorBuilder, ReactorClientBuilder, UnsupportedStoredProtocolError, createHybridProjectionCoordinatorFactory, instrumentPgPool, parseDriveUrl, supportsLiveReadModelRegistration } from "@powerhousedao/reactor";
14
- import { AuthorizationPolicy, BaseSubgraph, ForbiddenError, HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, callerSubject, createCanonicalDocumentIdResolver, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
14
+ import { AuthorizationPolicy, BaseSubgraph, ForbiddenError, HttpPackageLoader, ImportPackageLoader, PGLITE_UTC_PARSERS, PackageManagementService, PackagesSubgraph, PrivacyPermissionAdapter, callerSubject, createCanonicalDocumentIdResolver, getUniqueDocumentModels, initializeAndStartAPI, resolveRenownConfig } from "@powerhousedao/reactor-api";
15
15
  import { httpsHooksPath } from "@powerhousedao/reactor-api/https-hooks";
16
16
  import { AttachmentAlreadyExists, AttachmentNotFound, AttachmentPending, AttachmentReferenceReadModel, AttachmentSchemaCompiler, HashMismatch, InvalidAttachmentRef, ReservationNotFound, SizeMismatch, UploadTooLarge, createRef, createRemoteAttachmentService, parseAttachmentDownloadTarget, parseRef } from "@powerhousedao/reactor-attachments";
17
17
  import { createAttachmentClient } from "@powerhousedao/reactor-attachments/client";
@@ -23,6 +23,7 @@ import path$1 from "path";
23
23
  import { Pool } from "pg";
24
24
  import { Readable } from "node:stream";
25
25
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
26
+ import { DisclosureService, MIN_DEPLOYMENT_SECRET_BYTES, createModuleErasure, createPrivacySubgraph, registerSubjectDocumentsReadModel } from "@powerhousedao/reactor-privacy";
26
27
  import { ReactorGroupV1 } from "@powerhousedao/reactor-group";
27
28
  import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
28
29
  import { DEFAULT_SIGNATURE_POLICY, isSignaturePolicy } from "@powerhousedao/shared/document-model";
@@ -790,7 +791,7 @@ async function registerAttachmentReferenceReadModelOnModule(clientModule, attach
790
791
  //#endregion
791
792
  //#region src/fatal-shutdown.mts
792
793
  const FORCED_EXIT_MS = 15e3;
793
- const installed = /* @__PURE__ */ new WeakSet();
794
+ const fatalHandlers = /* @__PURE__ */ new WeakMap();
794
795
  /**
795
796
  * Sends an uncaught exception or unhandled rejection through the SIGTERM
796
797
  * shutdown that `withSignalHandlers()` installs, so the reactor and read-model
@@ -803,8 +804,7 @@ const installed = /* @__PURE__ */ new WeakSet();
803
804
  * listener deliberately keeps the process running.
804
805
  */
805
806
  function installFatalErrorShutdown(logger, proc = process) {
806
- if (installed.has(proc)) return;
807
- installed.add(proc);
807
+ if (fatalHandlers.has(proc)) return;
808
808
  const realExit = proc.exit.bind(proc);
809
809
  let shuttingDown = false;
810
810
  const onFatal = (kind, err) => {
@@ -818,12 +818,97 @@ function installFatalErrorShutdown(logger, proc = process) {
818
818
  }, FORCED_EXIT_MS).unref();
819
819
  proc.kill(proc.pid, "SIGTERM");
820
820
  };
821
+ fatalHandlers.set(proc, onFatal);
821
822
  proc.on("uncaughtException", (err) => onFatal("Uncaught exception", err));
822
823
  proc.on("unhandledRejection", (reason) => {
823
824
  if (proc.listenerCount("unhandledRejection") > 1) return;
824
825
  onFatal("Unhandled rejection", reason);
825
826
  });
826
827
  }
828
+ /**
829
+ * Routes a fatal condition the caller detected through the same shutdown as an
830
+ * uncaught error. Returns false, and does nothing, when
831
+ * `installFatalErrorShutdown` has not run for `proc`.
832
+ */
833
+ function triggerFatalShutdown(kind, err, proc = process) {
834
+ const onFatal = fatalHandlers.get(proc);
835
+ if (!onFatal) return false;
836
+ onFatal(kind, err);
837
+ return true;
838
+ }
839
+ //#endregion
840
+ //#region src/privacy.mts
841
+ const DAY_MS = 864e5;
842
+ /** Enabling privacy on a switchboard that cannot run it safely. */
843
+ var PrivacyBootError = class extends Error {
844
+ constructor(message) {
845
+ super(message);
846
+ this.name = "PrivacyBootError";
847
+ }
848
+ };
849
+ function positive(env, name, unitMs) {
850
+ const raw = env[name];
851
+ if (raw === void 0 || raw.trim() === "") return void 0;
852
+ const value = Number(raw);
853
+ if (!Number.isFinite(value) || value <= 0) throw new PrivacyBootError(`${name} must be a positive number, got "${raw}"`);
854
+ return value * unitMs;
855
+ }
856
+ function resolvePrivacy(options, env) {
857
+ if (!(options?.enabled ?? env.PH_PRIVACY_ENABLED === "true")) return { enabled: false };
858
+ const deploymentSecret = options?.deploymentSecret ?? env.PH_PRIVACY_DEPLOYMENT_SECRET;
859
+ if (!deploymentSecret || Buffer.byteLength(deploymentSecret, "utf8") < MIN_DEPLOYMENT_SECRET_BYTES) throw new PrivacyBootError(`PH_PRIVACY_ENABLED needs PH_PRIVACY_DEPLOYMENT_SECRET of at least ${MIN_DEPLOYMENT_SECRET_BYTES} bytes`);
860
+ return {
861
+ enabled: true,
862
+ deploymentSecret,
863
+ intervalMs: positive(env, "PH_PRIVACY_INTERVAL_MS", 1),
864
+ deadlineMs: positive(env, "PH_PRIVACY_DEADLINE_DAYS", DAY_MS),
865
+ markerGraceMs: positive(env, "PH_PRIVACY_MARKER_GRACE_DAYS", DAY_MS),
866
+ purgeTimeoutMs: positive(env, "PH_PRIVACY_PURGE_TIMEOUT_MINUTES", 6e4)
867
+ };
868
+ }
869
+ /** Subject index, erasure scheduler and admin subgraph on a built reactor. */
870
+ async function startPrivacy(deps) {
871
+ const { config, reactorModule, authorizationService } = deps;
872
+ if (authorizationService.config.policy === AuthorizationPolicy.OPEN) throw new PrivacyBootError("PH_PRIVACY_ENABLED is refused under the OPEN authorization policy: every caller, anonymous included, would administer erasure. Enable authentication (AUTH_ENABLED=true with ADMINS) first.");
873
+ if (!reactorModule) throw new PrivacyBootError("PH_PRIVACY_ENABLED needs an in-process reactor module");
874
+ if (!deps.signer?.app?.key) throw new PrivacyBootError("PH_PRIVACY_ENABLED needs a reactor signer (a Renown identity): every receiver refuses an unsigned purge marker");
875
+ const permissions = deps.documentPermissionService ? new PrivacyPermissionAdapter(deps.documentPermissionService) : void 0;
876
+ const { service, scheduler } = createModuleErasure(reactorModule, {
877
+ deploymentSecret: config.deploymentSecret,
878
+ signer: deps.signer,
879
+ permissions,
880
+ intervalMs: config.intervalMs,
881
+ deadlineMs: config.deadlineMs,
882
+ markerGraceMs: config.markerGraceMs,
883
+ purgeTimeoutMs: config.purgeTimeoutMs,
884
+ logger: deps.logger
885
+ });
886
+ const disclosure = new DisclosureService(reactorModule.database, config.deploymentSecret, permissions);
887
+ let subgraph;
888
+ try {
889
+ subgraph = createPrivacySubgraph({
890
+ authorizationService,
891
+ erasure: service,
892
+ disclosure
893
+ });
894
+ } catch (error) {
895
+ throw new PrivacyBootError(error instanceof Error ? error.message : String(error));
896
+ }
897
+ await registerSubjectDocumentsReadModel(reactorModule, { deploymentSecret: config.deploymentSecret });
898
+ await deps.graphqlManager.registerSubgraphInstance({
899
+ ...subgraph,
900
+ path: deps.graphqlManager.getBasePath(),
901
+ reactorClient: deps.reactorClient,
902
+ relationalDb: void 0
903
+ }, "graphql", false);
904
+ scheduler.start();
905
+ deps.logger.info("Privacy add-on started: subject index, erasure scheduler");
906
+ return {
907
+ erasure: service,
908
+ scheduler,
909
+ stop: () => scheduler.stop()
910
+ };
911
+ }
827
912
  //#endregion
828
913
  //#region src/builder-defaults.mts
829
914
  /**
@@ -1930,28 +2015,14 @@ function writeAssertion(authorizationService, reactorClient) {
1930
2015
  if (!await authorizationService.canWrite(documentId, ctx.user?.address)) throw new ForbiddenError("to write this document");
1931
2016
  };
1932
2017
  }
1933
- /** Whether the workflow document really references the attachment. A step
1934
- * carries no caller, so the relationship is the whole check. */
1935
- function attachmentRefCheck(deps) {
1936
- const resolveCanonical = createCanonicalDocumentIdResolver(deps.reactorClient);
1937
- const references = deps.attachmentReferences;
1938
- const projection = deps.attachmentReferenceProjection;
1939
- return async (documentId, ref) => {
1940
- if (!references || projection?.status !== "available") return false;
1941
- let parsed;
1942
- try {
1943
- parsed = parseRef(ref);
1944
- } catch {
1945
- return false;
1946
- }
1947
- if (parsed.version !== 1) return false;
1948
- const canonicalRef = createRef(parsed.hash.toLowerCase());
1949
- try {
1950
- return await references.hasReference(await resolveCanonical(documentId), canonicalRef);
1951
- } catch {
1952
- return false;
1953
- }
1954
- };
2018
+ /** A step reads any well-formed attachment, as it reads any document: a run
2019
+ * carries no caller to check against. */
2020
+ function canReadAttachmentRef(_documentId, ref) {
2021
+ try {
2022
+ return Promise.resolve(parseRef(ref).version === 1);
2023
+ } catch {
2024
+ return Promise.resolve(false);
2025
+ }
1955
2026
  }
1956
2027
  async function registerWorkflowTriggersReadModel(engine, runtime, clientModule) {
1957
2028
  const reactorModule = clientModule?.reactorModule;
@@ -1995,7 +2066,7 @@ async function composeWorkflowRuntime(deps) {
1995
2066
  assertCanWrite: writeAssertion(deps.authorizationService, deps.reactorClient),
1996
2067
  webhooks: deps.webhooks,
1997
2068
  attachments: deps.attachments,
1998
- canReadAttachmentRef: attachmentRefCheck(deps),
2069
+ canReadAttachmentRef,
1999
2070
  logger: deps.logger
2000
2071
  });
2001
2072
  const triggers = await registerWorkflowTriggersReadModel(engine, runtime, deps.clientModule);
@@ -2332,6 +2403,9 @@ const PGLITE_FLUSH_INTERVAL_MS = (() => {
2332
2403
  const parsed = Number(raw);
2333
2404
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : 100;
2334
2405
  })();
2406
+ function onPgliteFlushError(err) {
2407
+ triggerFatalShutdown("PGlite snapshot flush failed", err);
2408
+ }
2335
2409
  const PGLITE_IN_MEMORY = process.env.PH_PGLITE_IN_MEMORY === "1";
2336
2410
  /**
2337
2411
  * Attempt to bind a throwaway TCP server to the given port. Resolves true if
@@ -2396,7 +2470,8 @@ async function createReactorKysely(opts) {
2396
2470
  const { PGlite } = await loadPGliteModule(reactorPgliteMajor);
2397
2471
  const pglite = inMemory ? new PGlite() : new PGlite({ fs: new AtomicNodeFs(reactorPgliteDir, {
2398
2472
  logger,
2399
- flushIntervalMs
2473
+ flushIntervalMs,
2474
+ onFlushError: onPgliteFlushError
2400
2475
  }) });
2401
2476
  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}`);
2402
2477
  return {
@@ -2519,6 +2594,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
2519
2594
  process.kill(process.pid, "SIGTERM");
2520
2595
  };
2521
2596
  const workflowsEnabled = options.workflows?.enabled === true;
2597
+ const privacyConfig = resolvePrivacy(options.privacy, process.env);
2522
2598
  if (workflowsEnabled) {
2523
2599
  await assertWorkflowPackageLoadable();
2524
2600
  if (!packages.includes("@powerhousedao/workflow")) packages.push(WORKFLOW_PACKAGE_NAME);
@@ -2698,7 +2774,8 @@ async function initServer(serverPort, options, renown, renownConfig) {
2698
2774
  pgliteFactory = PGLITE_IN_MEMORY ? () => new ReadModelPGlite({ parsers: PGLITE_UTC_PARSERS }) : (connectionString) => new ReadModelPGlite({
2699
2775
  fs: new AtomicNodeFs(connectionString ?? readModelPgliteDir, {
2700
2776
  logger,
2701
- flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS
2777
+ flushIntervalMs: PGLITE_FLUSH_INTERVAL_MS,
2778
+ onFlushError: onPgliteFlushError
2702
2779
  }),
2703
2780
  parsers: PGLITE_UTC_PARSERS
2704
2781
  });
@@ -2743,8 +2820,6 @@ async function initServer(serverPort, options, renown, renownConfig) {
2743
2820
  relationalDb: api.relationalDb,
2744
2821
  secretsKeyFile: readModelPgliteDir === null ? false : void 0,
2745
2822
  attachments: createAttachmentClient(api.attachments.service),
2746
- attachmentReferences: api.attachmentReferenceIndex.store,
2747
- attachmentReferenceProjection: api.attachmentReferenceProjection,
2748
2823
  webhooks: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME).webhooks,
2749
2824
  http: api.httpRoutes.scopeFor(WORKFLOW_PACKAGE_NAME),
2750
2825
  authorizationService: api.authorizationService,
@@ -2769,7 +2844,28 @@ async function initServer(serverPort, options, renown, renownConfig) {
2769
2844
  await workflows.start();
2770
2845
  logger.info("Workflow runtime started");
2771
2846
  }
2847
+ let privacy;
2848
+ if (privacyConfig.enabled) {
2849
+ const signer = options.reactor ? options.privacy?.signer : options.privacy?.signer ?? renown?.signer;
2850
+ try {
2851
+ privacy = await startPrivacy({
2852
+ config: privacyConfig,
2853
+ reactorModule: (options.reactor ?? ownedReactorModule)?.reactorModule,
2854
+ signer,
2855
+ authorizationService: api.authorizationService,
2856
+ documentPermissionService: api.documentPermissionService,
2857
+ graphqlManager,
2858
+ reactorClient: client,
2859
+ logger: logger.child(["privacy"])
2860
+ });
2861
+ } catch (error) {
2862
+ await workflows?.stop();
2863
+ await abortBoot(api);
2864
+ throw error;
2865
+ }
2866
+ }
2772
2867
  const shutdown = async () => {
2868
+ await privacy?.stop();
2773
2869
  await workflows?.stop();
2774
2870
  await api.dispose();
2775
2871
  };
@@ -2864,6 +2960,7 @@ async function initServer(serverPort, options, renown, renownConfig) {
2864
2960
  attachmentReferenceProjection: api.attachmentReferenceProjection,
2865
2961
  workflowTriggers: workflows?.triggers,
2866
2962
  workflowsEnabled,
2963
+ privacy: privacy ? { erasure: privacy.erasure } : void 0,
2867
2964
  mcpEnabled: options.mcp !== false,
2868
2965
  renown,
2869
2966
  port: serverPort,
@@ -2935,5 +3032,5 @@ if (import.meta.main) await startSwitchboard({ fatalErrorShutdown: true });
2935
3032
  //#endregion
2936
3033
  export { resolveUnsupportedStoredDocuments as a, StoredDocumentsRefusedError as i, isPortAvailable as n, applySwitchboardReactorDefaults as o, startSwitchboard as r, parseForcePgVersion as s, deriveAttachmentServiceConfig as t };
2937
3034
 
2938
- //# sourceMappingURL=server-sQoWp21G.mjs.map
2939
- //# debugId=66287c02-682f-57d0-8260-a1005691b2bc
3035
+ //# sourceMappingURL=server-CXAp5TOI.mjs.map
3036
+ //# debugId=bf12fafa-a066-5f83-98b1-3dddd60afc6a