@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/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
- package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/CHANGELOG.md +50 -0
- package/README.md +1 -0
- package/dist/index.mjs +1 -1
- package/dist/{server-DAgJkmvH.mjs → server-CXAp5TOI.mjs} +149 -34
- package/dist/server-CXAp5TOI.mjs.map +1 -0
- package/dist/server.d.mts +22 -1
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -3
- package/package.json +16 -15
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/boot-mcp-enabled.test.ts +121 -0
- package/test/boot-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +9 -6
- package/test/fatal-shutdown.test.ts +31 -1
- package/test/mcp-flag.test.ts +56 -0
- package/test/workflow-runtime.test.ts +14 -1
- package/tsconfig.json +3 -0
- package/dist/server-DAgJkmvH.mjs.map +0 -1
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
|
/**
|
package/dist/server.d.mts.map
CHANGED
|
@@ -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":"
|
|
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-
|
|
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]="
|
|
5
|
-
//# debugId=
|
|
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.
|
|
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.
|
|
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.
|
|
59
|
-
"@powerhousedao/reactor": "6.2.3-dev.
|
|
60
|
-
"@powerhousedao/shared": "6.2.3-dev.
|
|
61
|
-
"@powerhousedao/vetra": "6.2.3-dev.
|
|
62
|
-
"@powerhousedao/reactor-api": "6.2.3-dev.
|
|
63
|
-
"@powerhousedao/reactor-attachments": "6.2.3-dev.
|
|
64
|
-
"@powerhousedao/reactor-drive": "6.2.3-dev.
|
|
65
|
-
"@powerhousedao/reactor-group": "6.2.3-dev.
|
|
66
|
-
"@powerhousedao/reactor-
|
|
67
|
-
"@powerhousedao/workflow": "6.2.3-dev.
|
|
68
|
-
"@powerhousedao/
|
|
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.
|
|
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.
|
|
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 = {
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
+
});
|