@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.50

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.
Files changed (137) hide show
  1. package/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
  2. package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
  3. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
  4. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
  5. package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
  7. package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
  8. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  19. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  20. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  21. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  24. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  25. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  26. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  27. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
  28. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
  29. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  30. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  31. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  32. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  33. package/.tsbuild/test/observability-signals.test.d.ts +2 -0
  34. package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
  35. package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
  36. package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
  37. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  38. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  39. package/.tsbuild/test/snapshot-writer.d.ts +18 -0
  40. package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
  41. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  42. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  43. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  44. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  45. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  46. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  48. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  49. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  50. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  51. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  52. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  53. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  54. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  55. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  56. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  57. package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
  58. package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
  59. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
  60. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
  61. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  62. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  63. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  64. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  65. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  66. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  67. package/.tsbuild/vitest.config.d.ts.map +1 -1
  68. package/Auth.md +94 -0
  69. package/CHANGELOG.md +509 -0
  70. package/README.md +35 -0
  71. package/dist/index.d.mts +2 -1
  72. package/dist/index.mjs +48 -49
  73. package/dist/index.mjs.map +1 -1
  74. package/dist/install-packages.d.mts +2 -1
  75. package/dist/install-packages.mjs +2 -2
  76. package/dist/install-packages.mjs.map +1 -1
  77. package/dist/migrate.d.mts +2 -1
  78. package/dist/migrate.mjs +4 -3
  79. package/dist/migrate.mjs.map +1 -1
  80. package/dist/server-Zz7SqTwO.mjs +3335 -0
  81. package/dist/server-Zz7SqTwO.mjs.map +1 -0
  82. package/dist/server.d.mts +154 -30
  83. package/dist/server.d.mts.map +1 -1
  84. package/dist/server.mjs +4 -5
  85. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  86. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  87. package/dist/utils.d.mts +4 -5
  88. package/dist/utils.d.mts.map +1 -1
  89. package/dist/utils.mjs +3 -3
  90. package/package.json +22 -18
  91. package/test/attachment-reference-read-model.test.ts +6 -1
  92. package/test/attachments/auth.test.ts +85 -0
  93. package/test/attachments/byte-grants.test.ts +290 -0
  94. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  95. package/test/attachments/download-target.test.ts +201 -32
  96. package/test/attachments/index.test.ts +34 -3
  97. package/test/attachments/reserve-metadata-parity.test.ts +96 -0
  98. package/test/attachments/routes-integration.test.ts +60 -4
  99. package/test/attachments/routes.test.ts +166 -23
  100. package/test/attachments/service-config.test.ts +13 -3
  101. package/test/attachments/url-signer.test.ts +105 -0
  102. package/test/boot-create-signature-policy.test.ts +108 -0
  103. package/test/boot-mcp-enabled.test.ts +121 -0
  104. package/test/boot-privacy.test.ts +243 -0
  105. package/test/boot-unsupported-stored-documents.test.ts +213 -0
  106. package/test/boot-without-renown.test.ts +81 -0
  107. package/test/boot-without-require-signatures.test.ts +74 -0
  108. package/test/boot-worker-trust-policy.test.ts +90 -0
  109. package/test/builder-defaults.test.ts +80 -0
  110. package/test/create-signature-policy.test.ts +69 -0
  111. package/test/durable-node-fs-pg16.test.ts +80 -0
  112. package/test/fatal-shutdown.test.ts +165 -0
  113. package/test/mcp-flag.test.ts +56 -0
  114. package/test/observability-signals.test.ts +131 -0
  115. package/test/pglite-dialect.test.ts +159 -2
  116. package/test/pglite-preflight.test.ts +313 -0
  117. package/test/pglite-version.test.ts +9 -1
  118. package/test/renown-trust-policy.test.ts +315 -0
  119. package/test/snapshot-writer.ts +94 -0
  120. package/test/unsupported-stored-documents.test.ts +72 -0
  121. package/test/worker-pool.test.ts +27 -1
  122. package/test/workflow/harness.ts +289 -0
  123. package/test/workflow/oauth.test.ts +214 -0
  124. package/test/workflow/resolvers.test.ts +322 -0
  125. package/test/workflow/webhook-dedupe.test.ts +295 -0
  126. package/test/workflow/webhook-signed.test.ts +509 -0
  127. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  128. package/test/workflow/webhook-unsigned.test.ts +345 -0
  129. package/test/workflow-reactor-access.test.ts +384 -0
  130. package/test/workflow-reactor-enforcement.test.ts +805 -0
  131. package/test/workflow-read-gate.test.ts +210 -0
  132. package/test/workflow-runtime.test.ts +572 -0
  133. package/tsconfig.json +9 -3
  134. package/tsdown.config.ts +3 -1
  135. package/dist/server-CiKkVE6g.mjs +0 -1555
  136. package/dist/server-CiKkVE6g.mjs.map +0 -1
  137. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -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
