@powerhousedao/switchboard 6.2.3-dev.9 → 6.2.3

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 (108) 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/url-signer.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  7. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  8. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  19. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  20. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  21. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  24. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  25. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  26. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  27. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  28. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  29. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  30. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  31. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  32. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  33. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  34. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  35. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  36. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  37. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  38. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  39. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  40. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  41. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  42. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  43. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  44. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  45. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  46. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  48. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  49. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  50. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  51. package/.tsbuild/vitest.config.d.ts.map +1 -1
  52. package/Auth.md +94 -0
  53. package/CHANGELOG.md +302 -0
  54. package/README.md +35 -0
  55. package/dist/index.d.mts +2 -1
  56. package/dist/index.mjs +10 -8
  57. package/dist/index.mjs.map +1 -1
  58. package/dist/install-packages.d.mts +2 -1
  59. package/dist/install-packages.mjs +2 -2
  60. package/dist/install-packages.mjs.map +1 -1
  61. package/dist/migrate.d.mts +2 -1
  62. package/dist/migrate.mjs +4 -3
  63. package/dist/migrate.mjs.map +1 -1
  64. package/dist/{server-CiKkVE6g.mjs → server-sQoWp21G.mjs} +1454 -70
  65. package/dist/server-sQoWp21G.mjs.map +1 -0
  66. package/dist/server.d.mts +103 -29
  67. package/dist/server.d.mts.map +1 -1
  68. package/dist/server.mjs +4 -5
  69. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  70. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  71. package/dist/utils.d.mts +4 -5
  72. package/dist/utils.d.mts.map +1 -1
  73. package/dist/utils.mjs +3 -3
  74. package/package.json +21 -17
  75. package/test/attachments/auth.test.ts +85 -0
  76. package/test/attachments/byte-grants.test.ts +287 -0
  77. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  78. package/test/attachments/download-target.test.ts +194 -31
  79. package/test/attachments/index.test.ts +34 -3
  80. package/test/attachments/routes-integration.test.ts +59 -4
  81. package/test/attachments/routes.test.ts +165 -23
  82. package/test/attachments/service-config.test.ts +13 -3
  83. package/test/attachments/url-signer.test.ts +105 -0
  84. package/test/boot-create-signature-policy.test.ts +108 -0
  85. package/test/boot-mcp-enabled.test.ts +121 -0
  86. package/test/boot-unsupported-stored-documents.test.ts +210 -0
  87. package/test/boot-without-renown.test.ts +81 -0
  88. package/test/boot-without-require-signatures.test.ts +74 -0
  89. package/test/boot-worker-trust-policy.test.ts +90 -0
  90. package/test/builder-defaults.test.ts +80 -0
  91. package/test/create-signature-policy.test.ts +69 -0
  92. package/test/fatal-shutdown.test.ts +102 -0
  93. package/test/mcp-flag.test.ts +56 -0
  94. package/test/renown-trust-policy.test.ts +315 -0
  95. package/test/unsupported-stored-documents.test.ts +72 -0
  96. package/test/workflow/harness.ts +289 -0
  97. package/test/workflow/oauth.test.ts +214 -0
  98. package/test/workflow/resolvers.test.ts +296 -0
  99. package/test/workflow/webhook-dedupe.test.ts +295 -0
  100. package/test/workflow/webhook-signed.test.ts +509 -0
  101. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  102. package/test/workflow/webhook-unsigned.test.ts +345 -0
  103. package/test/workflow-read-gate.test.ts +145 -0
  104. package/test/workflow-runtime.test.ts +486 -0
  105. package/tsconfig.json +6 -0
  106. package/tsdown.config.ts +3 -1
  107. package/dist/server-CiKkVE6g.mjs.map +0 -1
  108. 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,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,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
