@powerhousedao/switchboard 6.2.3-dev.33 → 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/dist/server.d.mts CHANGED
@@ -3,11 +3,22 @@ import { ChannelScheme, IDocumentModelLoader, IReactorClient, InProcessReactorCl
3
3
  import { AttachmentReferenceProjectionCapability } from "@powerhousedao/reactor-api";
4
4
  import { IAttachmentService } from "@powerhousedao/reactor-attachments";
5
5
  import { IRenown } from "@renown/sdk/node";
6
+ import { IErasureService } from "@powerhousedao/reactor-privacy";
6
7
  import { DriveInput } from "@powerhousedao/shared/document-drive";
7
- import { DocumentModelModule, SignaturePolicy, UpgradeManifest } from "@powerhousedao/shared/document-model";
8
+ import { DocumentModelModule, ISigner, SignaturePolicy, UpgradeManifest } from "@powerhousedao/shared/document-model";
8
9
  import "@powerhousedao/reactor-workflow";
9
10
  import "@powerhousedao/shared/processors";
10
11
  import { IRenown as IRenown$1 } from "@renown/sdk";
12
+ //#region src/privacy.d.mts
13
+ type PrivacyOptions = {
14
+ /** Wins over PH_PRIVACY_ENABLED. */
15
+ enabled?: boolean;
16
+ /** Wins over PH_PRIVACY_DEPLOYMENT_SECRET. */
17
+ deploymentSecret?: string;
18
+ /** The reactor's signer, for a caller-provided reactor. */
19
+ signer?: ISigner;
20
+ };
21
+ //#endregion
11
22
  //#region src/workflow-runtime.d.mts
12
23
  /** Whether the operation intake is indexing. Same shape as the attachment
13
24
  * reference projection, because it is the same limitation. */
@@ -108,12 +119,16 @@ export type StartServerOptions = {
108
119
  identity?: IdentityOptions;
109
120
  /** Base URL for the attachment service; defaults to `PH_SWITCHBOARD_PUBLIC_URL` then `http(s)://localhost:${port}`. */
110
121
  attachmentServiceUrl?: string;
122
+ /** Mount the MCP server at `/mcp`. On by default; `false` wins over
123
+ * MCP_ENABLED, which can turn it off but never on. */
111
124
  mcp?: boolean;
112
125
  /** Powerhouse workflows: the runtime, its subgraph, its webhooks and its
113
126
  * document models. Wins over PH_WORKFLOWS_ENABLED and the config file. */
114
127
  workflows?: {
115
128
  enabled?: boolean;
116
129
  };
130
+ /** The GDPR add-on; off unless this or PH_PRIVACY_ENABLED turns it on. */
131
+ privacy?: PrivacyOptions;
117
132
  processorConfig?: Map<string, unknown>;
118
133
  disableLocalPackages?: boolean;
119
134
  enableDocumentModelSubgraphs?: boolean;
@@ -191,6 +206,12 @@ export type SwitchboardReactor = {
191
206
  workflowTriggers?: WorkflowTriggersCapability;
192
207
  /** Whether workflows are on, after the option, PH_WORKFLOWS_ENABLED and the config file. */
193
208
  workflowsEnabled: boolean;
209
+ /** Present when the privacy add-on runs: its erasure service. */
210
+ privacy?: {
211
+ erasure: IErasureService;
212
+ };
213
+ /** Whether `/mcp` is mounted, after the option and MCP_ENABLED. */
214
+ mcpEnabled: boolean;
194
215
  /** The Renown instance if identity was initialized */
195
216
  renown: IRenown$1 | null;
196
217
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.mts","names":[],"sources":["../src/workflow-runtime.mts","../src/types.ts","../src/builder-defaults.mts","../src/unsupported-stored-documents.mts","../src/server.mts"],"mappings":";;;;;;;;;;;;;KA2DY;EACN;;EAEA;EACA;;;;;;;;;;YC5CM;;YAKA,wBAAwB;EAClC,eAAe;;YAGL;EACV;EACA;EACA;;YAGU;;EAEV;;;;;EAKA;;EAGA;;YAGU;;;;;;;;;;;;;;;;;;;;;EAqBV,UAAU;;;;;;;;EAQV;;;;;;;EAOA;EACA;EACA;;;;;;EAMA;EACA;EACA;EACA,QAAQ;EACR;EACA;EACA;IAEM;IACA;;;;;;;EASN,WAAW;;EAEX;EACA;;;EAGA;IACE;;EAEF,kBAAkB;EAClB;EACA;;;;;;EAMA;EACA,SAAS;;;;;;;;;;EAUT;;;;;;;;;;EAUA;;;;;;;;;EASA;IACE;IACA;IACA;;;;;;;;;;;EAWF;IACE;IACA;;;;;;;;;EASF,6BAA6B;;YAGnB;EACV;EACA,SAAS;;EAET,mBAAmB;;EAEnB,+BAA+B;;;EAG/B,mBAAmB;;EAEnB;;EAEA,QAAQ;;;;;EAKR;;;;;;;;;;;;;EAaA,gBAAgB;;;;KClMN;EAIV,iBAAiB;;EAEjB,mBAAmB;;EAEnB;;;;;;EAMA,gBAAgB;;EAEhB;;EAEA;IACE;IACA,eAAe,QAAQ;;;EAGzB,sBAAsB;EACtB,SAAS;;;;;;EAMT,SAAS;;;;;EAKT,cAAc,KAAK;;EAEnB,wBAAwB;;wBAsBV,gCACd,gBAAgB,gBAChB,eAAe,sBACf,UAAS;;;;wBC3EK,kCACd,OAAO,wCACP,KAAK,OAAO,aACX;;qBAaU,oCAAoC;WACtC;IAAqB;IAAkB;;WACvC;EAET,YAAY,OAAO;SAcZ,QAAQ,iBAAiB,SAAS;;;;;;;;;wBC2G3B,gBAAgB,eAAe;;wBAoI/B,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBAi7BvB,mBAAgB,UAClB,uBACR,QAAQ"}
1
+ {"version":3,"file":"server.d.mts","names":[],"sources":["../src/privacy.mts","../src/workflow-runtime.mts","../src/types.ts","../src/builder-defaults.mts","../src/unsupported-stored-documents.mts","../src/server.mts"],"mappings":";;;;;;;;;;;;KAyBY;;EAEV;;EAEA;;EAEA,SAAS;;;;;;KCsBC;EACN;;EAEA;EACA;;;;;;;;;;YCpCM;;YAKA,wBAAwB;EAClC,eAAe;;YAGL;EACV;EACA;EACA;;YAGU;;EAEV;;;;;EAKA;;EAGA;;YAGU;;;;;;;;;;;;;;;;;;;;;EAqBV,UAAU;;;;;;;;EAQV;;;;;;;EAOA;EACA;EACA;;;;;;EAMA;EACA;EACA;EACA,QAAQ;EACR;EACA;EACA;IAEM;IACA;;;;;;;EASN,WAAW;;EAEX;;;EAGA;;;EAGA;IACE;;;EAGF,UAAU;EACV,kBAAkB;EAClB;EACA;;;;;;EAMA;EACA,SAAS;;;;;;;;;;EAUT;;;;;;;;;;EAUA;;;;;;;;;EASA;IACE;IACA;IACA;;;;;;;;;;;EAWF;IACE;IACA;;;;;;;;;EASF,6BAA6B;;YAGnB;EACV;EACA,SAAS;;EAET,mBAAmB;;EAEnB,+BAA+B;;;EAG/B,mBAAmB;;EAEnB;;EAEA;IAAY,SAAS;;;EAErB;;EAEA,QAAQ;;;;;EAKR;;;;;;;;;;;;;EAaA,gBAAgB;;;;KC5MN;EAIV,iBAAiB;;EAEjB,mBAAmB;;EAEnB;;;;;;EAMA,gBAAgB;;EAEhB;;EAEA;IACE;IACA,eAAe,QAAQ;;;EAGzB,sBAAsB;EACtB,SAAS;;;;;;EAMT,SAAS;;;;;EAKT,cAAc,KAAK;;EAEnB,wBAAwB;;wBAsBV,gCACd,gBAAgB,gBAChB,eAAe,sBACf,UAAS;;;;wBC3EK,kCACd,OAAO,wCACP,KAAK,OAAO,aACX;;qBAaU,oCAAoC;WACtC;IAAqB;IAAkB;;WACvC;EAET,YAAY,OAAO;SAcZ,QAAQ,iBAAiB,SAAS;;;;;;;;;wBCyH3B,gBAAgB,eAAe;;wBAwI/B,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBA88BvB,mBAAgB,UAClB,uBACR,QAAQ"}
package/dist/server.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as resolveUnsupportedStoredDocuments, i as StoredDocumentsRefusedError, n as isPortAvailable, o as applySwitchboardReactorDefaults, r as startSwitchboard, t as deriveAttachmentServiceConfig } from "./server-DAgJkmvH.mjs";
2
+ import { a as resolveUnsupportedStoredDocuments, i as StoredDocumentsRefusedError, n as isPortAvailable, o as applySwitchboardReactorDefaults, r as startSwitchboard, t as deriveAttachmentServiceConfig } from "./server-CXAp5TOI.mjs";
3
3
  export { StoredDocumentsRefusedError, applySwitchboardReactorDefaults, deriveAttachmentServiceConfig, isPortAvailable, resolveUnsupportedStoredDocuments, startSwitchboard };
4
- !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]="631cf56c-74d8-5495-a51e-94daa58b7193")}catch(e){}}();
5
- //# debugId=631cf56c-74d8-5495-a51e-94daa58b7193
4
+ !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]="91c41927-1b39-5750-b103-5886cde48ae6")}catch(e){}}();
5
+ //# debugId=91c41927-1b39-5750-b103-5886cde48ae6
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@powerhousedao/switchboard",
3
3
  "type": "module",
