@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,572 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ ReactorBuilder,
7
+ ReactorClientBuilder,
8
+ type AttachmentRef,
9
+ type Database,
10
+ type InProcessReactorClientModule,
11
+ type IReadModelCoordinator,
12
+ } from "@powerhousedao/reactor";
13
+ import {
14
+ BaseSubgraph,
15
+ type CanonicalDocumentId,
16
+ type GraphQLManager,
17
+ type PackagePieceEntry,
18
+ } from "@powerhousedao/reactor-api";
19
+ import { PieceRegistry } from "@powerhousedao/reactor-workflow";
20
+ import {
21
+ createRelationalDb,
22
+ type IRelationalDb,
23
+ type IWebhookScope,
24
+ type WebhookSpec,
25
+ } from "@powerhousedao/shared/processors";
26
+ import type { ILogger } from "document-model";
27
+ import { Kysely } from "kysely";
28
+ import { PGliteDialect } from "kysely-pglite-dialect";
29
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
30
+ import { initFeatureFlags } from "../src/feature-flags.js";
31
+ import { startSwitchboard } from "../src/server.mjs";
32
+ import {
33
+ PH_WORKFLOWS_ENABLED,
34
+ bindPackagePieces,
35
+ attachmentReadPolicy,
36
+ composeWorkflowRuntime,
37
+ assertWorkflowPackageLoadable,
38
+ hostPrincipalOf,
39
+ reactorAccessOf,
40
+ resolveWorkflowsEnabled,
41
+ type BooleanFlagSource,
42
+ type ComposedWorkflowRuntime,
43
+ } from "../src/workflow-runtime.mjs";
44
+
45
+ function stubLogger(): ILogger & {
46
+ info: ReturnType<typeof vi.fn>;
47
+ error: ReturnType<typeof vi.fn>;
48
+ } {
49
+ const logger = {
50
+ verbose: vi.fn(),
51
+ debug: vi.fn(),
52
+ info: vi.fn(),
53
+ warn: vi.fn(),
54
+ error: vi.fn(),
55
+ child: vi.fn(),
56
+ };
57
+ logger.child.mockReturnValue(logger);
58
+ return logger as unknown as ILogger & {
59
+ info: ReturnType<typeof vi.fn>;
60
+ error: ReturnType<typeof vi.fn>;
61
+ };
62
+ }
63
+
64
+ describe("resolveWorkflowsEnabled", () => {
65
+ let featureFlags: BooleanFlagSource;
66
+
67
+ beforeAll(async () => {
68
+ featureFlags = await initFeatureFlags();
69
+ });
70
+
71
+ afterEach(() => {
72
+ delete process.env[PH_WORKFLOWS_ENABLED];
73
+ });
74
+
75
+ it("defaults to off", async () => {
76
+ await expect(resolveWorkflowsEnabled({ featureFlags })).resolves.toBe(
77
+ false,
78
+ );
79
+ });
80
+
81
+ it("lets the host's option win over the env var", async () => {
82
+ process.env[PH_WORKFLOWS_ENABLED] = "false";
83
+
84
+ await expect(
85
+ resolveWorkflowsEnabled({ featureFlags, override: true }),
86
+ ).resolves.toBe(true);
87
+
88
+ process.env[PH_WORKFLOWS_ENABLED] = "true";
89
+
90
+ await expect(
91
+ resolveWorkflowsEnabled({ featureFlags, override: false }),
92
+ ).resolves.toBe(false);
93
+ });
94
+
95
+ it("reads the env var in every form reactor-api accepted", async () => {
96
+ for (const [raw, expected] of [
97
+ ["true", true],
98
+ ["1", true],
99
+ ["false", false],
100
+ ["0", false],
101
+ ] as const) {
102
+ process.env[PH_WORKFLOWS_ENABLED] = raw;
103
+ await expect(
104
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: !expected }),
105
+ ).resolves.toBe(expected);
106
+ }
107
+ });
108
+
109
+ it("falls back to the config file, then off", async () => {
110
+ await expect(
111
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: true }),
112
+ ).resolves.toBe(true);
113
+ await expect(
114
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: false }),
115
+ ).resolves.toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("assertWorkflowPackageLoadable", () => {
120
+ it("passes when the package resolves", async () => {
121
+ await expect(
122
+ assertWorkflowPackageLoadable(() => Promise.resolve({})),
123
+ ).resolves.toBeUndefined();
124
+ });
125
+
126
+ it("names the package a host would have to install when the load fails", async () => {
127
+ const cause = new Error("Cannot find module");
128
+ const failing = assertWorkflowPackageLoadable(() => Promise.reject(cause));
129
+ await expect(failing).rejects.toThrow("@powerhousedao/workflow");
130
+ await expect(failing).rejects.toMatchObject({ cause });
131
+ });
132
+ });
133
+
134
+ // The endpoint family as the host's webhook service holds it, in memory.
135
+ function memoryWebhooks() {
136
+ const families: WebhookSpec[] = [];
137
+ const scope: IWebhookScope = {
138
+ hasPublicOrigin: true,
139
+ register: (spec) => {
140
+ families.push(spec);
141
+ return Promise.resolve({
142
+ endpointFor: (key) =>
143
+ Promise.resolve({
144
+ token: key,
145
+ url: `https://hooks.test/${key}`,
146
+ createdAt: new Date().toISOString(),
147
+ }),
148
+ revoke: () => Promise.resolve(),
149
+ list: () => Promise.resolve([]),
150
+ });
151
+ },
152
+ };
153
+ return { families, scope };
154
+ }
155
+
156
+ describe("composeWorkflowRuntime", () => {
157
+ let databases: Kysely<unknown>[] = [];
158
+ let module: InProcessReactorClientModule | undefined;
159
+ let composed: ComposedWorkflowRuntime | undefined;
160
+
161
+ afterEach(async () => {
162
+ await composed?.stop();
163
+ await module?.reactor.kill().completed;
164
+ for (const database of databases) await database.destroy();
165
+ composed = undefined;
166
+ module = undefined;
167
+ databases = [];
168
+ });
169
+
170
+ function pglite(): Kysely<unknown> {
171
+ const database = new Kysely<unknown>({
172
+ dialect: new PGliteDialect(new PGlite()),
173
+ });
174
+ databases.push(database);
175
+ return database;
176
+ }
177
+
178
+ async function buildReactorModule(
179
+ coordinator?: IReadModelCoordinator,
180
+ ): Promise<InProcessReactorClientModule> {
181
+ const builder = new ReactorBuilder().withKysely(
182
+ pglite() as unknown as Kysely<Database>,
183
+ );
184
+ if (coordinator) builder.withReadModelCoordinator(coordinator);
185
+ module = await new ReactorClientBuilder()
186
+ .withReactorBuilder(builder)
187
+ .buildModule();
188
+ return module;
189
+ }
190
+
191
+ // The real engine, loaded as production loads it.
192
+ async function compose(
193
+ clientModule: InProcessReactorClientModule,
194
+ overrides: Partial<Parameters<typeof composeWorkflowRuntime>[0]> = {},
195
+ ): Promise<ComposedWorkflowRuntime> {
196
+ composed = await composeWorkflowRuntime({
197
+ reactorClient: clientModule.client,
198
+ clientModule,
199
+ relationalDb: createRelationalDb(pglite()) as IRelationalDb,
200
+ attachments: {} as never,
201
+ authorizationService: {} as never,
202
+ logger: stubLogger(),
203
+ ...overrides,
204
+ });
205
+ return composed;
206
+ }
207
+
208
+ it("lets a step read a ref only through a document its user may read", async () => {
209
+ const ref = `attachment://v1:${"a".repeat(64)}`;
210
+ const asked: unknown[] = [];
211
+ const policy = attachmentReadPolicy(
212
+ {
213
+ canReadAttachment: (request) => {
214
+ asked.push(request);
215
+ return Promise.resolve(
216
+ request.documentId === "doc-b" && request.userAddress === "0xabc"
217
+ ? {
218
+ kind: "allowed",
219
+ documentId: "doc-b" as CanonicalDocumentId,
220
+ ref: request.attachmentRef as AttachmentRef,
221
+ }
222
+ : { kind: "denied" },
223
+ );
224
+ },
225
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
226
+ },
227
+ 1,
228
+ );
229
+ const runUser = {
230
+ address: "0xabc",
231
+ subject: { address: "0xabc", key: "did:key:z1" },
232
+ };
233
+ expect(
234
+ await policy({
235
+ workflowId: "wf-1",
236
+ ref,
237
+ documentIds: ["doc-a", "doc-b"],
238
+ runUser,
239
+ }),
240
+ ).toBe(true);
241
+ expect(asked[1]).toEqual({
242
+ documentId: "doc-b",
243
+ attachmentRef: ref,
244
+ userAddress: "0xabc",
245
+ appKey: "did:key:z1",
246
+ });
247
+ // No document to read it through, another user, or not a ref: refused.
248
+ expect(
249
+ await policy({ workflowId: "wf-1", ref, documentIds: [], runUser }),
250
+ ).toBe(false);
251
+ expect(
252
+ await policy({
253
+ workflowId: "wf-1",
254
+ ref,
255
+ documentIds: ["doc-b"],
256
+ runUser: null,
257
+ }),
258
+ ).toBe(false);
259
+ expect(
260
+ await policy({
261
+ workflowId: "wf-1",
262
+ ref: "not-a-ref",
263
+ documentIds: ["doc-b"],
264
+ runUser,
265
+ }),
266
+ ).toBe(false);
267
+ expect(
268
+ await attachmentReadPolicy(undefined)({
269
+ workflowId: "wf-1",
270
+ ref,
271
+ documentIds: ["doc-b"],
272
+ runUser,
273
+ }),
274
+ ).toBe(false);
275
+ });
276
+
277
+ it("waits for the reference index to catch up with the triggering operation", async () => {
278
+ const ref = `attachment://v1:${"b".repeat(64)}`;
279
+ let calls = 0;
280
+ const policy = attachmentReadPolicy(
281
+ {
282
+ canReadAttachment: (request) =>
283
+ Promise.resolve(
284
+ ++calls < 3
285
+ ? { kind: "denied" }
286
+ : {
287
+ kind: "allowed",
288
+ documentId: request.documentId as CanonicalDocumentId,
289
+ ref: request.attachmentRef as AttachmentRef,
290
+ },
291
+ ),
292
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
293
+ },
294
+ 1,
295
+ );
296
+ expect(
297
+ await policy({ workflowId: "wf-1", ref, documentIds: ["doc-a"] }),
298
+ ).toBe(true);
299
+ expect(calls).toBe(3);
300
+ });
301
+
302
+ it("serves a subgraph the GraphQL manager can construct", async () => {
303
+ const { subgraph } = await compose(await buildReactorModule());
304
+
305
+ expect(subgraph.prototype).toBeInstanceOf(BaseSubgraph);
306
+ });
307
+
308
+ it("registers the trigger read model on the reactor's coordinator", async () => {
309
+ const clientModule = await buildReactorModule();
310
+
311
+ const workflows = await compose(clientModule);
312
+
313
+ expect(workflows.triggers).toEqual({ status: "available" });
314
+ expect(
315
+ clientModule.reactorModule!.readModelCoordinator.readModels.filter(
316
+ ({ name }) => name === "workflow-triggers",
317
+ ),
318
+ ).toHaveLength(1);
319
+ });
320
+
321
+ it("reports the intake unavailable on a coordinator without live registration", async () => {
322
+ const customCoordinator: IReadModelCoordinator = {
323
+ readModels: [],
324
+ start: vi.fn(),
325
+ stop: vi.fn(),
326
+ drain: vi.fn().mockResolvedValue(undefined),
327
+ getChainDepth: vi.fn().mockReturnValue(0),
328
+ };
329
+
330
+ const workflows = await compose(
331
+ await buildReactorModule(customCoordinator),
332
+ );
333
+
334
+ expect(workflows.triggers).toEqual({
335
+ status: "unavailable",
336
+ reason: "live-read-model-registration-unsupported",
337
+ });
338
+ expect(customCoordinator.readModels).toEqual([]);
339
+ });
340
+
341
+ it("reports the intake unavailable without an in-process reactor", async () => {
342
+ const clientModule = await buildReactorModule();
343
+
344
+ const workflows = await compose(clientModule, {
345
+ clientModule: {} as InProcessReactorClientModule,
346
+ });
347
+
348
+ expect(workflows.triggers).toEqual({
349
+ status: "unavailable",
350
+ reason: "in-process-reactor-module-unavailable",
351
+ });
352
+ });
353
+
354
+ it("arms the webhooks and the supervisor on start, and stops them once", async () => {
355
+ const webhooks = memoryWebhooks();
356
+ const workflows = await compose(await buildReactorModule(), {
357
+ webhooks: webhooks.scope,
358
+ });
359
+
360
+ // Intervals only, from here on: the supervisor's tick is the runtime's one.
361
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
362
+ try {
363
+ await workflows.start();
364
+
365
+ expect(webhooks.families.map(({ name }) => name)).toEqual(["trigger"]);
366
+ // The real runtime answers for it: an unknown workflow is not armed.
367
+ await expect(
368
+ webhooks.families[0]!.policyFor?.("wf-unknown"),
369
+ ).resolves.toBeUndefined();
370
+ expect(vi.getTimerCount()).toBe(1);
371
+
372
+ await workflows.stop();
373
+ await workflows.stop();
374
+
375
+ expect(vi.getTimerCount()).toBe(0);
376
+ } finally {
377
+ vi.useRealTimers();
378
+ }
379
+ });
380
+
381
+ it("names the package a host would have to install when the load fails", async () => {
382
+ const cause = new Error("Cannot find module");
383
+ const failing = compose(await buildReactorModule(), {
384
+ load: () => Promise.reject(cause),
385
+ });
386
+
387
+ await expect(failing).rejects.toThrow("@powerhousedao/reactor-workflow");
388
+ await expect(failing).rejects.toMatchObject({ cause });
389
+ });
390
+ });
391
+
392
+ describe("the host's reactor access", () => {
393
+ const signer = { app: { key: "did:key:zHost" }, user: { address: "0xhost" } };
394
+ const moduleWith = (flags?: Record<string, boolean>) =>
395
+ ({
396
+ signer,
397
+ reactorModule: flags ? { featureFlags: flags } : undefined,
398
+ }) as unknown as InProcessReactorClientModule;
399
+
400
+ it("grants the host by key under auth conditions, else by address", () => {
401
+ const byKey = reactorAccessOf(
402
+ moduleWith({ authEnforcement: true, authConditions: true }),
403
+ );
404
+ expect(byKey).toEqual({
405
+ authEnforcement: true,
406
+ authConditions: true,
407
+ identity: { address: "0xhost", key: "did:key:zHost" },
408
+ });
409
+ expect(hostPrincipalOf(byKey)).toEqual({
410
+ match: { eq: [{ attr: "subject.key" }, { lit: "did:key:zHost" }] },
411
+ });
412
+
413
+ const byAddress = reactorAccessOf(
414
+ moduleWith({ authEnforcement: false, authConditions: false }),
415
+ );
416
+ expect(byAddress.authEnforcement).toBe(false);
417
+ expect(hostPrincipalOf(byAddress)).toEqual({ address: "0xhost" });
418
+ });
419
+
420
+ it("reads unknown flags as enforced and a keyless signer as no identity", () => {
421
+ const access = reactorAccessOf({
422
+ signer: {},
423
+ reactorModule: undefined,
424
+ } as unknown as InProcessReactorClientModule);
425
+ expect(access).toEqual({
426
+ authEnforcement: true,
427
+ authConditions: false,
428
+ identity: null,
429
+ });
430
+ expect(hostPrincipalOf(access)).toBeUndefined();
431
+ });
432
+ });
433
+
434
+ // The GraphQL manager rides on the boot result without being on its public
435
+ // type, and the subgraph registers late: poll rather than race it.
436
+ async function pollWorkflowSubgraph(
437
+ switchboard: Awaited<ReturnType<typeof startSwitchboard>>,
438
+ ): Promise<{ name: string } | undefined> {
439
+ const { graphqlManager } = (
440
+ switchboard as unknown as {
441
+ api: { graphqlManager: GraphQLManager };
442
+ }
443
+ ).api;
444
+ for (let attempt = 0; attempt < 100; attempt++) {
445
+ const subgraph = graphqlManager.getSubgraphByName("workflow-runtime");
446
+ if (subgraph) return subgraph;
447
+ await new Promise((resolve) => setTimeout(resolve, 50));
448
+ }
449
+ return undefined;
450
+ }
451
+
452
+ describe("booting Switchboard with workflows on", () => {
453
+ it("arms the intake and registers the workflow document models", async () => {
454
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-workflows-"));
455
+ const previousReactorDb = process.env.PH_REACTOR_DATABASE_URL;
456
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
457
+ let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
458
+
459
+ try {
460
+ switchboard = await startSwitchboard({
461
+ workflows: { enabled: true },
462
+ dbPath: join(tempRoot, "read-model"),
463
+ port: 0,
464
+ mcp: false,
465
+ disableLocalPackages: true,
466
+ identity: { keypairPath: join(tempRoot, "identity.json") },
467
+ logger: stubLogger(),
468
+ });
469
+
470
+ expect(switchboard.workflowTriggers).toEqual({ status: "available" });
471
+ const { results } = await switchboard.reactor.getDocumentModelModules();
472
+ expect(
473
+ results.map(({ documentModel }) => documentModel.global.id),
474
+ ).toContain("powerhouse/workflow");
475
+ // With no worker pool or projection worker, workflows alone resolve them.
476
+ expect(
477
+ switchboard.modelManifest().map((entry) => entry.documentType),
478
+ ).toEqual(
479
+ expect.arrayContaining([
480
+ "powerhouse/workflow",
481
+ "powerhouse/connection",
482
+ ]),
483
+ );
484
+ // The subgraph is registered late, so the schema it joins arrives after
485
+ // the boot resolves; poll rather than race it.
486
+ await expect(pollWorkflowSubgraph(switchboard)).resolves.toMatchObject({
487
+ name: "workflow-runtime",
488
+ });
489
+
490
+ await switchboard.shutdown();
491
+ switchboard = undefined;
492
+ } finally {
493
+ await switchboard?.shutdown();
494
+ if (previousReactorDb === undefined) {
495
+ delete process.env.PH_REACTOR_DATABASE_URL;
496
+ } else {
497
+ process.env.PH_REACTOR_DATABASE_URL = previousReactorDb;
498
+ }
499
+ await rm(tempRoot, { recursive: true, force: true });
500
+ }
501
+ }, 60_000);
502
+ });
503
+
504
+ describe("bindPackagePieces", () => {
505
+ // The manager's shape, no more of it than the binding uses.
506
+ function stubSource(initial: Map<string, PackagePieceEntry[]>) {
507
+ const handlers: ((pieces: Map<string, PackagePieceEntry[]>) => void)[] = [];
508
+ return {
509
+ getPieces: () => initial,
510
+ onPiecesChange(
511
+ handler: (pieces: Map<string, PackagePieceEntry[]>) => void,
512
+ ) {
513
+ handlers.push(handler);
514
+ },
515
+ emit(pieces: Map<string, PackagePieceEntry[]>) {
516
+ for (const handler of handlers) handler(pieces);
517
+ },
518
+ };
519
+ }
520
+
521
+ const entry = (name: string, version: string): PackagePieceEntry => ({
522
+ name,
523
+ version,
524
+ bundleDir: `/pkg/dist/node/pieces/${name}`,
525
+ });
526
+
527
+ it("fills the registry from what the manager already loaded", () => {
528
+ const registry = new PieceRegistry();
529
+ const source = stubSource(
530
+ new Map([
531
+ ["@acme/pack", [entry("@acme/piece-a", "1.0.0")]],
532
+ ["/srv/project", [entry("@acme/piece-b", "2.0.0")]],
533
+ ]),
534
+ );
535
+
536
+ bindPackagePieces(registry, source);
537
+
538
+ expect(registry.versions()).toEqual({
539
+ "@acme/piece-a": "1.0.0",
540
+ "@acme/piece-b": "2.0.0",
541
+ });
542
+ });
543
+
544
+ it("refills on every change, so a rebuilt package needs no restart", () => {
545
+ const registry = new PieceRegistry();
546
+ const source = stubSource(new Map());
547
+
548
+ bindPackagePieces(registry, source);
549
+ expect(registry.entries()).toEqual([]);
550
+
551
+ source.emit(new Map([["/srv/project", [entry("@acme/piece-b", "2.0.0")]]]));
552
+ expect(registry.versions()).toEqual({ "@acme/piece-b": "2.0.0" });
553
+
554
+ // And a package that stops shipping one loses it.
555
+ source.emit(new Map());
556
+ expect(registry.entries()).toEqual([]);
557
+ });
558
+
559
+ it("gives a piece two packages ship to the first by package name, whatever the load order", () => {
560
+ const registry = new PieceRegistry();
561
+ const source = stubSource(
562
+ new Map([
563
+ ["@zeta/pack", [entry("@acme/piece-a", "9.0.0")]],
564
+ ["@alpha/pack", [entry("@acme/piece-a", "1.0.0")]],
565
+ ]),
566
+ );
567
+
568
+ bindPackagePieces(registry, source);
569
+
570
+ expect(registry.versions()).toEqual({ "@acme/piece-a": "1.0.0" });
571
+ });
572
+ });
package/tsconfig.json CHANGED
@@ -15,9 +15,6 @@
15
15
  {
16
16
  "path": "../../packages/opentelemetry-instrumentation-reactor"
17
17
  },
