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

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 (143) 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/boot-workflow-singleton-conflict.test.d.ts +2 -0
  24. package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts.map +1 -0
  25. package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts +2 -0
  26. package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts.map +1 -0
  27. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  28. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  29. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  30. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  31. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
  32. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
  33. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  34. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  35. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  36. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  37. package/.tsbuild/test/observability-signals.test.d.ts +2 -0
  38. package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
  39. package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
  40. package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
  41. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  42. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  43. package/.tsbuild/test/snapshot-writer.d.ts +18 -0
  44. package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
  45. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  46. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  48. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  49. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  50. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  51. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  52. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  53. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  54. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  55. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  56. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  57. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  58. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  59. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  60. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  61. package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
  62. package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
  63. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
  64. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
  65. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  66. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  67. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  68. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  69. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  70. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  71. package/.tsbuild/vitest.config.d.ts.map +1 -1
  72. package/Auth.md +94 -0
  73. package/CHANGELOG.md +541 -0
  74. package/README.md +35 -0
  75. package/dist/index.d.mts +2 -1
  76. package/dist/index.mjs +48 -49
  77. package/dist/index.mjs.map +1 -1
  78. package/dist/install-packages.d.mts +2 -1
  79. package/dist/install-packages.mjs +2 -2
  80. package/dist/install-packages.mjs.map +1 -1
  81. package/dist/migrate.d.mts +2 -1
  82. package/dist/migrate.mjs +4 -3
  83. package/dist/migrate.mjs.map +1 -1
  84. package/dist/server-J9fpd42v.mjs +3549 -0
  85. package/dist/server-J9fpd42v.mjs.map +1 -0
  86. package/dist/server.d.mts +155 -30
  87. package/dist/server.d.mts.map +1 -1
  88. package/dist/server.mjs +4 -5
  89. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  90. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  91. package/dist/utils.d.mts +4 -5
  92. package/dist/utils.d.mts.map +1 -1
  93. package/dist/utils.mjs +3 -3
  94. package/package.json +22 -18
  95. package/test/attachment-reference-read-model.test.ts +6 -1
  96. package/test/attachments/auth.test.ts +85 -0
  97. package/test/attachments/byte-grants.test.ts +290 -0
  98. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  99. package/test/attachments/download-target.test.ts +201 -32
  100. package/test/attachments/index.test.ts +34 -3
  101. package/test/attachments/reserve-metadata-parity.test.ts +96 -0
  102. package/test/attachments/routes-integration.test.ts +60 -4
  103. package/test/attachments/routes.test.ts +166 -23
  104. package/test/attachments/service-config.test.ts +13 -3
  105. package/test/attachments/url-signer.test.ts +105 -0
  106. package/test/boot-create-signature-policy.test.ts +108 -0
  107. package/test/boot-mcp-enabled.test.ts +121 -0
  108. package/test/boot-privacy.test.ts +243 -0
  109. package/test/boot-unsupported-stored-documents.test.ts +213 -0
  110. package/test/boot-without-renown.test.ts +81 -0
  111. package/test/boot-without-require-signatures.test.ts +74 -0
  112. package/test/boot-worker-trust-policy.test.ts +90 -0
  113. package/test/boot-workflow-singleton-conflict.test.ts +107 -0
  114. package/test/boot-workflow-singleton-retry.test.ts +133 -0
  115. package/test/builder-defaults.test.ts +80 -0
  116. package/test/create-signature-policy.test.ts +69 -0
  117. package/test/durable-node-fs-pg16.test.ts +80 -0
  118. package/test/fatal-shutdown.test.ts +165 -0
  119. package/test/mcp-flag.test.ts +56 -0
  120. package/test/observability-signals.test.ts +131 -0
  121. package/test/pglite-dialect.test.ts +217 -2
  122. package/test/pglite-preflight.test.ts +313 -0
  123. package/test/pglite-version.test.ts +9 -1
  124. package/test/renown-trust-policy.test.ts +315 -0
  125. package/test/snapshot-writer.ts +94 -0
  126. package/test/unsupported-stored-documents.test.ts +72 -0
  127. package/test/worker-pool.test.ts +27 -1
  128. package/test/workflow/harness.ts +289 -0
  129. package/test/workflow/oauth.test.ts +214 -0
  130. package/test/workflow/resolvers.test.ts +357 -0
  131. package/test/workflow/webhook-dedupe.test.ts +295 -0
  132. package/test/workflow/webhook-signed.test.ts +509 -0
  133. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  134. package/test/workflow/webhook-unsigned.test.ts +345 -0
  135. package/test/workflow-reactor-access.test.ts +384 -0
  136. package/test/workflow-reactor-enforcement.test.ts +808 -0
  137. package/test/workflow-read-gate.test.ts +213 -0
  138. package/test/workflow-runtime.test.ts +1067 -0
  139. package/tsconfig.json +9 -3
  140. package/tsdown.config.ts +3 -1
  141. package/dist/server-CiKkVE6g.mjs +0 -1555
  142. package/dist/server-CiKkVE6g.mjs.map +0 -1
  143. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -0,0 +1,313 @@