+ });
@@ -0,0 +1,74 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ILogger } from "document-model";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { startSwitchboard } from "../src/server.mjs";
7
+
8
+ function stubLogger(): ILogger & { info: ReturnType<typeof vi.fn> } {
9
+ const logger = {
10
+ verbose: vi.fn(),
11
+ debug: vi.fn(),
12
+ info: vi.fn(),
13
+ warn: vi.fn(),
14
+ error: vi.fn(),
15
+ child: vi.fn(),
16
+ };
17
+ logger.child.mockReturnValue(logger);
18
+ return logger as unknown as ILogger & { info: ReturnType<typeof vi.fn> };
19
+ }
20
+
21
+ describe("booting Switchboard", () => {
22
+ it("has no REQUIRE_SIGNATURES flag, and ignores the variable", async () => {
23
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-signatures-"));
24
+ const previous = {
25
+ reactorDb: process.env.PH_REACTOR_DATABASE_URL,
26
+ requireSignatures: process.env.REQUIRE_SIGNATURES,
27
+ };
28
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
29
+ process.env.REQUIRE_SIGNATURES = "true";
30
+ const logger = stubLogger();
31
+ let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
32
+
33
+ try {
34
+ switchboard = await startSwitchboard({
35
+ workflows: { enabled: false },
36
+ dbPath: join(tempRoot, "read-model"),
37
+ port: 0,
38
+ mcp: false,
39
+ disableLocalPackages: true,
40
+ identity: { keypairPath: join(tempRoot, "identity.json") },
41
+ logger,
42
+ });
43
+
44
+ const flagLog = logger.info.mock.calls.find(
45
+ ([message]) => message === "Feature flags: @flags",
46
+ );
47
+ expect(flagLog).toBeDefined();
48
+ expect(JSON.parse(flagLog![1] as string)).not.toHaveProperty(
49
+ "REQUIRE_SIGNATURES",
50
+ );
51
+
52
+ const created = await switchboard.reactor.createEmpty(
53
+ "powerhouse/document-model",
54
+ );
55
+ expect(created.header.id).toBeTruthy();
56
+
57
+ await switchboard.shutdown();
58
+ switchboard = undefined;
59
+ } finally {
60
+ await switchboard?.shutdown();
61
+ for (const [name, value] of [
62
+ ["PH_REACTOR_DATABASE_URL", previous.reactorDb],
63
+ ["REQUIRE_SIGNATURES", previous.requireSignatures],
64
+ ] as const) {
65
+ if (value === undefined) {
66
+ delete process.env[name];
67
+ } else {
68
+ process.env[name] = value;
69
+ }
70
+ }
71
+ await rm(tempRoot, { recursive: true, force: true });
72
+ }
73
+ }, 60_000);
74
+ });
@@ -0,0 +1,90 @@
1
+ import type { ILogger } from "document-model";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type * as RenownModule from "../src/renown.js";
7
+ import { startSwitchboard } from "../src/server.mjs";
8
+
9
+ vi.mock("../src/renown.js", async (importOriginal) => ({
10
+ ...(await importOriginal<typeof RenownModule>()),
11
+ initRenown: vi.fn().mockRejectedValue(new Error("Renown is unreachable")),
12
+ }));
13
+
14
+ const TRUST_FAILURE =
15
+ /REACTOR_WORKERS[\s\S]*REACTOR_AUTH_ENFORCEMENT[\s\S]*RENOWN_SOURCE/;
16
+ const ENV_KEYS = [
17
+ "PH_REACTOR_DATABASE_URL",
18
+ "RENOWN_SOURCE",
19
+ "REACTOR_DOCUMENT_DECISIONS",
20
+ "REACTOR_AUTH_ENFORCEMENT",
21
+ ] as const;
22
+
23
+ function stubLogger(): ILogger {
24
+ const logger = {
25
+ verbose: vi.fn(),
26
+ debug: vi.fn(),
27
+ info: vi.fn(),
28
+ warn: vi.fn(),
29
+ error: vi.fn(),
30
+ child: vi.fn(),
31
+ };
32
+ logger.child.mockReturnValue(logger);
33
+ return logger as unknown as ILogger;
34
+ }
35
+
36
+ describe("booting with pooled workers under authEnforcement", () => {
37
+ let tempRoot: string;
38
+ let previous: Partial<Record<(typeof ENV_KEYS)[number], string>>;
39
+
40
+ beforeEach(async () => {
41
+ tempRoot = await mkdtemp(join(tmpdir(), "switchboard-worker-trust-"));
42
+ previous = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
43
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
44
+ process.env.REACTOR_DOCUMENT_DECISIONS = "true";
45
+ process.env.REACTOR_AUTH_ENFORCEMENT = "true";
46
+ });
47
+
48
+ afterEach(async () => {
49
+ for (const key of ENV_KEYS) {
50
+ if (previous[key] === undefined) {
51
+ delete process.env[key];
52
+ } else {
53
+ process.env[key] = previous[key];
54
+ }
55
+ }
56
+ await rm(tempRoot, { recursive: true, force: true });
57
+ });
58
+
59
+ function boot(numWorkers: number) {
60
+ return startSwitchboard({
61
+ workflows: { enabled: false },
62
+ dbPath: join(tempRoot, "read-model"),
63
+ port: 0,
64
+ mcp: false,
65
+ disableLocalPackages: true,
66
+ logger: stubLogger(),
67
+ workerPool: { numWorkers },
68
+ });
69
+ }
70
+
71
+ it("fails with a self Renown source", async () => {
72
+ process.env.RENOWN_SOURCE = "self";
73
+
74
+ await expect(boot(2)).rejects.toThrow(TRUST_FAILURE);
75
+ });
76
+
77
+ // Postgres is the next requirement a pooled boot has; reaching it passes the check.
78
+ it("passes the check with a remote Renown source", async () => {
79
+ process.env.RENOWN_SOURCE = "remote";
80
+
81
+ await expect(boot(2)).rejects.toThrow(/requires a Postgres/);
82
+ });
83
+
84
+ // No loaded package serves the renown read model here; reaching that check passes this one.
85
+ it("passes the check without workers", async () => {
86
+ process.env.RENOWN_SOURCE = "self";
87
+
88
+ await expect(boot(0)).rejects.toThrow(/no loaded package serves/);
89
+ }, 60_000);
90
+ });
@@ -0,0 +1,80 @@
1
+ import type { ISigner } from "@powerhousedao/shared/document-model";
2
+ import {
3
+ ReactorBuilder,
4
+ ReactorClientBuilder,
5
+ type FactorySpec,
6
+ } from "@powerhousedao/reactor";
7
+ import type { IRenown } from "@renown/sdk/node";
8
+ import { existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { describe, expect, it, vi } from "vitest";
11
+ import { applySwitchboardReactorDefaults } from "../src/builder-defaults.mjs";
12
+ import {
13
+ getRenownSignerConfig,
14
+ getRenownWorkerSignerSpec,
15
+ } from "../src/renown.js";
16
+
17
+ const USER = { address: "0xswitchboard", networkId: "eip155", chainId: 1 };
18
+
19
+ function stubRenown(user = USER): IRenown {
20
+ const signer = {
21
+ user,
22
+ app: { name: "switchboard", key: "did:key:zSwitchboard" },
23
+ } as unknown as ISigner;
24
+ return { signer } as unknown as IRenown;
25
+ }
26
+
27
+ describe("applySwitchboardReactorDefaults", () => {
28
+ it("gives the reactor the signer and its worker spec", () => {
29
+ const reactorBuilder = new ReactorBuilder();
30
+ const clientBuilder = new ReactorClientBuilder();
31
+ const reactorWithSigner = vi.spyOn(reactorBuilder, "withSigner");
32
+ const clientWithSigner = vi.spyOn(clientBuilder, "withSigner");
33
+ const signer = getRenownSignerConfig(stubRenown(), ".ph/key.json");
34
+
35
+ applySwitchboardReactorDefaults(reactorBuilder, clientBuilder, {
36
+ includeBaseModels: false,
37
+ signalHandlers: false,
38
+ signer,
39
+ });
40
+
41
+ expect(clientWithSigner).toHaveBeenCalledWith(signer);
42
+ expect(reactorWithSigner).toHaveBeenCalledWith(
43
+ signer.signer,
44
+ signer.workerSigner,
45
+ );
46
+ expect(reactorBuilder.hasSigner()).toBe(true);
47
+ });
48
+ });
49
+
50
+ describe("getRenownWorkerSignerSpec", () => {
51
+ it("points workers at createNodeRenownSigner over the stored key", () => {
52
+ const spec: FactorySpec = getRenownWorkerSignerSpec(
53
+ stubRenown(),
54
+ ".ph/key.json",
55
+ );
56
+
57
+ expect(spec.module.exportName).toBe("createNodeRenownSigner");
58
+ const filePath = "filePath" in spec.module ? spec.module.filePath : "";
59
+ expect(existsSync(filePath)).toBe(true);
60
+ expect(spec.initArgs).toEqual({
61
+ appName: "switchboard",
62
+ keypairPath: resolve(".ph/key.json"),
63
+ did: "did:key:zSwitchboard",
64
+ user: USER,
65
+ });
66
+ });
67
+
68
+ it("leaves the user out when none is logged in", () => {
69
+ const renown = stubRenown();
70
+ (renown.signer as { user?: unknown }).user = undefined;
71
+
72
+ const spec = getRenownWorkerSignerSpec(renown);
73
+
74
+ expect(spec.initArgs).toEqual({
75
+ appName: "switchboard",
76
+ keypairPath: resolve(".ph/.keypair.json"),
77
+ did: "did:key:zSwitchboard",
78
+ });
79
+ });
80
+ });