18
- {
19
- "path": "../../packages/pglite-fs"
20
- },
21
18
  {
22
19
  "path": "../../packages/reactor"
23
20
  },
@@ -33,6 +30,12 @@
33
30
  {
34
31
  "path": "../../packages/reactor-group"
35
32
  },
33
+ {
34
+ "path": "../../packages/reactor-privacy"
35
+ },
36
+ {
37
+ "path": "../../packages/reactor-workflow"
38
+ },
36
39
  {
37
40
  "path": "../../packages/renown"
38
41
  },
@@ -41,6 +44,9 @@
41
44
  },
42
45
  {
43
46
  "path": "../../packages/vetra"
47
+ },
48
+ {
49
+ "path": "../../packages/workflow"
44
50
  }
45
51
  ]
46
52
  }
package/tsdown.config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { defineConfig } from "tsdown";
2
+ import { dtsExportList } from "../../tsdown.dts.mjs";
2
3
 
3
4
  export default defineConfig({
4
5
  entry: [
@@ -11,6 +12,7 @@ export default defineConfig({
11
12
  platform: "node",
12
13
  outDir: "dist",
13
14
  clean: true,
14
- dts: true,
15
+ dts: { generator: "tsgo" },
16
+ plugins: [dtsExportList()],
15
17
  sourcemap: true,
16
18
  });