@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/dist/server.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ILogger } from "document-model";
2
- import { ChannelScheme, IDocumentModelLoader, IReactorClient, InProcessReactorClientModule, JwtHandler, ReactorBuilder, ReactorClientBuilder, ReactorFeatureFlags, SignerConfig } from "@powerhousedao/reactor";
2
+ import { ChannelScheme, IDocumentModelLoader, IReactorClient, InProcessReactorClientModule, JwtHandler, ReactorBuilder, ReactorClientBuilder, ReactorFeatureFlags, SignerConfig, UnsupportedStoredDocuments, UnsupportedStoredProtocolError } from "@powerhousedao/reactor";
3
3
  import { AttachmentReferenceProjectionCapability } from "@powerhousedao/reactor-api";
4
4
  import { IAttachmentService } from "@powerhousedao/reactor-attachments";
5
5
  import { IRenown } from "@renown/sdk/node";
@@ -108,6 +108,8 @@ export type StartServerOptions = {
108
108
  identity?: IdentityOptions;
109
109
  /** Base URL for the attachment service; defaults to `PH_SWITCHBOARD_PUBLIC_URL` then `http(s)://localhost:${port}`. */
110
110
  attachmentServiceUrl?: string;
111
+ /** Mount the MCP server at `/mcp`. On by default; `false` wins over
112
+ * MCP_ENABLED, which can turn it off but never on. */
111
113
  mcp?: boolean;
112
114
  /** Powerhouse workflows: the runtime, its subgraph, its webhooks and its
113
115
  * document models. Wins over PH_WORKFLOWS_ENABLED and the config file. */
@@ -170,6 +172,14 @@ export type StartServerOptions = {
170
172
  enabled?: boolean;
171
173
  dbPoolSize?: number;
172
174
  };
175
+ /**
176
+ * Stored documents at protocol versions this build does not run: "refuse"
177
+ * (the default) fails the boot with a StoredDocumentsRefusedError;
178
+ * "read-only" starts with a warning and keeps those documents read-only.
179
+ * Unset falls back to the REACTOR_UNSUPPORTED_STORED_DOCUMENTS env var.
180
+ * Ignored when `reactor` is passed.
181
+ */
182
+ unsupportedStoredDocuments?: UnsupportedStoredDocuments;
173
183
  };