+ );
@@ -0,0 +1,213 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { NodeFS } from "@electric-sql/pglite/nodefs";
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(new PGlite({ fs: new NodeFS(dir) })),
69
+ });
70
+ const module = await new ReactorBuilder()
71
+ .withKysely(db)
72
+ .withDocumentModelSources([driveDocumentModelModule as never])
73
+ .withPeerCapabilities([BASE_REDUCER_7])
74
+ .buildModule();
75
+ const created = await module.reactor.create(
76
+ withSignaturePolicy(
77
+ driveDocumentModelModule.utils.createDocument(),
78
+ "legacy",
79
+ {
80
+ id: "br7-stored",
81
+ protocolVersions: { "base-reducer": 7 },
82
+ },
83
+ ),
84
+ );
85
+ await vi.waitUntil(
86
+ async () => {
87
+ const { status } = await module.reactor.getJobStatus(created.id);
88
+ return status === JobStatus.READ_READY || status === JobStatus.FAILED;
89
+ },
90
+ // PGlite is several times slower on the Windows runner.
91
+ { timeout: BOOT_TIMEOUT, interval: 5 },
92
+ );
93
+ expect((await module.reactor.getJobStatus(created.id)).status).toBe(
94
+ JobStatus.READ_READY,
95
+ );
96
+ await module.reactor.kill().completed;
97
+ await db.destroy();
98
+ // PGlite leaves its lockfile behind after a clean close.
99
+ await rm(join(dir, "postmaster.pid"), { force: true });
100
+ }
101
+
102
+ // PGlite boots are several times slower on Windows runners.
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 };
106
+
107
+ describe("booting over documents this build does not run", () => {
108
+ let seeded: string;
109
+ let tempRoot: string;
110
+ let previous: Partial<Record<(typeof ENV_KEYS)[number], string>>;
111
+
112
+ beforeAll(async () => {
113
+ seeded = await mkdtemp(join(tmpdir(), "switchboard-stored-protocol-seed-"));
114
+ await seedStore(join(seeded, "reactor-storage"));
115
+ }, BOOT_TIMEOUT);
116
+
117
+ afterAll(async () => {
118
+ await rm(seeded, RM_OPTIONS);
119
+ }, BOOT_TIMEOUT);
120
+
121
+ beforeEach(async () => {
122
+ tempRoot = await mkdtemp(join(tmpdir(), "switchboard-stored-protocol-"));
123
+ previous = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
124
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
125
+ process.env.DATABASE_URL = join(tempRoot, "read-model");
126
+ delete process.env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS;
127
+ await cp(
128
+ join(seeded, "reactor-storage"),
129
+ process.env.PH_REACTOR_DATABASE_URL,
130
+ { recursive: true },
131
+ );
132
+ }, BOOT_TIMEOUT);
133
+
134
+ afterEach(async () => {
135
+ for (const key of ENV_KEYS) {
136
+ if (previous[key] === undefined) {
137
+ delete process.env[key];
138
+ } else {
139
+ process.env[key] = previous[key];
140
+ }
141
+ }
142
+ await rm(tempRoot, RM_OPTIONS);
143
+ }, BOOT_TIMEOUT);
144
+
145
+ function boot(logger: ILogger) {
146
+ return startSwitchboard({
147
+ workflows: { enabled: false },
148
+ port: 0,
149
+ mcp: false,
150
+ disableLocalPackages: true,
151
+ identity: { keypairPath: join(tempRoot, "identity.json") },
152
+ logger,
153
+ });
154
+ }
155
+
156
+ it(
157
+ "refuses by default and tells the operator both ways forward",
158
+ async () => {
159
+ const logger = stubLogger();
160
+
161
+ const booted = boot(logger);
162
+
163
+ await expect(booted).rejects.toSatisfy(
164
+ (error) =>
165
+ StoredDocumentsRefusedError.isError(error) &&
166
+ error.documents === 1 &&
167
+ error.versions.some(
168
+ ({ protocol, version }) =>
169
+ protocol === "base-reducer" && version === 7,
170
+ ),
171
+ );
172
+ const message = (await booted.catch((error: Error) => error)) as Error;
173
+ expect(message.message).toMatch(
174
+ /1 stored document\(s\) require base-reducer 7/,
175
+ );
176
+ expect(message.message).toMatch(
177
+ /switchboard build that runs base-reducer 7/,
178
+ );
179
+ expect(message.message).toMatch(
180
+ /REACTOR_UNSUPPORTED_STORED_DOCUMENTS=read-only/,
181
+ );
182
+ expect(logger.error).toHaveBeenCalledWith(message.message);
183
+ expect(logger.error).not.toHaveBeenCalledWith(
184
+ "App crashed: @error",
185
+ expect.anything(),
186
+ );
187
+ },
188
+ BOOT_TIMEOUT,
189
+ );
190
+
191
+ it(
192
+ "starts read-only when REACTOR_UNSUPPORTED_STORED_DOCUMENTS says so",
193
+ async () => {
194
+ process.env.REACTOR_UNSUPPORTED_STORED_DOCUMENTS = "read-only";
195
+ const logger = stubLogger();
196
+
197
+ const switchboard = await boot(logger);
198
+ try {
199
+ expect(logger.warn).toHaveBeenCalledWith(
200
+ expect.stringContaining("read-only"),
201
+ expect.stringContaining(
202
+ "1 stored document(s) require base-reducer 7",
203
+ ),
204
+ );
205
+ const stored = await switchboard.reactor.get("br7-stored");
206
+ expect(stored.header.id).toBe("br7-stored");
207
+ } finally {
208
+ await switchboard.shutdown();
209
+ }
210
+ },
211
+ BOOT_TIMEOUT,
212
+ );
213
+ });
@@ -0,0 +1,81 @@
1
+ import {
2
+ isDerivedDocumentId,
3
+ signaturePolicyOf,
4
+ } from "@powerhousedao/shared/document-model";
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import type { ILogger } from "document-model";
9
+ import { describe, expect, it, vi } from "vitest";
10
+ import type * as RenownModule from "../src/renown.js";
11
+ import { startSwitchboard } from "../src/server.mjs";
12
+
13
+ vi.mock("../src/renown.js", async (importOriginal) => ({
14
+ ...(await importOriginal<typeof RenownModule>()),
15
+ initRenown: vi.fn().mockRejectedValue(new Error("Renown is unreachable")),
16
+ }));
17
+
18
+ function stubLogger(): ILogger & { warn: ReturnType<typeof vi.fn> } {
19
+ const logger = {
20
+ verbose: vi.fn(),
21
+ debug: vi.fn(),
22
+ info: vi.fn(),
23
+ warn: vi.fn(),
24
+ error: vi.fn(),
25
+ child: vi.fn(),
26
+ };
27
+ logger.child.mockReturnValue(logger);
28
+ return logger as unknown as ILogger & { warn: ReturnType<typeof vi.fn> };
29
+ }
30
+
31
+ describe("booting Switchboard without Renown", () => {
32
+ it("warns and creates legacy documents, which it can write unsigned", async () => {
33
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-no-renown-"));
34
+ const previous = process.env.PH_REACTOR_DATABASE_URL;
35
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
36
+ const logger = stubLogger();
37
+ let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
38
+
39
+ try {
40
+ switchboard = await startSwitchboard({
41
+ workflows: { enabled: false },
42
+ dbPath: join(tempRoot, "read-model"),
43
+ port: 0,
44
+ mcp: false,
45
+ disableLocalPackages: true,
46
+ logger,
47
+ });
48
+
49
+ expect(
50
+ logger.warn.mock.calls.some(
51
+ ([message]) =>
52
+ typeof message === "string" &&
53
+ message.includes("new documents are created legacy"),
54
+ ),
55
+ ).toBe(true);
56
+
57
+ const document = await switchboard.reactor.createEmpty(
58
+ "powerhouse/document-model",
59
+ );
60
+ expect(signaturePolicyOf(document.header)).toBe("legacy");
61
+ expect(isDerivedDocumentId(document.header.id)).toBe(false);
62
+
63
+ const renamed = await switchboard.reactor.rename(
64
+ document.header.id,
65
+ "Renamed",
66
+ );
67
+ expect(renamed.header.name).toBe("Renamed");
68
+
69
+ await switchboard.shutdown();
70
+ switchboard = undefined;
71
+ } finally {
72
+ await switchboard?.shutdown();
73
+ if (previous === undefined) {
74
+ delete process.env.PH_REACTOR_DATABASE_URL;
75
+ } else {
76
+ process.env.PH_REACTOR_DATABASE_URL = previous;
77
+ }
78
+ await rm(tempRoot, { recursive: true, force: true });
79
+ }
80
+ }, 60_000);
81
+ });