1
+ import { createDurableNodeFs } from "@powerhousedao/reactor-api/pglite-node";
2
+ import type { ILogger } from "document-model";
3
+ import { promises as fs } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import {
7
+ afterAll,
8
+ afterEach,
9
+ beforeAll,
10
+ beforeEach,
11
+ describe,
12
+ expect,
13
+ it,
14
+ vi,
15
+ } from "vitest";
16
+ import { runPglitePreflight } from "../src/pglite-preflight.js";
17
+ import { loadNodeFsClass, loadPGliteModule } from "../src/pglite-version.js";
18
+ import { writeSnapshotFromDir } from "./snapshot-writer.js";
19
+
20
+ // PGlite initdb and pg_dump are several times slower on the Windows runner.
21
+ const BOOT_TIMEOUT = 120_000;
22
+ const RM = { recursive: true, force: true, maxRetries: 10 } as const;
23
+ const ROWS = 10;
24
+ const CONVERTED_LINE =
25
+ /^Converted PGlite snapshot at (.+): snapshot (\d+) bytes -> (\d+) bytes on disk, (\d+) entries, (\d+) ms$/;
26
+
27
+ type StubLogger = ILogger & {
28
+ debug: ReturnType<typeof vi.fn>;
29
+ info: ReturnType<typeof vi.fn>;
30
+ warn: ReturnType<typeof vi.fn>;
31
+ };
32
+
33
+ function stubLogger(): StubLogger {
34
+ const logger = {
35
+ verbose: vi.fn(),
36
+ debug: vi.fn(),
37
+ info: vi.fn(),
38
+ warn: vi.fn(),
39
+ error: vi.fn(),
40
+ child: vi.fn(),
41
+ };
42
+ logger.child.mockReturnValue(logger);
43
+ return logger as unknown as StubLogger;
44
+ }
45
+
46
+ async function exists(p: string): Promise<boolean> {
47
+ try {
48
+ await fs.lstat(p);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function pgVersion(dir: string): Promise<string> {
56
+ return (await fs.readFile(path.join(dir, "PG_VERSION"), "utf8")).trim();
57
+ }
58
+
59
+ /** Opens `dir` the way the server does and counts the seeded rows. */
60
+ async function countRows(dir: string, major: 16 | 17): Promise<number> {
61
+ const [{ PGlite: Pg }, Base] = await Promise.all([
62
+ loadPGliteModule(major),
63
+ loadNodeFsClass(major),
64
+ ]);
65
+ const pg = new Pg({
66
+ fs: createDurableNodeFs(Base, dir, {
67
+ fsync: false,
68
+ maintenanceIntervalMs: 0,
69
+ }),
70
+ });
71
+ try {
72
+ await pg.waitReady;
73
+ const { rows } = await pg.query<{ n: number }>(
74
+ 'SELECT count(*)::int AS n FROM "Rows"',
75
+ );
76
+ return rows[0].n;
77
+ } finally {
78
+ await pg.close();
79
+ }
80
+ }
81
+
82
+ function convertedLines(logger: StubLogger): string[] {
83
+ return logger.info.mock.calls
84
+ .map((call) => String(call[0]))
85
+ .filter((line) => CONVERTED_LINE.test(line));
86
+ }
87
+
88
+ describe("runPglitePreflight", () => {
89
+ // A pure-snapshot PG16 store: snapshot.bin and nothing else, the layout
90
+ // AtomicNodeFs left for a store it created itself.
91
+ let template: string;
92
+ let root: string;
93
+ let logger: StubLogger;
94
+
95
+ beforeAll(async () => {
96
+ template = await fs.mkdtemp(path.join(os.tmpdir(), "preflight-template-"));
97
+ const loose = path.join(template, "loose");
98
+ const { PGlite: LegacyPGlite } = await loadPGliteModule(16);
99
+ const LegacyNodeFS = await loadNodeFsClass(16);
100
+ const pg = new LegacyPGlite({ fs: new LegacyNodeFS(loose) });
101
+ await pg.waitReady;
102
+ await pg.exec('CREATE TABLE "Rows" (id int PRIMARY KEY, payload text)');
103
+ await pg.exec(
104
+ `INSERT INTO "Rows" SELECT g, repeat('x', 100) FROM generate_series(1, ${ROWS}) g`,
105
+ );
106
+ await pg.close();
107
+ await fs.rm(path.join(loose, "postmaster.pid"), { force: true });
108
+ expect(await pgVersion(loose)).toBe("16");
109
+
110
+ const store = path.join(template, "store");
111
+ await fs.mkdir(store);
112
+ await writeSnapshotFromDir(loose, path.join(store, "snapshot.bin"));
113
+ await fs.rm(loose, RM);
114
+ }, BOOT_TIMEOUT);
115
+
116
+ afterAll(async () => {
117
+ await fs.rm(template, RM);
118
+ }, BOOT_TIMEOUT);
119
+
120
+ beforeEach(async () => {
121
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "preflight-"));
122
+ logger = stubLogger();
123
+ });
124
+
125
+ afterEach(async () => {
126
+ await fs.rm(root, RM);
127
+ }, BOOT_TIMEOUT);
128
+
129
+ async function snapshotDir(name: string): Promise<string> {
130
+ const dir = path.join(root, name);
131
+ await fs.cp(path.join(template, "store"), dir, { recursive: true });
132
+ return dir;
133
+ }
134
+
135
+ async function looseDir(name: string, major = 17): Promise<string> {
136
+ const dir = path.join(root, name);
137
+ await fs.mkdir(path.join(dir, "pg_wal"), { recursive: true });
138
+ await fs.writeFile(path.join(dir, "PG_VERSION"), `${major}\n`);
139
+ return dir;
140
+ }
141
+
142
+ it(
143
+ "converts before detecting the major, so a PG16 snapshot is migrated",
144
+ async () => {
145
+ const dir = await snapshotDir("reactor-storage");
146
+
147
+ const detected = await runPglitePreflight({
148
+ dirs: [dir],
149
+ migratePglite: true,
150
+ inMemory: false,
151
+ logger,
152
+ });
153
+
154
+ expect(detected.get(dir)).toBe(17);
155
+ expect(await pgVersion(dir)).toBe("17");
156
+ expect(await exists(path.join(dir, "snapshot.bin"))).toBe(false);
157
+ expect(await exists(`${dir}.converting`)).toBe(false);
158
+ expect(await exists(`${dir}.old`)).toBe(false);
159
+ expect(convertedLines(logger)).toHaveLength(1);
160
+ expect(logger.info).toHaveBeenCalledWith(
161
+ expect.stringContaining("Migrating"),
162
+ );
163
+ expect(await countRows(dir, 17)).toBe(ROWS);
164
+ },
165
+ BOOT_TIMEOUT,
166
+ );
167
+
168
+ // Pins the order alone: before this change a pure-snapshot dir had no loose
169
+ // PG_VERSION and the migration logged "No PG_VERSION; skipping". The case
170
+ // above also needs the legacy pg_dump to work.
171
+ it(
172
+ "attempts the migration of a PG16 snapshot instead of skipping it",
173
+ async () => {
174
+ const dir = await snapshotDir("reactor-storage");
175
+
176
+ await runPglitePreflight({
177
+ dirs: [dir],
178
+ migratePglite: true,
179
+ inMemory: false,
180
+ logger,
181
+ }).catch(() => undefined);
182
+
183
+ expect(convertedLines(logger)).toHaveLength(1);
184
+ expect(logger.info).not.toHaveBeenCalledWith(
185
+ expect.stringContaining("No PG_VERSION"),
186
+ );
187
+ expect(logger.info).toHaveBeenCalledWith(
188
+ expect.stringContaining(`Migrating ${dir} from PG16 to PG17`),
189
+ );
190
+ },
191
+ BOOT_TIMEOUT,
192
+ );
193
+
194
+ it(
195
+ "boots a converted dir on the second start without converting",
196
+ async () => {
197
+ const dir = await snapshotDir("reactor-storage");
198
+ const first = { dirs: [dir], inMemory: false, logger };
199
+ await runPglitePreflight(first);
200
+ expect(convertedLines(logger)).toHaveLength(1);
201
+ expect(await pgVersion(dir)).toBe("16");
202
+
203
+ const second = stubLogger();
204
+ const detected = await runPglitePreflight({ ...first, logger: second });
205
+
206
+ expect(detected.get(dir)).toBe(16);
207
+ expect(convertedLines(second)).toHaveLength(0);
208
+ expect(await exists(`${dir}.converting`)).toBe(false);
209
+ expect(await exists(`${dir}.old`)).toBe(false);
210
+ expect(await countRows(dir, 16)).toBe(ROWS);
211
+ },
212
+ BOOT_TIMEOUT,
213
+ );
214
+
215
+ it("removes pg_wal/xlogtemp.*", async () => {
216
+ const dir = await looseDir("read-storage");
217
+ const segment = path.join(dir, "pg_wal", "000000010000000000000001");
218
+ await fs.writeFile(segment, "wal");
219
+ await fs.writeFile(path.join(dir, "pg_wal", "xlogtemp.42"), "");
220
+ await fs.writeFile(path.join(dir, "pg_wal", "xlogtemp.7"), "");
221
+
222
+ const detected = await runPglitePreflight({
223
+ dirs: [dir],
224
+ inMemory: false,
225
+ logger,
226
+ });
227
+
228
+ expect(detected.get(dir)).toBe(17);
229
+ expect(await fs.readdir(path.join(dir, "pg_wal"))).toEqual([
230
+ "000000010000000000000001",
231
+ ]);
232
+ expect(await exists(segment)).toBe(true);
233
+ });
234
+
235
+ it("removes postmaster.pid without a warn call", async () => {
236
+ const dir = await looseDir("read-storage");
237
+ const lockfile = path.join(dir, "postmaster.pid");
238
+ await fs.writeFile(lockfile, "42\n");
239
+
240
+ await runPglitePreflight({ dirs: [dir], inMemory: false, logger });
241
+
242
+ expect(await exists(lockfile)).toBe(false);
243
+ expect(logger.warn).not.toHaveBeenCalled();
244
+ expect(logger.debug).toHaveBeenCalledWith(
245
+ expect.stringContaining(lockfile),
246
+ );
247
+ });
248
+
249
+ it(
250
+ "logs one info line per converted dir with sizes and duration",
251
+ async () => {
252
+ const reactor = await snapshotDir("reactor-storage");
253
+ const readModel = await snapshotDir("read-storage");
254
+
255
+ await runPglitePreflight({
256
+ dirs: [reactor, readModel],
257
+ inMemory: false,
258
+ logger,
259
+ });
260
+
261
+ const lines = convertedLines(logger);
262
+ expect(lines).toHaveLength(2);
263
+ const byDir = new Map(
264
+ lines.map((line) => {
265
+ const match = CONVERTED_LINE.exec(line)!;
266
+ return [match[1], match.slice(2).map(Number)] as const;
267
+ }),
268
+ );
269
+ for (const dir of [reactor, readModel]) {
270
+ const [snapshotBytes, convertedBytes, entries] = byDir.get(dir)!;
271
+ expect(snapshotBytes).toBeGreaterThan(0);
272
+ expect(convertedBytes).toBeGreaterThan(0);
273
+ expect(entries).toBeGreaterThan(0);
274
+ }
275
+ },
276
+ BOOT_TIMEOUT,
277
+ );
278
+
279
+ it("touches nothing in memory", async () => {
280
+ const dir = await snapshotDir("reactor-storage");
281
+
282
+ const detected = await runPglitePreflight({
283
+ dirs: [dir],
284
+ inMemory: true,
285
+ logger,
286
+ });
287
+
288
+ expect(detected.size).toBe(0);
289
+ expect(await exists(path.join(dir, "snapshot.bin"))).toBe(true);
290
+ });
291
+
292
+ it("wipes the dirs and their conversion siblings under forcePgVersion", async () => {
293
+ const dir = await snapshotDir("reactor-storage");
294
+ await fs.mkdir(`${dir}.old`);
295
+ await fs.mkdir(`${dir}.converting`);
296
+
297
+ const detected = await runPglitePreflight({
298
+ dirs: [dir],
299
+ forcePgVersion: 17,
300
+ migratePglite: true,
301
+ inMemory: false,
302
+ logger,
303
+ });
304
+
305
+ expect(detected.size).toBe(0);
306
+ expect(await exists(dir)).toBe(false);
307
+ expect(await exists(`${dir}.old`)).toBe(false);
308
+ expect(await exists(`${dir}.converting`)).toBe(false);
309
+ expect(logger.warn).toHaveBeenCalledWith(
310
+ expect.stringContaining("ignoring --migrate-pglite"),
311
+ );
312
+ });
313
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { parseForcePgVersion } from "../src/pglite-version.js";
2
+ import { openForVerify, parseForcePgVersion } from "../src/pglite-version.js";
3
3
 
4
4
  describe("parseForcePgVersion", () => {
5
5
  it("returns null for undefined", () => {
@@ -35,3 +35,11 @@ describe("parseForcePgVersion", () => {
35
35
  expect(() => parseForcePgVersion("16.5")).toThrow(/PH_FORCE_PG_VERSION/);
36
36
  });
37
37
  });
38
+
39
+ describe("openForVerify", () => {
40
+ it("throws on an unsupported major before touching disk", async () => {
41
+ await expect(openForVerify(15, "/nonexistent/pglite-dir")).rejects.toThrow(
42
+ /PG_VERSION=15 is not supported/,
43
+ );
44
+ });
45
+ });
@@ -0,0 +1,315 @@
1
+ import type {
2
+ ActionSigner,
3
+ ISigner,
4
+ } from "@powerhousedao/shared/document-model";
5
+ import {
6
+ ReactorBuilder,
7
+ ReactorClientBuilder,
8
+ type SignatureTrustPolicy,
9
+ } from "@powerhousedao/reactor";
10
+ import type { IRenown } from "@renown/sdk/node";
11
+ import { existsSync } from "node:fs";
12
+ import { pathToFileURL } from "node:url";
13
+ import { afterEach, describe, expect, it, vi } from "vitest";
14
+ import { applySwitchboardReactorDefaults } from "../src/builder-defaults.mjs";
15
+ import {
16
+ assertWorkerTrustPolicy,
17
+ getRenownTrustPolicyConfig,
18
+ } from "../src/renown.js";
19
+
20
+ const WALLET = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
21
+ const CONNECT_KEY = "did:key:zDnaeConnectKey";
22
+ const SWITCHBOARD_KEY = "did:key:zDnaeSwitchboardKey";
23
+ const SWITCHBOARD_USER = {
24
+ address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
25
+ networkId: "eip155",
26
+ chainId: 1,
27
+ };
28
+ const GRAPHQL = "http://renown.test/graphql";
29
+
30
+ // Signed by WALLET's Anvil dev key; its expiry is ignored by the policy.
31
+ const CREDENTIAL_ROW = {
32
+ documentId: "doc-cred",
33
+ credentialId: "urn:uuid:c7882913-68ef-48e5-9e89-f2948d2ca22c",
34
+ context: ["https://www.w3.org/2018/credentials/v1"],
35
+ type: ["VerifiableCredential", "RenownCredential"],
36
+ issuerId: "did:pkh:eip155:1:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
37
+ issuerEthereumAddress: WALLET,
38
+ issuanceDate: "2026-09-24T00:48:25.566Z",
39
+ expirationDate: "2026-10-01T00:48:25.566Z",
40
+ credentialSubjectId: CONNECT_KEY,
41
+ credentialSubjectApp: "connect",
42
+ credentialStatusId: null,
43
+ credentialStatusType: null,
44
+ credentialSchemaId: "https://renown.id/schemas/renown-credential/v1",
45
+ credentialSchemaType: "JsonSchemaValidator2018",
46
+ proofVerificationMethod:
47
+ "did:pkh:eip155:1:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
48
+ proofEthereumAddress: WALLET,
49
+ proofCreated: "2026-09-24T00:48:25.566Z",
50
+ proofPurpose: "assertionMethod",
51
+ proofType: "EthereumEip712Signature2021",
52
+ proofValue:
53
+ "0xe8d7a2468b9eb21286483fd88601d350ac7a8ed908506e51a34c6b119f93a7d75c6284a3f231698f6fcc8a3ad0ec5096a34811c7b6adef2d1e4146ddb22826a91c",
54
+ proofEip712Domain: JSON.stringify({ version: "1", chainId: 1 }),
55
+ proofEip712PrimaryType: "VerifiableCredential",
56
+ revoked: false,
57
+ };
58
+
59
+ function stubRenown(user: ActionSigner["user"] = SWITCHBOARD_USER): IRenown {
60
+ const signer = {
61
+ user,
62
+ app: { name: "switchboard", key: SWITCHBOARD_KEY },
63
+ } as unknown as ISigner;
64
+ return { signer } as unknown as IRenown;
65
+ }
66
+
67
+ function claim(address: string, key: string): ActionSigner {
68
+ return {
69
+ user: { address, networkId: "eip155", chainId: 1 },
70
+ app: { name: "connect", key },
71
+ signatures: [],
72
+ };
73
+ }
74
+
75
+ /** Answers the renown read model with `rows`; counts the requests. */
76
+ function mockReadModel(rows: unknown[] = [CREDENTIAL_ROW]) {
77
+ return vi.spyOn(globalThis, "fetch").mockImplementation(() =>
78
+ Promise.resolve(
79
+ new Response(JSON.stringify({ data: { renownCredentials: rows } }), {
80
+ status: 200,
81
+ }),
82
+ ),
83
+ );
84
+ }
85
+
86
+ async function remotePolicy(
87
+ renown = stubRenown(),
88
+ ): Promise<SignatureTrustPolicy> {
89
+ const { trustPolicy } = await getRenownTrustPolicyConfig(
90
+ { source: "remote", switchboardUrl: GRAPHQL },
91
+ renown,
92
+ );
93
+ return trustPolicy!;
94
+ }
95
+
96
+ describe("getRenownTrustPolicyConfig", () => {
97
+ afterEach(() => vi.restoreAllMocks());
98
+
99
+ it("accepts a key a credential binds to the claimed wallet", async () => {
100
+ const fetchMock = mockReadModel();
101
+ const policy = await remotePolicy();
102
+
103
+ await expect(
104
+ policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
105
+ ).resolves.toBe(true);
106
+ expect(fetchMock.mock.calls[0][0]).toBe(GRAPHQL);
107
+ });
108
+
109
+ it("refuses a mismatched address or key", async () => {
110
+ mockReadModel();
111
+ const policy = await remotePolicy();
112
+
113
+ await expect(
114
+ policy.authorizeSigner(
115
+ claim(SWITCHBOARD_USER.address, CONNECT_KEY),
116
+ CONNECT_KEY,
117
+ "doc",
118
+ ),
119
+ ).resolves.toBe(false);
120
+ await expect(
121
+ policy.authorizeSigner(
122
+ claim(WALLET, "did:key:zDnaeOther"),
123
+ "did:key:zDnaeOther",
124
+ "doc",
125
+ ),
126
+ ).resolves.toBe(false);
127
+ });
128
+
129
+ it("refuses a key no credential binds yet", async () => {
130
+ const fetchMock = mockReadModel([]);
131
+ const policy = await remotePolicy();
132
+
133
+ await expect(
134
+ policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
135
+ ).resolves.toBe(false);
136
+ await expect(
137
+ policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
138
+ ).resolves.toBe(false);
139
+ expect(fetchMock).toHaveBeenCalledTimes(1);
140
+ });
141
+
142
+ it("caches an acceptance per (address, key)", async () => {
143
+ const fetchMock = mockReadModel();
144
+ const policy = await remotePolicy();
145
+ const signer = claim(WALLET, CONNECT_KEY);
146
+
147
+ await policy.authorizeSigner(signer, CONNECT_KEY, "doc-1");
148
+ fetchMock.mockRejectedValue(new TypeError("offline"));
149
+
150
+ await expect(
151
+ policy.authorizeSigner(signer, CONNECT_KEY, "doc-2"),
152
+ ).resolves.toBe(true);
153
+ expect(fetchMock).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ it("throws on a network failure rather than refusing", async () => {
157
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("offline"));
158
+ const policy = await remotePolicy();
159
+
160
+ await expect(
161
+ policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
162
+ ).rejects.toThrow("offline");
163
+ });
164
+
165
+ it("accepts the switchboard's own key for its own user without a lookup", async () => {
166
+ const fetchMock = mockReadModel([]);
167
+ const policy = await remotePolicy();
168
+
169
+ await expect(
170
+ policy.authorizeSigner(
171
+ claim(SWITCHBOARD_USER.address, SWITCHBOARD_KEY),
172
+ SWITCHBOARD_KEY,
173
+ "doc",
174
+ ),
175
+ ).resolves.toBe(true);
176
+ expect(fetchMock).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it("matches the switchboard's own key against its current user", async () => {
180
+ const fetchMock = mockReadModel([]);
181
+ const renown = stubRenown();
182
+ const policy = await remotePolicy(renown);
183
+
184
+ renown.signer.user = { address: WALLET, networkId: "eip155", chainId: 1 };
185
+
186
+ await expect(
187
+ policy.authorizeSigner(
188
+ claim(WALLET, SWITCHBOARD_KEY),
189
+ SWITCHBOARD_KEY,
190
+ "doc",
191
+ ),
192
+ ).resolves.toBe(true);
193
+ expect(fetchMock).not.toHaveBeenCalled();
194
+ await expect(
195
+ policy.authorizeSigner(
196
+ claim(SWITCHBOARD_USER.address, SWITCHBOARD_KEY),
197
+ SWITCHBOARD_KEY,
198
+ "doc",
199
+ ),
200
+ ).resolves.toBe(false);
201
+ });
202
+
203
+ it("gives pooled workers a spec that builds the same policy", async () => {
204
+ const fetchMock = mockReadModel();
205
+ const { workerTrustPolicy } = await getRenownTrustPolicyConfig(
206
+ { source: "remote", switchboardUrl: GRAPHQL },
207
+ stubRenown(),
208
+ );
209
+
210
+ const module = workerTrustPolicy!.module;
211
+ expect(module.exportName).toBe("createRenownTrustPolicy");
212
+ const filePath = "filePath" in module ? module.filePath : "";
213
+ expect(existsSync(filePath)).toBe(true);
214
+ expect(workerTrustPolicy!.initArgs).toEqual({
215
+ switchboard: GRAPHQL,
216
+ self: { key: SWITCHBOARD_KEY, user: SWITCHBOARD_USER },
217
+ });
218
+
219
+ const imported = (await import(pathToFileURL(filePath).href)) as Record<
220
+ string,
221
+ (args: unknown) => SignatureTrustPolicy
222
+ >;
223
+ const policy = imported[module.exportName](workerTrustPolicy!.initArgs);
224
+ await expect(
225
+ policy.authorizeSigner(claim(WALLET, CONNECT_KEY), CONNECT_KEY, "doc"),
226
+ ).resolves.toBe(true);
227
+ expect(fetchMock).toHaveBeenCalledTimes(1);
228
+ });
229
+
230
+ it("reads a self source in process and gives workers no spec", async () => {
231
+ const request = vi.fn(() =>
232
+ Promise.resolve({ renownCredentials: [CREDENTIAL_ROW] }),
233
+ );
234
+ const config = await getRenownTrustPolicyConfig(
235
+ { source: "self", request },
236
+ null,
237
+ );
238
+
239
+ expect(config.workerTrustPolicy).toBeUndefined();
240
+ await expect(
241
+ config.trustPolicy!.authorizeSigner(
242
+ claim(WALLET, CONNECT_KEY),
243
+ CONNECT_KEY,
244
+ "doc",
245
+ ),
246
+ ).resolves.toBe(true);
247
+ expect(request).toHaveBeenCalledTimes(1);
248
+ });
249
+ });
250
+
251
+ describe("assertWorkerTrustPolicy", () => {
252
+ it("fails with workers, authEnforcement and a self source", () => {
253
+ expect(() =>
254
+ assertWorkerTrustPolicy({
255
+ workers: 2,
256
+ authEnforcement: true,
257
+ renownSource: "self",
258
+ }),
259
+ ).toThrow(
260
+ /REACTOR_WORKERS[\s\S]*REACTOR_AUTH_ENFORCEMENT[\s\S]*RENOWN_SOURCE/,
261
+ );
262
+ });
263
+
264
+ it.each([
265
+ { workers: 0, authEnforcement: true, renownSource: "self" as const },
266
+ { workers: 2, authEnforcement: false, renownSource: "self" as const },
267
+ { workers: 2, authEnforcement: true, renownSource: "remote" as const },
268
+ ])("passes %o", (config) => {
269
+ expect(() => assertWorkerTrustPolicy(config)).not.toThrow();
270
+ });
271
+ });
272
+
273
+ describe("applySwitchboardReactorDefaults with a trust policy", () => {
274
+ it("gives the reactor the policy and its worker spec", () => {
275
+ const reactorBuilder = new ReactorBuilder();
276
+ const withTrustPolicy = vi.spyOn(reactorBuilder, "withTrustPolicy");
277
+ const trustPolicy: SignatureTrustPolicy = {
278
+ authorizeSigner: () => Promise.resolve(true),
279
+ };
280
+ const workerTrustPolicy = {
281
+ module: { filePath: "/trust.js", exportName: "createTrustPolicy" },
282
+ };
283
+
284
+ applySwitchboardReactorDefaults(
285
+ reactorBuilder,
286
+ new ReactorClientBuilder(),
287
+ {
288
+ includeBaseModels: false,
289
+ signalHandlers: false,
290
+ trustPolicy: { trustPolicy, workerTrustPolicy },
291
+ },
292
+ );
293
+
294
+ expect(withTrustPolicy).toHaveBeenCalledWith(
295
+ trustPolicy,
296
+ workerTrustPolicy,
297
+ );
298
+ expect(reactorBuilder.hasTrustPolicy()).toBe(true);
299
+ });
300
+
301
+ it("leaves the reactor on the default without one", () => {
302
+ const reactorBuilder = new ReactorBuilder();
303
+
304
+ applySwitchboardReactorDefaults(
305
+ reactorBuilder,
306
+ new ReactorClientBuilder(),
307
+ {
308
+ includeBaseModels: false,
309
+ signalHandlers: false,
310
+ },
311
+ );
312
+
313
+ expect(reactorBuilder.hasTrustPolicy()).toBe(false);
314
+ });
315
+ });