4
- "version": "6.2.3-dev.33",
4
+ "version": "6.2.3-dev.35",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
7
7
  ".": {
@@ -41,7 +41,7 @@
41
41
  "@openfeature/core": "1.9.1",
42
42
  "@openfeature/env-var-provider": "0.3.1",
43
43
  "@openfeature/server-sdk": "1.19.0",
44
- "@powerhousedao/config": "6.2.3-dev.33",
44
+ "@powerhousedao/config": "6.2.3-dev.35",
45
45
  "@opentelemetry/api": "^1.9.0",
46
46
  "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
47
47
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
@@ -55,22 +55,23 @@
55
55
  "@opentelemetry/sdk-node": "^0.218.0",
56
56
  "@opentelemetry/sdk-trace-base": "^2.7.1",
57
57
  "@opentelemetry/semantic-conventions": "^1.41.1",
58
- "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.33",
59
- "@powerhousedao/reactor": "6.2.3-dev.33",
60
- "@powerhousedao/shared": "6.2.3-dev.33",
61
- "@powerhousedao/vetra": "6.2.3-dev.33",
62
- "@powerhousedao/reactor-api": "6.2.3-dev.33",
63
- "@powerhousedao/reactor-attachments": "6.2.3-dev.33",
64
- "@powerhousedao/reactor-drive": "6.2.3-dev.33",
65
- "@powerhousedao/reactor-group": "6.2.3-dev.33",
66
- "@powerhousedao/reactor-workflow": "6.2.3-dev.33",
67
- "@powerhousedao/workflow": "6.2.3-dev.33",
68
- "@powerhousedao/pglite-fs": "6.2.3-dev.33",
58
+ "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.35",
59
+ "@powerhousedao/reactor": "6.2.3-dev.35",
60
+ "@powerhousedao/shared": "6.2.3-dev.35",
61
+ "@powerhousedao/vetra": "6.2.3-dev.35",
62
+ "@powerhousedao/reactor-api": "6.2.3-dev.35",
63
+ "@powerhousedao/reactor-attachments": "6.2.3-dev.35",
64
+ "@powerhousedao/reactor-drive": "6.2.3-dev.35",
65
+ "@powerhousedao/reactor-group": "6.2.3-dev.35",
66
+ "@powerhousedao/reactor-privacy": "6.2.3-dev.35",
67
+ "@powerhousedao/reactor-workflow": "6.2.3-dev.35",
68
+ "@powerhousedao/workflow": "6.2.3-dev.35",
69
+ "@powerhousedao/pglite-fs": "6.2.3-dev.35",
69
70
  "@pyroscope/nodejs": "^0.4.5",
70
- "@renown/sdk": "6.2.3-dev.33",
71
+ "@renown/sdk": "6.2.3-dev.35",
71
72
  "@sentry/node": "^10.53.1",
72
73
  "@sentry/opentelemetry": "^10.53.1",
73
- "document-model": "6.2.3-dev.33",
74
+ "document-model": "6.2.3-dev.35",
74
75
  "dotenv": "^16.4.7",
75
76
  "express": "^4.22.1",
76
77
  "graphql": "^16",
@@ -232,6 +232,8 @@ describe("Switchboard attachment-reference read model registration", () => {
232
232
  writtenOrdinals.push(...references.map(({ ordinal }) => ordinal));
233
233
  await attachmentReferenceIndex.store.addReferences(references);
234
234
  },
235
+ removeDocuments: (documentIds) =>
236
+ attachmentReferenceIndex.store.removeDocuments(documentIds),
235
237
  };
236
238
 
237
239
  const coordinator =
@@ -283,7 +285,10 @@ describe("Switchboard attachment-reference read model registration", () => {
283
285
  .withReadModelCoordinator(customCoordinator)
284
286
  .buildModule();
285
287
  const addReferences = vi.fn();
286
- const writer = { addReferences } as IAttachmentReferenceWriter;
288
+ const writer: IAttachmentReferenceWriter = {
289
+ addReferences,
290
+ removeDocuments: vi.fn(),
291
+ };
287
292
 
288
293
  await expect(
289
294
  registerAttachmentReferenceReadModelOnModule(
@@ -0,0 +1,121 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { createServer } from "node:net";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ILogger } from "document-model";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { startSwitchboard } from "../src/server.mjs";
8
+
9
+ // `port: 0` would bind a random port the switchboard does not report back, so
10
+ // the test picks a free one and pins it.
11
+ async function freePort(): Promise<number> {
12
+ const server = createServer();
13
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
14
+ const address = server.address();
15
+ await new Promise<void>((resolve) => server.close(() => resolve()));
16
+ if (!address || typeof address === "string") throw new Error("no port");
17
+ return address.port;
18
+ }
19
+
20
+ function stubLogger(): ILogger {
21
+ const logger = {
22
+ verbose: vi.fn(),
23
+ debug: vi.fn(),
24
+ info: vi.fn(),
25
+ warn: vi.fn(),
26
+ error: vi.fn(),
27
+ child: vi.fn(),
28
+ };
29
+ logger.child.mockReturnValue(logger);
30
+ return logger as unknown as ILogger;
31
+ }
32
+
33
+ async function withSwitchboard(
34
+ env: Record<string, string>,
35
+ mcp: boolean | undefined,
36
+ run: (
37
+ switchboard: Awaited<ReturnType<typeof startSwitchboard>>,
38
+ ) => Promise<void>,
39
+ ): Promise<void> {
40
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-mcp-enabled-"));
41
+ const names = ["PH_REACTOR_DATABASE_URL", "MCP_ENABLED", ...Object.keys(env)];
42
+ const previous = names.map((name) => [name, process.env[name]] as const);
43
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
44
+ delete process.env.MCP_ENABLED;
45
+ Object.assign(process.env, env);
46
+ let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
47
+
48
+ try {
49
+ switchboard = await startSwitchboard({
50
+ workflows: { enabled: false },
51
+ dbPath: join(tempRoot, "read-model"),
52
+ port: await freePort(),
53
+ strictPort: true,
54
+ mcp,
55
+ disableLocalPackages: true,
56
+ identity: { keypairPath: join(tempRoot, "identity.json") },
57
+ drive: {
58
+ slug: "default-drive",
59
+ global: { name: "Default Drive" },
60
+ },
61
+ logger: stubLogger(),
62
+ });
63
+ await run(switchboard);
64
+ await switchboard.shutdown();
65
+ switchboard = undefined;
66
+ } finally {
67
+ await switchboard?.shutdown();
68
+ for (const [name, value] of previous) {
69
+ if (value === undefined) {
70
+ delete process.env[name];
71
+ } else {
72
+ process.env[name] = value;
73
+ }
74
+ }
75
+ await rm(tempRoot, { recursive: true, force: true });
76
+ }
77
+ }
78
+
79
+ async function postMcp(port: number): Promise<number> {
80
+ const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
81
+ method: "POST",
82
+ headers: {
83
+ "content-type": "application/json",
84
+ accept: "application/json, text/event-stream",
85
+ },
86
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
87
+ });
88
+ await response.body?.cancel();
89
+ return response.status;
90
+ }
91
+
92
+ describe("booting Switchboard", () => {
93
+ it("mounts /mcp by default", async () => {
94
+ await withSwitchboard({}, undefined, async ({ mcpEnabled, port }) => {
95
+ expect(mcpEnabled).toBe(true);
96
+ expect(await postMcp(port)).not.toBe(404);
97
+ });
98
+ }, 60_000);
99
+
100
+ it('leaves /mcp unmounted under MCP_ENABLED="false"', async () => {
101
+ await withSwitchboard(
102
+ { MCP_ENABLED: "false" },
103
+ undefined,
104
+ async ({ mcpEnabled, port }) => {
105
+ expect(mcpEnabled).toBe(false);
106
+ expect(await postMcp(port)).toBe(404);
107
+ },
108
+ );
109
+ }, 60_000);
110
+
111
+ it("keeps a host's mcp: false over MCP_ENABLED=true", async () => {
112
+ await withSwitchboard(
113
+ { MCP_ENABLED: "true" },
114
+ false,
115
+ async ({ mcpEnabled, port }) => {
116
+ expect(mcpEnabled).toBe(false);
117
+ expect(await postMcp(port)).toBe(404);
118
+ },
119
+ );
120
+ }, 60_000);
121
+ });
@@ -0,0 +1,243 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { createServer } from "node:net";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ILogger } from "document-model";
6
+ import pg from "pg";
7
+ import { describe, expect, it, vi } from "vitest";
8
+ import { startSwitchboard } from "../src/server.mjs";
9
+
10
+ const ADMIN = "0xadad00000000000000000000000000000000adad";
11
+ const SECRET = "switchboard-privacy-test-deployment-secret-0123";
12
+
13
+ function stubLogger(): ILogger & { error: ReturnType<typeof vi.fn> } {
14
+ const logger = {
15
+ verbose: vi.fn(),
16
+ debug: vi.fn(),
17
+ info: vi.fn(),
18
+ warn: vi.fn(),
19
+ error: vi.fn(),
20
+ child: vi.fn(),
21
+ };
22
+ logger.child.mockReturnValue(logger);
23
+ return logger as unknown as ILogger & { error: ReturnType<typeof vi.fn> };
24
+ }
25
+
26
+ type Switchboard = Awaited<ReturnType<typeof startSwitchboard>>;
27
+
28
+ async function withEnv<T>(
29
+ env: Record<string, string>,
30
+ run: (tempRoot: string) => Promise<T>,
31
+ ): Promise<T> {
32
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-privacy-"));
33
+ const names = [
34
+ "PH_REACTOR_DATABASE_URL",
35
+ "AUTH_ENABLED",
36
+ "ADMINS",
37
+ "PH_PRIVACY_ENABLED",
38
+ "PH_PRIVACY_DEPLOYMENT_SECRET",
39
+ ...Object.keys(env),
40
+ ];
41
+ const previous = names.map((name) => [name, process.env[name]] as const);
42
+ for (const name of names) delete process.env[name];
43
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
44
+ Object.assign(process.env, env);
45
+ try {
46
+ return await run(tempRoot);
47
+ } finally {
48
+ for (const [name, value] of previous) {
49
+ if (value === undefined) {
50
+ delete process.env[name];
51
+ } else {
52
+ process.env[name] = value;
53
+ }
54
+ }
55
+ await rm(tempRoot, { recursive: true, force: true });
56
+ }
57
+ }
58
+
59
+ async function freePort(): Promise<number> {
60
+ const server = createServer();
61
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
62
+ const address = server.address();
63
+ await new Promise((resolve) => server.close(resolve));
64
+ if (address === null || typeof address === "string") {
65
+ throw new Error("no port");
66
+ }
67
+ return address.port;
68
+ }
69
+
70
+ async function boot(
71
+ tempRoot: string,
72
+ logger: ILogger = stubLogger(),
73
+ ): Promise<Switchboard> {
74
+ return startSwitchboard({
75
+ workflows: { enabled: false },
76
+ dbPath: join(tempRoot, "read-model"),
77
+ port: await freePort(),
78
+ strictPort: true,
79
+ mcp: false,
80
+ disableLocalPackages: true,
81
+ identity: { keypairPath: join(tempRoot, "identity.json") },
82
+ logger,
83
+ });
84
+ }
85
+
86
+ async function queryErasure(switchboard: Switchboard) {
87
+ const response = await fetch(`http://127.0.0.1:${switchboard.port}/graphql`, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({
91
+ query: `{ erasureRequest(requestId: "none") { requestId } }`,
92
+ }),
93
+ });
94
+ return (await response.json()) as {
95
+ errors?: { message: string; extensions?: { code?: string } }[];
96
+ };
97
+ }
98
+
99
+ describe("booting Switchboard with the privacy add-on", () => {
100
+ it("mounts nothing when PH_PRIVACY_ENABLED is off", async () => {
101
+ await withEnv({}, async (tempRoot) => {
102
+ const switchboard = await boot(tempRoot);
103
+ try {
104
+ expect(switchboard.privacy).toBeUndefined();
105
+ const result = await queryErasure(switchboard);
106
+ expect(result.errors?.[0]?.message).toMatch(/erasureRequest/);
107
+ expect(result.errors?.[0]?.extensions?.code).not.toBe("FORBIDDEN");
108
+ } finally {
109
+ await switchboard.shutdown();
110
+ }
111
+ });
112
+ }, 60_000);
113
+
114
+ it("serves erasure to admins only when it is on under authentication", async () => {
115
+ await withEnv(
116
+ {
117
+ AUTH_ENABLED: "true",
118
+ ADMINS: ADMIN,
119
+ PH_PRIVACY_ENABLED: "true",
120
+ PH_PRIVACY_DEPLOYMENT_SECRET: SECRET,
121
+ PH_PRIVACY_INTERVAL_MS: "50",
122
+ },
123
+ async (tempRoot) => {
124
+ const logger = stubLogger();
125
+ const switchboard = await boot(tempRoot, logger);
126
+ try {
127
+ expect(switchboard.privacy).toBeDefined();
128
+ expect(await switchboard.privacy!.erasure.plan([])).toEqual({
129
+ maxPurgeOperations: expect.any(Number) as number,
130
+ items: [],
131
+ });
132
+ await vi.waitUntil(
133
+ async () =>
134
+ (await queryErasure(switchboard)).errors?.[0]?.extensions
135
+ ?.code === "FORBIDDEN",
136
+ { timeout: 20_000, interval: 100 },
137
+ );
138
+ const document = await switchboard.reactor.createEmpty(
139
+ "powerhouse/document-model",
140
+ );
141
+ const id = document.header.id;
142
+ await switchboard.reactor.deleteDocument(id);
143
+ const erasure = switchboard.privacy!.erasure;
144
+ const { requestId } = await erasure.request([id], {
145
+ requestedBy: ADMIN,
146
+ });
147
+ await vi.waitUntil(
148
+ async () => (await erasure.status(requestId)).status === "complete",
149
+ { timeout: 30_000, interval: 100 },
150
+ );
151
+ expect(
152
+ logger.error.mock.calls.filter(([message]) =>
153
+ String(message).startsWith("Erasure"),
154
+ ),
155
+ ).toEqual([]);
156
+ } finally {
157
+ await switchboard.shutdown();
158
+ }
159
+ },
160
+ );
161
+ }, 60_000);
162
+
163
+ it("refuses to boot under the OPEN policy", async () => {
164
+ await withEnv(
165
+ {
166
+ PH_PRIVACY_ENABLED: "true",
167
+ PH_PRIVACY_DEPLOYMENT_SECRET: SECRET,
168
+ },
169
+ async (tempRoot) => {
170
+ await expect(boot(tempRoot)).rejects.toThrow(/OPEN/);
171
+ },
172
+ );
173
+ }, 60_000);
174
+
175
+ it("refuses to boot without a deployment secret", async () => {
176
+ await withEnv(
177
+ { AUTH_ENABLED: "true", ADMINS: ADMIN, PH_PRIVACY_ENABLED: "true" },
178
+ async (tempRoot) => {
179
+ await expect(boot(tempRoot)).rejects.toThrow(
180
+ /PH_PRIVACY_DEPLOYMENT_SECRET/,
181
+ );
182
+ },
183
+ );
184
+ }, 60_000);
185
+ });
186
+
187
+ const PG_URL = process.env.REACTOR_TEST_PG_URL;
188
+
189
+ describe.skipIf(!PG_URL)(
190
+ "the privacy add-on on a Postgres reactor store",
191
+ () => {
192
+ it("erases a document end to end", async () => {
193
+ const name = `sb_privacy_${process.pid}`;
194
+ const admin = new pg.Pool({ connectionString: PG_URL });
195
+ await admin.query(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
196
+ await admin.query(`CREATE DATABASE "${name}"`);
197
+ const url = new URL(PG_URL!);
198
+ url.pathname = `/${name}`;
199
+ try {
200
+ await withEnv(
201
+ {
202
+ AUTH_ENABLED: "true",
203
+ ADMINS: ADMIN,
204
+ PH_PRIVACY_ENABLED: "true",
205
+ PH_PRIVACY_DEPLOYMENT_SECRET: SECRET,
206
+ PH_PRIVACY_INTERVAL_MS: "50",
207
+ },
208
+ async (tempRoot) => {
209
+ process.env.PH_REACTOR_DATABASE_URL = url.toString();
210
+ const logger = stubLogger();
211
+ const switchboard = await boot(tempRoot, logger);
212
+ try {
213
+ const document = await switchboard.reactor.createEmpty(
214
+ "powerhouse/document-model",
215
+ );
216
+ const id = document.header.id;
217
+ await switchboard.reactor.deleteDocument(id);
218
+ const erasure = switchboard.privacy!.erasure;
219
+ const { requestId } = await erasure.request([id], {
220
+ requestedBy: ADMIN,
221
+ });
222
+ await vi.waitUntil(
223
+ async () =>
224
+ (await erasure.status(requestId)).status === "complete",
225
+ { timeout: 30_000, interval: 100 },
226
+ );
227
+ expect(
228
+ logger.error.mock.calls.filter(([message]) =>
229
+ String(message).startsWith("Erasure"),
230
+ ),
231
+ ).toEqual([]);
232
+ } finally {
233
+ await switchboard.shutdown();
234
+ }
235
+ },
236
+ );
237
+ } finally {
238
+ await admin.query(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
239
+ await admin.end();
240
+ }
241
+ }, 90_000);
242
+ },
243
+ );
@@ -89,7 +89,8 @@ async function seedStore(dir: string): Promise<void> {
89
89
  const { status } = await module.reactor.getJobStatus(created.id);
90
90
  return status === JobStatus.READ_READY || status === JobStatus.FAILED;
91
91
  },
92
- { timeout: 10_000, interval: 5 },
92
+ // One job is ~40 full snapshots; the Windows runner needs the boot budget.
93
+ { timeout: BOOT_TIMEOUT, interval: 5 },
93
94
  );
94
95
  expect((await module.reactor.getJobStatus(created.id)).status).toBe(
95
96
  JobStatus.READ_READY,
@@ -100,6 +101,8 @@ async function seedStore(dir: string): Promise<void> {
100
101
 
101
102
  // PGlite boots are several times slower on Windows runners.
102
103
  const BOOT_TIMEOUT = 120_000;
104
+ // Windows can briefly keep just-closed store files locked (EBUSY/EPERM).
105
+ const RM_OPTIONS = { recursive: true, force: true, maxRetries: 10 };
103
106
 
104
107
  describe("booting over documents this build does not run", () => {
105
108
  let seeded: string;
@@ -112,8 +115,8 @@ describe("booting over documents this build does not run", () => {
112
115
  }, BOOT_TIMEOUT);
113
116
 
114
117
  afterAll(async () => {
115
- await rm(seeded, { recursive: true, force: true });
116
- });
118
+ await rm(seeded, RM_OPTIONS);
119
+ }, BOOT_TIMEOUT);
117
120
 
118
121
  beforeEach(async () => {
119
122
  tempRoot = await mkdtemp(join(tmpdir(), "switchboard-stored-protocol-"));
@@ -126,7 +129,7 @@ describe("booting over documents this build does not run", () => {
126
129
  process.env.PH_REACTOR_DATABASE_URL,
127
130
  { recursive: true },
128
131
  );
129
- });
132
+ }, BOOT_TIMEOUT);
130
133
 
131
134
  afterEach(async () => {
132
135
  for (const key of ENV_KEYS) {
@@ -136,8 +139,8 @@ describe("booting over documents this build does not run", () => {
136
139
  process.env[key] = previous[key];
137
140
  }
138
141
  }
139
- await rm(tempRoot, { recursive: true, force: true });
140
- });
142
+ await rm(tempRoot, RM_OPTIONS);
143
+ }, BOOT_TIMEOUT);
141
144
 
142
145
  function boot(logger: ILogger) {
143
146
  return startSwitchboard({
@@ -1,6 +1,9 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { installFatalErrorShutdown } from "../src/fatal-shutdown.mjs";
3
+ import {
4
+ installFatalErrorShutdown,
5
+ triggerFatalShutdown,
6
+ } from "../src/fatal-shutdown.mjs";
4
7
 
5
8
  function makeProc() {
6
9
  const emitter = new EventEmitter();
@@ -91,6 +94,33 @@ describe("installFatalErrorShutdown", () => {
91
94
  expect(proc.exit).not.toHaveBeenCalled();
92
95
  });
93
96
 
97
+ it("routes a triggered fatal error through SIGTERM with exit code 1", () => {
98
+ const proc = makeProc();
99
+ const onSigterm = vi.fn();
100
+ proc.on("SIGTERM", onSigterm);
101
+ installFatalErrorShutdown(logger, proc as never);
102
+
103
+ expect(
104
+ triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never),
105
+ ).toBe(true);
106
+ triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never);
107
+ proc.emit("uncaughtException", new Error("later"));
108
+
109
+ expect(proc.kill).toHaveBeenCalledOnce();
110
+ expect(onSigterm).toHaveBeenCalledOnce();
111
+ expect(proc.exitCode).toBe(1);
112
+ });
113
+
114
+ it("ignores a trigger when shutdown on fatal errors is not installed", () => {
115
+ const proc = makeProc();
116
+
117
+ expect(
118
+ triggerFatalShutdown("Flush failed", new Error("ENOSPC"), proc as never),
119
+ ).toBe(false);
120
+ expect(proc.kill).not.toHaveBeenCalled();
121
+ expect(proc.exitCode).toBeUndefined();
122
+ });
123
+
94
124
  it("installs its listeners once per process", () => {
95
125
  const proc = makeProc();
96
126
  installFatalErrorShutdown(logger, proc as never);
@@ -0,0 +1,56 @@
1
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
2
+ import { initFeatureFlags } from "../src/feature-flags.js";
3
+ import { MCP_ENABLED, resolveMcpEnabled } from "../src/mcp-flag.mjs";
4
+ import type { BooleanFlagSource } from "../src/workflow-runtime.mjs";
5
+
6
+ describe("resolveMcpEnabled", () => {
7
+ let featureFlags: BooleanFlagSource;
8
+
9
+ beforeAll(async () => {
10
+ featureFlags = await initFeatureFlags();
11
+ });
12
+
13
+ afterEach(() => {
14
+ delete process.env[MCP_ENABLED];
15
+ });
16
+
17
+ it("defaults to on", async () => {
18
+ await expect(resolveMcpEnabled({ featureFlags })).resolves.toBe(true);
19
+ await expect(
20
+ resolveMcpEnabled({ featureFlags, option: true }),
21
+ ).resolves.toBe(true);
22
+ });
23
+
24
+ it('is turned off by MCP_ENABLED="false"', async () => {
25
+ process.env[MCP_ENABLED] = "false";
26
+
27
+ await expect(resolveMcpEnabled({ featureFlags })).resolves.toBe(false);
28
+ });
29
+
30
+ // `ph vetra` passes `mcp: true` explicitly; the env var still wins.
31
+ it("turns off a host that asked for MCP", async () => {
32
+ process.env[MCP_ENABLED] = "false";
33
+
34
+ await expect(
35
+ resolveMcpEnabled({ featureFlags, option: true }),
36
+ ).resolves.toBe(false);
37
+ });
38
+
39
+ it("never turns on a host that switched MCP off", async () => {
40
+ process.env[MCP_ENABLED] = "true";
41
+
42
+ await expect(
43
+ resolveMcpEnabled({ featureFlags, option: false }),
44
+ ).resolves.toBe(false);
45
+ });
46
+
47
+ it("leaves MCP on for any value but the string false", async () => {
48
+ for (const raw of ["true", "0", "no", ""]) {
49
+ process.env[MCP_ENABLED] = raw;
50
+ await expect(
51
+ resolveMcpEnabled({ featureFlags }),
52
+ `MCP_ENABLED=${JSON.stringify(raw)}`,
53
+ ).resolves.toBe(true);
54
+ }
55
+ });
56
+ });