174
184
  export type SwitchboardReactor = {
175
185
  defaultDriveUrl: string | undefined;
@@ -183,6 +193,8 @@ export type SwitchboardReactor = {
183
193
  workflowTriggers?: WorkflowTriggersCapability;
184
194
  /** Whether workflows are on, after the option, PH_WORKFLOWS_ENABLED and the config file. */
185
195
  workflowsEnabled: boolean;
196
+ /** Whether `/mcp` is mounted, after the option and MCP_ENABLED. */
197
+ mcpEnabled: boolean;
186
198
  /** The Renown instance if identity was initialized */
187
199
  renown: IRenown$1 | null;
188
200
  /**
@@ -244,6 +256,20 @@ type SwitchboardReactorDefaultsOptions = {
244
256
  };
245
257
  export declare function applySwitchboardReactorDefaults(reactorBuilder: ReactorBuilder, clientBuilder: ReactorClientBuilder, options?: SwitchboardReactorDefaultsOptions): void;
246
258
  //#endregion
259
+ //#region src/unsupported-stored-documents.d.mts
260
+ /** From REACTOR_UNSUPPORTED_STORED_DOCUMENTS; `input` wins when defined. */
261
+ export declare function resolveUnsupportedStoredDocuments(input: UnsupportedStoredDocuments | undefined, env: NodeJS.ProcessEnv): UnsupportedStoredDocuments;
262
+ /** The reactor refused to start over documents this build does not run. */
263
+ export declare class StoredDocumentsRefusedError extends Error {
264
+ readonly versions: readonly {
265
+ protocol: string;
266
+ version: number;
267
+ }[];
268
+ readonly documents: number;
269
+ constructor(cause: UnsupportedStoredProtocolError);
270
+ static isError(error: unknown): error is StoredDocumentsRefusedError;
271
+ }
272
+ //#endregion
247
273
  //#region src/server.d.mts
248
274
  /**
249
275
  * Attempt to bind a throwaway TCP server to the given port. Resolves true if
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.mts","names":[],"sources":["../src/workflow-runtime.mts","../src/types.ts","../src/builder-defaults.mts","../src/server.mts"],"mappings":";;;;;;;;;;;;;KA2DY;EACN;;EAEA;EACA;;;;;;;;;;YC7CM;;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;;;YAIQ;EACV;EACA,SAAS;;EAET,mBAAmB;;EAEnB,+BAA+B;;;EAG/B,mBAAmB;;EAEnB;;EAEA,QAAQ;;;;;EAKR;;;;;;;;;;;;;EAaA,gBAAgB;;;;KCzLN;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;;;;;;;;wBC6DK,gBAAgB,eAAe;;wBAoI/B,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBAu5BvB,mBAAgB,UAClB,uBACR,QAAQ"}
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;;;EAGA;;;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;;EAEA,QAAQ;;;;;EAKR;;;;;;;;;;;;;EAaA,gBAAgB;;;;KCtMN;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;;;;;;;;;wBC4G3B,gBAAgB,eAAe;;wBAoI/B,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBAk7BvB,mBAAgB,UAClB,uBACR,QAAQ"}
package/dist/server.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { i as applySwitchboardReactorDefaults, n as isPortAvailable, r as startSwitchboard, t as deriveAttachmentServiceConfig } from "./server-CKKn0aXV.mjs";
3
- export { applySwitchboardReactorDefaults, deriveAttachmentServiceConfig, isPortAvailable, 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]="fe3a107a-4dd4-530b-b8d9-ef36656e688e")}catch(e){}}();
5
- //# debugId=fe3a107a-4dd4-530b-b8d9-ef36656e688e
2
+ import { a as resolveUnsupportedStoredDocuments, i as StoredDocumentsRefusedError, n as isPortAvailable, o as applySwitchboardReactorDefaults, r as startSwitchboard, t as deriveAttachmentServiceConfig } from "./server-sQoWp21G.mjs";
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]="1396e5c4-5148-5e73-aaff-79e70b5910cc")}catch(e){}}();
5
+ //# debugId=1396e5c4-5148-5e73-aaff-79e70b5910cc
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.32",
4
+ "version": "6.2.3-dev.34",
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.32",
44
+ "@powerhousedao/config": "6.2.3-dev.34",
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,22 @@
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.32",
59
- "@powerhousedao/reactor": "6.2.3-dev.32",
60
- "@powerhousedao/shared": "6.2.3-dev.32",
61
- "@powerhousedao/vetra": "6.2.3-dev.32",
62
- "@powerhousedao/reactor-api": "6.2.3-dev.32",
63
- "@powerhousedao/reactor-attachments": "6.2.3-dev.32",
64
- "@powerhousedao/reactor-drive": "6.2.3-dev.32",
65
- "@powerhousedao/reactor-group": "6.2.3-dev.32",
66
- "@powerhousedao/reactor-workflow": "6.2.3-dev.32",
67
- "@powerhousedao/workflow": "6.2.3-dev.32",
68
- "@powerhousedao/pglite-fs": "6.2.3-dev.32",
58
+ "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.34",
59
+ "@powerhousedao/reactor": "6.2.3-dev.34",
60
+ "@powerhousedao/shared": "6.2.3-dev.34",
61
+ "@powerhousedao/vetra": "6.2.3-dev.34",
62
+ "@powerhousedao/reactor-api": "6.2.3-dev.34",
63
+ "@powerhousedao/reactor-attachments": "6.2.3-dev.34",
64
+ "@powerhousedao/reactor-drive": "6.2.3-dev.34",
65
+ "@powerhousedao/reactor-group": "6.2.3-dev.34",
66
+ "@powerhousedao/reactor-workflow": "6.2.3-dev.34",
67
+ "@powerhousedao/workflow": "6.2.3-dev.34",
68
+ "@powerhousedao/pglite-fs": "6.2.3-dev.34",
69
69
  "@pyroscope/nodejs": "^0.4.5",
70
- "@renown/sdk": "6.2.3-dev.32",
70
+ "@renown/sdk": "6.2.3-dev.34",
71
71
  "@sentry/node": "^10.53.1",
72
72
  "@sentry/opentelemetry": "^10.53.1",
73
- "document-model": "6.2.3-dev.32",
73
+ "document-model": "6.2.3-dev.34",
74
74
  "dotenv": "^16.4.7",
75
75
  "express": "^4.22.1",
76
76
  "graphql": "^16",
@@ -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,210 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { AtomicNodeFs } from "@powerhousedao/pglite-fs";
3
+ import {
4
+ JobStatus,
5
+ ReactorBuilder,
6
+ type Database,
7
+ } from "@powerhousedao/reactor";
8
+ import { driveDocumentModelModule } from "@powerhousedao/shared/document-drive";
9
+ import {
10
+ withSignaturePolicy,
11
+ type PeerCapability,
12
+ } from "@powerhousedao/shared/document-model";
13
+ import type { ILogger } from "document-model";
14
+ import { Kysely } from "kysely";
15
+ import { cp, mkdtemp, rm } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import {
19
+ afterAll,
20
+ afterEach,
21
+ beforeAll,
22
+ beforeEach,
23
+ describe,
24
+ expect,
25
+ it,
26
+ vi,
27
+ } from "vitest";
28
+ import { ClosablePGliteDialect } from "../src/pglite-dialect.js";
29
+ import { startSwitchboard } from "../src/server.mjs";
30
+ import { StoredDocumentsRefusedError } from "../src/unsupported-stored-documents.mjs";
31
+
32
+ const ENV_KEYS = [
33
+ "PH_REACTOR_DATABASE_URL",
34
+ "DATABASE_URL",
35
+ "REACTOR_UNSUPPORTED_STORED_DOCUMENTS",
36
+ ] as const;
37
+
38
+ const BASE_REDUCER_7: PeerCapability = {
39
+ kind: "protocol",
40
+ name: "base-reducer",
41
+ baseline: [1, 2],
42
+ supported: () => [1, 2, 7],
43
+ preferred: () => 2,
44
+ optional: false,
45
+ };
46
+
47
+ type StubLogger = ILogger & {
48
+ warn: ReturnType<typeof vi.fn>;
49
+ error: ReturnType<typeof vi.fn>;
50
+ };
51
+
52
+ function stubLogger(): StubLogger {
53
+ const logger = {
54
+ verbose: vi.fn(),
55
+ debug: vi.fn(),
56
+ info: vi.fn(),
57
+ warn: vi.fn(),
58
+ error: vi.fn(),
59
+ child: vi.fn(),
60
+ };
61
+ logger.child.mockReturnValue(logger);
62
+ return logger as unknown as StubLogger;
63
+ }
64
+
65
+ /** A reactor store holding one drive created at base-reducer 7. */
66
+ async function seedStore(dir: string): Promise<void> {
67
+ const db = new Kysely<Database>({
68
+ dialect: new ClosablePGliteDialect(
69
+ new PGlite({ fs: new AtomicNodeFs(dir) }),
70
+ ),
71
+ });
72
+ const module = await new ReactorBuilder()
73
+ .withKysely(db)
74
+ .withDocumentModelSources([driveDocumentModelModule as never])
75
+ .withPeerCapabilities([BASE_REDUCER_7])
76
+ .buildModule();
77
+ const created = await module.reactor.create(
78
+ withSignaturePolicy(
79
+ driveDocumentModelModule.utils.createDocument(),
80
+ "legacy",
81
+ {
82
+ id: "br7-stored",
83
+ protocolVersions: { "base-reducer": 7 },
84
+ },
85
+ ),
86
+ );
87
+ await vi.waitUntil(
88
+ async () => {
89
+ const { status } = await module.reactor.getJobStatus(created.id);
90
+ return status === JobStatus.READ_READY || status === JobStatus.FAILED;
91
+ },
92
+ { timeout: 10_000, interval: 5 },
93
+ );
94
+ expect((await module.reactor.getJobStatus(created.id)).status).toBe(
95
+ JobStatus.READ_READY,
96
+ );
97
+ await module.reactor.kill().completed;
98
+ await db.destroy();
99
+ }
100
+
101
+ // PGlite boots are several times slower on Windows runners.
102
+ const BOOT_TIMEOUT = 120_000;
103
+
104
+ describe("booting over documents this build does not run", () => {
105
+ let seeded: string;
106
+ let tempRoot: string;
107
+ let previous: Partial<Record<(typeof ENV_KEYS)[number], string>>;
108
+
109
+ beforeAll(async () => {
110
+ seeded = await mkdtemp(join(tmpdir(), "switchboard-stored-protocol-seed-"));
111
+ await seedStore(join(seeded, "reactor-storage"));
112
+ }, BOOT_TIMEOUT);
113
+
114
+ afterAll(async () => {
115
+ await rm(seeded, { recursive: true, force: true });
116
+ });
117
+
118
+ beforeEach(async () => {
119
+ tempRoot = await mkdtemp(join(tmpdir(), "switchboard-stored-protocol-"));
120
+ previous = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
121
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
122
+ process.env.DATABASE_URL = join(tempRoot, "read-model");
123
+ delete process.env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS;
124
+ await cp(
125
+ join(seeded, "reactor-storage"),
126
+ process.env.PH_REACTOR_DATABASE_URL,
127
+ { recursive: true },
128
+ );
129
+ });
130
+
131
+ afterEach(async () => {
132
+ for (const key of ENV_KEYS) {
133
+ if (previous[key] === undefined) {
134
+ delete process.env[key];
135
+ } else {
136
+ process.env[key] = previous[key];
137
+ }
138
+ }
139
+ await rm(tempRoot, { recursive: true, force: true });
140
+ });
141
+
142
+ function boot(logger: ILogger) {
143
+ return startSwitchboard({
144
+ workflows: { enabled: false },
145
+ port: 0,
146
+ mcp: false,
147
+ disableLocalPackages: true,
148
+ identity: { keypairPath: join(tempRoot, "identity.json") },
149
+ logger,
150
+ });
151
+ }
152
+
153
+ it(
154
+ "refuses by default and tells the operator both ways forward",
155
+ async () => {
156
+ const logger = stubLogger();
157
+
158
+ const booted = boot(logger);
159
+
160
+ await expect(booted).rejects.toSatisfy(
161
+ (error) =>
162
+ StoredDocumentsRefusedError.isError(error) &&
163
+ error.documents === 1 &&
164
+ error.versions.some(
165
+ ({ protocol, version }) =>
166
+ protocol === "base-reducer" && version === 7,
167
+ ),
168
+ );
169
+ const message = (await booted.catch((error: Error) => error)) as Error;
170
+ expect(message.message).toMatch(
171
+ /1 stored document\(s\) require base-reducer 7/,
172
+ );
173
+ expect(message.message).toMatch(
174
+ /switchboard build that runs base-reducer 7/,
175
+ );
176
+ expect(message.message).toMatch(
177
+ /REACTOR_UNSUPPORTED_STORED_DOCUMENTS=read-only/,
178
+ );
179
+ expect(logger.error).toHaveBeenCalledWith(message.message);
180
+ expect(logger.error).not.toHaveBeenCalledWith(
181
+ "App crashed: @error",
182
+ expect.anything(),
183
+ );
184
+ },
185
+ BOOT_TIMEOUT,
186
+ );
187
+
188
+ it(
189
+ "starts read-only when REACTOR_UNSUPPORTED_STORED_DOCUMENTS says so",
190
+ async () => {
191
+ process.env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS = "read-only";
192
+ const logger = stubLogger();
193
+
194
+ const switchboard = await boot(logger);
195
+ try {
196
+ expect(logger.warn).toHaveBeenCalledWith(
197
+ expect.stringContaining("read-only"),
198
+ expect.stringContaining(
199
+ "1 stored document(s) require base-reducer 7",
200
+ ),
201
+ );
202
+ const stored = await switchboard.reactor.get("br7-stored");
203
+ expect(stored.header.id).toBe("br7-stored");
204
+ } finally {
205
+ await switchboard.shutdown();
206
+ }
207
+ },
208
+ BOOT_TIMEOUT,
209
+ );
210
+ });
@@ -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
+ });
@@ -0,0 +1,72 @@
1
+ import { UnsupportedStoredProtocolError } from "@powerhousedao/reactor";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ StoredDocumentsRefusedError,
5
+ resolveUnsupportedStoredDocuments,
6
+ } from "../src/unsupported-stored-documents.mjs";
7
+
8
+ describe("resolveUnsupportedStoredDocuments", () => {
9
+ it("refuses by default", () => {
10
+ expect(resolveUnsupportedStoredDocuments(undefined, {})).toBe("refuse");
11
+ expect(
12
+ resolveUnsupportedStoredDocuments(undefined, {
13
+ REACTOR_UNSUPPORTED_STORED_DOCUMENTS: " ",
14
+ }),
15
+ ).toBe("refuse");
16
+ });
17
+
18
+ it("reads REACTOR_UNSUPPORTED_STORED_DOCUMENTS", () => {
19
+ expect(
20
+ resolveUnsupportedStoredDocuments(undefined, {
21
+ REACTOR_UNSUPPORTED_STORED_DOCUMENTS: " read-only ",
22
+ }),
23
+ ).toBe("read-only");
24
+ expect(
25
+ resolveUnsupportedStoredDocuments(undefined, {
26
+ REACTOR_UNSUPPORTED_STORED_DOCUMENTS: "refuse",
27
+ }),
28
+ ).toBe("refuse");
29
+ });
30
+
31
+ it("lets the option win over the env var", () => {
32
+ expect(
33
+ resolveUnsupportedStoredDocuments("refuse", {
34
+ REACTOR_UNSUPPORTED_STORED_DOCUMENTS: "read-only",
35
+ }),
36
+ ).toBe("refuse");
37
+ });
38
+
39
+ it("refuses an unknown value", () => {
40
+ expect(() =>
41
+ resolveUnsupportedStoredDocuments(undefined, {
42
+ REACTOR_UNSUPPORTED_STORED_DOCUMENTS: "readonly",
43
+ }),
44
+ ).toThrow(
45
+ 'REACTOR_UNSUPPORTED_STORED_DOCUMENTS must be "refuse" or "read-only", got "readonly"',
46
+ );
47
+ });
48
+ });
49
+
50
+ describe("StoredDocumentsRefusedError", () => {
51
+ it("names the versions, the count and both ways forward", () => {
52
+ const cause = new UnsupportedStoredProtocolError(
53
+ [
54
+ { protocol: "base-reducer", version: 3 },
55
+ { protocol: "base-reducer", version: 7 },
56
+ ],
57
+ 4,
58
+ );
59
+
60
+ const error = new StoredDocumentsRefusedError(cause);
61
+
62
+ expect(error.message).toBe(
63
+ "Refusing to start: 4 stored document(s) require base-reducer 3, base-reducer 7, which this switchboard does not run. " +
64
+ "Either start a switchboard build that runs base-reducer 3, base-reducer 7, or set REACTOR_UNSUPPORTED_STORED_DOCUMENTS=read-only to start with those documents read-only.",
65
+ );
66
+ expect(error.documents).toBe(4);
67
+ expect(error.versions).toBe(cause.versions);
68
+ expect(error.cause).toBe(cause);
69
+ expect(StoredDocumentsRefusedError.isError(error)).toBe(true);
70
+ expect(StoredDocumentsRefusedError.isError(cause)).toBe(false);
71
+ });
72
+ });