@powerhousedao/switchboard 6.2.3-dev.44 → 6.2.3-dev.45

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 (32) hide show
  1. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
  2. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
  3. package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
  4. package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
  5. package/.tsbuild/test/snapshot-writer.d.ts +18 -0
  6. package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
  7. package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
  8. package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
  9. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
  10. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
  11. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  12. package/CHANGELOG.md +28 -0
  13. package/dist/index.mjs +1 -1
  14. package/dist/{server-Bk_IZzCn.mjs → server-Dvw8_NeM.mjs} +256 -77
  15. package/dist/server-Dvw8_NeM.mjs.map +1 -0
  16. package/dist/server.d.mts +3 -1
  17. package/dist/server.d.mts.map +1 -1
  18. package/dist/server.mjs +3 -3
  19. package/package.json +15 -16
  20. package/test/boot-unsupported-stored-documents.test.ts +5 -5
  21. package/test/durable-node-fs-pg16.test.ts +80 -0
  22. package/test/pglite-dialect.test.ts +28 -0
  23. package/test/pglite-preflight.test.ts +313 -0
  24. package/test/pglite-version.test.ts +9 -1
  25. package/test/snapshot-writer.ts +94 -0
  26. package/test/worker-pool.test.ts +27 -1
  27. package/test/workflow/resolvers.test.ts +26 -0
  28. package/test/workflow-reactor-access.test.ts +384 -0
  29. package/test/workflow-reactor-enforcement.test.ts +805 -0
  30. package/test/workflow-runtime.test.ts +53 -0
  31. package/tsconfig.json +0 -3
  32. package/dist/server-Bk_IZzCn.mjs.map +0 -1
@@ -1,9 +1,12 @@
1
1
  import type { ILogger } from "document-model";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
3
5
  import { describe, expect, it, vi } from "vitest";
4
6
  import {
5
7
  autoWorkerCount,
6
8
  buildWorkerDbConfig,
9
+ keepImportableSources,
7
10
  resolveHostPoolSize,
8
11
  resolveWorkerModelSources,
9
12
  resolveWorkerPoolOptions,
@@ -264,3 +267,26 @@ describe("resolveWorkerModelSources", () => {
264
267
  expect(logger.warn).toHaveBeenCalledTimes(1);
265
268
  });
266
269
  });
270
+
271
+ describe("keepImportableSources", () => {
272
+ it("keeps sources that export a model and drops one that throws or has none", async () => {
273
+ const dir = mkdtempSync(path.join(tmpdir(), "ph-model-sources-"));
274
+ const good = path.join(dir, "good.mjs");
275
+ const broken = path.join(dir, "broken.mjs");
276
+ const empty = path.join(dir, "empty.mjs");
277
+ writeFileSync(
278
+ good,
279
+ 'export const Model = { reducer: () => ({}), documentModel: { global: { id: "test/model" } } };',
280
+ );
281
+ writeFileSync(broken, 'throw new Error("stale dist");');
282
+ // What `ph init` builds for a project with no models yet.
283
+ writeFileSync(empty, "export {};");
284
+
285
+ const kept = await keepImportableSources(
286
+ [{ filePath: broken }, { filePath: good }, { filePath: empty }],
287
+ stubLogger(),
288
+ );
289
+
290
+ expect(kept).toEqual([{ filePath: good }]);
291
+ });
292
+ });
@@ -48,6 +48,7 @@ function fakeRuntime() {
48
48
  testStep: vi.fn(() => Promise.resolve({})),
49
49
  stepOutputTree: vi.fn(() => Promise.resolve({})),
50
50
  blockResolutions: vi.fn(() => Promise.resolve([])),
51
+ reactorAccessDenial: vi.fn((): string | undefined => undefined),
51
52
  testTrigger: vi.fn(() => Promise.resolve(null)),
52
53
  cancelTriggerTestFor: vi.fn(() => Promise.resolve(true)),
53
54
  secrets: vi.fn(() =>
@@ -68,6 +69,12 @@ function build(isAdmin: boolean) {
68
69
  const resolvers = getResolvers(
69
70
  runtime as unknown as WorkflowRuntimeService,
70
71
  authorizationService,
72
+ undefined,
73
+ undefined,
74
+ (documentId) =>
75
+ documentId === "wf-hidden"
76
+ ? Promise.reject(new Error("You may not read this workflow"))
77
+ : Promise.resolve(),
71
78
  ) as Record<string, Record<string, Resolver>>;
72
79
  return {
73
80
  runtime,
@@ -167,6 +174,25 @@ describe("the workflow resolvers and the caller", () => {
167
174
  expect(runtime.secrets).toHaveBeenCalledTimes(1);
168
175
  });
169
176
 
177
+ it("serves why a workflow got no reactor access, naming the publisher", async () => {
178
+ const { runtime, queries } = build(false);
179
+ runtime.reactorAccessDenial.mockReturnValueOnce(
180
+ 'The publisher 0xabc cannot read reactor connection "conn-1"',
181
+ );
182
+
183
+ await expect(
184
+ queries.reactorAccessDenial({}, { workflowId: "wf-1" }, CTX),
185
+ ).resolves.toBe(
186
+ 'The publisher 0xabc cannot read reactor connection "conn-1"',
187
+ );
188
+ await expect(
189
+ queries.reactorAccessDenial({}, { workflowId: "wf-1" }, CTX),
190
+ ).resolves.toBeNull();
191
+ await expect(
192
+ queries.reactorAccessDenial({}, { workflowId: "wf-hidden" }, CTX),
193
+ ).rejects.toThrow("You may not read this workflow");
194
+ });
195
+
170
196
  it("serves the piece version a run's steps ran", async () => {
171
197
  const { runtime, queries } = build(true);
172
198
  runtime.run.mockResolvedValueOnce({
@@ -0,0 +1,384 @@
1
+ // The Switchboard's workflow runtime and its subgraph, composed over a real
2
+ // reactor with grants, with auth enforcement on and off (ADR 0005 §5-§9).
3
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ ReactorBuilder,
8
+ ReactorClientBuilder,
9
+ type IReactorClient,
10
+ type InProcessReactorClientModule,
11
+ } from "@powerhousedao/reactor";
12
+ import {
13
+ AuthorizationPolicy,
14
+ getDbClient,
15
+ type Context,
16
+ type IAuthorizationService,
17
+ } from "@powerhousedao/reactor-api";
18
+ import {
19
+ initializeAuth,
20
+ withSignaturePolicy,
21
+ type DocumentModelModule,
22
+ type Grant,
23
+ type ISigner,
24
+ } from "@powerhousedao/shared/document-model";
25
+ import {
26
+ createRelationalDb,
27
+ type IRelationalDb,
28
+ } from "@powerhousedao/shared/processors";
29
+ import {
30
+ Connection,
31
+ REACTOR_CONNECTOR_ID,
32
+ actions as connectionActions,
33
+ type ConnectionDocument,
34
+ } from "@powerhousedao/workflow/document-models/connection";
35
+ import {
36
+ Workflow,
37
+ actions as workflowActions,
38
+ } from "@powerhousedao/workflow/document-models/workflow";
39
+ import {
40
+ MemoryKeyStorage,
41
+ RenownCryptoBuilder,
42
+ RenownCryptoSigner,
43
+ } from "@renown/sdk/node";
44
+ import type { ILogger } from "document-model";
45
+ import { documentModelDocumentModelModule } from "document-model";
46
+ import type { Kysely } from "kysely";
47
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
48
+ import {
49
+ composeWorkflowRuntime,
50
+ type ComposedWorkflowRuntime,
51
+ } from "../src/workflow-runtime.mjs";
52
+
53
+ const PIECE = "@powerhousedao/piece-switchboard-docs";
54
+ const PIECE_VERSION = "1.0.0";
55
+ const PUBLISHER = "0xpublisher";
56
+ const OTHER = "0xother";
57
+ const HOST = "0xhost";
58
+
59
+ const SOURCE = `
60
+ const action = (name, requireReactor) => ({
61
+ name,
62
+ displayName: name,
63
+ description: name,
64
+ requireAuth: false,
65
+ requireReactor,
66
+ props: {},
67
+ run: async () => undefined,
68
+ });
69
+ module.exports = {
70
+ app: {
71
+ displayName: "Docs",
72
+ description: "Works with documents",
73
+ logoUrl: "https://example.com/docs.png",
74
+ categories: [],
75
+ actions: {
76
+ create_doc: {
77
+ ...action("create_doc", "write"),
78
+ run: async (ctx) => {
79
+ const doc = await ctx.reactor.createEmpty("powerhouse/document-model");
80
+ return { id: doc.header.id };
81
+ },
82
+ },
83
+ pick_doc: {
84
+ ...action("pick_doc", "read"),
85
+ props: {
86
+ document: {
87
+ displayName: "Document",
88
+ type: "DROPDOWN",
89
+ required: false,
90
+ refreshers: [],
91
+ options: async (_values, ctx) => {
92
+ const page = await ctx.reactor.find({
93
+ type: "powerhouse/document-model",
94
+ });
95
+ return {
96
+ options: page.results.map((d) => ({
97
+ label: d.header.id,
98
+ value: d.header.id,
99
+ })),
100
+ };
101
+ },
102
+ },
103
+ },
104
+ },
105
+ },
106
+ triggers: {},
107
+ },
108
+ };
109
+ `;
110
+
111
+ const logger = {
112
+ verbose: vi.fn(),
113
+ debug: vi.fn(),
114
+ info: vi.fn(),
115
+ warn: vi.fn(),
116
+ error: vi.fn(),
117
+ child: () => logger,
118
+ } as unknown as ILogger;
119
+
120
+ const ctxOf = (address?: string) =>
121
+ ({
122
+ headers: {},
123
+ db: {},
124
+ user: address ? { address, appKey: undefined } : undefined,
125
+ }) as unknown as Context;
126
+
127
+ const grant = (
128
+ id: string,
129
+ principal: Grant["principal"],
130
+ capability: Grant["capability"],
131
+ ): Grant => ({ id, description: id, effect: "allow", principal, capability });
132
+
133
+ async function signerFor(address: string): Promise<ISigner> {
134
+ const crypto = await new RenownCryptoBuilder()
135
+ .withKeyPairStorage(new MemoryKeyStorage())
136
+ .build();
137
+ return new RenownCryptoSigner(crypto, "test", {
138
+ address,
139
+ networkId: "eip155",
140
+ chainId: 1,
141
+ });
142
+ }
143
+
144
+ const OPEN: IAuthorizationService = {
145
+ config: {
146
+ admins: [],
147
+ defaultProtection: false,
148
+ policy: AuthorizationPolicy.OPEN,
149
+ },
150
+ isSupremeAdmin: () => true,
151
+ canCreate: () => true,
152
+ canRead: () => Promise.resolve(true),
153
+ canWrite: () => Promise.resolve(true),
154
+ canManage: () => Promise.resolve(true),
155
+ canMutate: () => Promise.resolve(true),
156
+ };
157
+
158
+ type Resolver = (parent: unknown, args: unknown, ctx: Context) => unknown;
159
+
160
+ describe.each([
161
+ { enforcement: false, label: "auth enforcement off" },
162
+ { enforcement: true, label: "auth enforcement on" },
163
+ ])("the Switchboard workflow runtime, $label", ({ enforcement }) => {
164
+ let root = "";
165
+ let module: InProcessReactorClientModule;
166
+ let hostSigner: ISigner;
167
+ let publisher: IReactorClient;
168
+ let composed: ComposedWorkflowRuntime;
169
+ let queries: Record<string, Resolver>;
170
+ let mutations: Record<string, Resolver>;
171
+
172
+ async function createDocument(
173
+ client: IReactorClient,
174
+ model: DocumentModelModule,
175
+ id: string,
176
+ grants?: Grant[],
177
+ ) {
178
+ await client.create(
179
+ withSignaturePolicy(model.utils.createDocument(), "legacy", { id }),
180
+ );
181
+ if (grants) {
182
+ await client.execute(id, "main", [
183
+ initializeAuth({ version: 1, grants }),
184
+ ]);
185
+ }
186
+ }
187
+
188
+ beforeAll(async () => {
189
+ root = await mkdtemp(join(tmpdir(), "switchboard-reactor-access-"));
190
+ const bundle = join(root, "dist", "node", "pieces", "docs");
191
+ await mkdir(bundle, { recursive: true });
192
+ await writeFile(
193
+ join(bundle, "package.json"),
194
+ JSON.stringify({ name: PIECE, version: PIECE_VERSION, main: "index.js" }),
195
+ );
196
+ await writeFile(join(bundle, "index.js"), SOURCE);
197
+
198
+ hostSigner = await signerFor(HOST);
199
+ module = await new ReactorClientBuilder()
200
+ .withSigner(hostSigner)
201
+ .withCreateSignaturePolicy("legacy")
202
+ .withReactorBuilder(
203
+ new ReactorBuilder()
204
+ .withDocumentModelSources([
205
+ Workflow as unknown as DocumentModelModule,
206
+ Connection as unknown as DocumentModelModule,
207
+ documentModelDocumentModelModule as unknown as DocumentModelModule,
208
+ ])
209
+ .withExecutorConfig({
210
+ featureFlags: enforcement
211
+ ? { documentDecisions: true, authEnforcement: true }
212
+ : {},
213
+ })
214
+ // Test keys hold no Renown credential.
215
+ .withTrustPolicy({ authorizeSigner: () => Promise.resolve(true) }),
216
+ )
217
+ .buildModule();
218
+ publisher = await new ReactorClientBuilder()
219
+ .withReactor(
220
+ module.reactor,
221
+ module.eventBus,
222
+ module.documentIndexer,
223
+ module.documentView,
224
+ )
225
+ .withSigner(await signerFor(PUBLISHER))
226
+ .build();
227
+
228
+ // Only the publisher reads or writes the first; the host may write both.
229
+ const publisherOnly = grant(
230
+ "publisher",
231
+ { address: PUBLISHER },
232
+ { can: "execute" },
233
+ );
234
+ await createDocument(
235
+ publisher,
236
+ documentModelDocumentModelModule as never,
237
+ "doc-publisher",
238
+ [publisherOnly],
239
+ );
240
+ await createDocument(
241
+ publisher,
242
+ documentModelDocumentModelModule as never,
243
+ "doc-shared",
244
+ [publisherOnly, grant("host", { address: HOST }, { can: "execute" })],
245
+ );
246
+ await createDocument(publisher, Connection as never, "conn-open");
247
+ await publisher.execute("conn-open", "main", [
248
+ connectionActions.setConnector({
249
+ connectorId: REACTOR_CONNECTOR_ID,
250
+ authType: "REACTOR",
251
+ }),
252
+ connectionActions.setConfig({ config: { endpoint: "local" } }),
253
+ ]);
254
+ await createDocument(publisher, Workflow as never, "wf-create");
255
+ await publisher.execute("wf-create", "main", [
256
+ workflowActions.addStep({
257
+ id: "create",
258
+ key: "create",
259
+ name: "create",
260
+ pieceName: PIECE,
261
+ pieceVersion: PIECE_VERSION,
262
+ actionName: "create_doc",
263
+ config: {},
264
+ reactorConnectionId: "conn-open",
265
+ }),
266
+ ]);
267
+ const authorizationService = OPEN;
268
+ const { db } = getDbClient();
269
+ const relationalDb = createRelationalDb(
270
+ db as unknown as Kysely<unknown>,
271
+ ) as IRelationalDb;
272
+ const pieces = new Map([
273
+ [root, [{ name: PIECE, version: PIECE_VERSION, bundleDir: bundle }]],
274
+ ]);
275
+ composed = await composeWorkflowRuntime({
276
+ reactorClient: module.client,
277
+ clientModule: module,
278
+ relationalDb,
279
+ attachments: {} as never,
280
+ authorizationService,
281
+ pieces: { getPieces: () => pieces, onPiecesChange: () => undefined },
282
+ logger,
283
+ });
284
+ const subgraph = new composed.subgraph({
285
+ reactorClient: module.client,
286
+ authorizationService,
287
+ relationalDb,
288
+ } as never);
289
+ const resolvers = subgraph.resolvers as Record<
290
+ string,
291
+ Record<string, Resolver>
292
+ >;
293
+ queries = resolvers.WorkflowRuntimeQueries;
294
+ mutations = resolvers.WorkflowRuntimeMutations;
295
+ });
296
+
297
+ afterAll(async () => {
298
+ await composed.stop();
299
+ module.reactor.kill();
300
+ await rm(root, { recursive: true, force: true });
301
+ });
302
+
303
+ it("tells any caller whether auth is enforced", () => {
304
+ expect(queries.authEnforcement({}, {}, ctxOf())).toBe(enforcement);
305
+ expect(queries.authEnforcement({}, {}, ctxOf(OTHER))).toBe(enforcement);
306
+ });
307
+
308
+ it("names the host's identity and the grant form to signed-in callers only", () => {
309
+ expect(queries.reactorIdentity({}, {}, ctxOf())).toBeNull();
310
+ expect(queries.authConditions({}, {}, ctxOf())).toBeNull();
311
+
312
+ expect(queries.reactorIdentity({}, {}, ctxOf(OTHER))).toEqual({
313
+ address: HOST,
314
+ key: hostSigner.app?.key,
315
+ });
316
+ expect(queries.authConditions({}, {}, ctxOf(OTHER))).toBe(false);
317
+ });
318
+
319
+ it("resolves options reading as the caller", async () => {
320
+ const options = async (caller: string, reactorConnectionId?: string) =>
321
+ (
322
+ (await queries.blockOptions(
323
+ {},
324
+ {
325
+ block: {
326
+ pieceName: PIECE,
327
+ pieceVersion: PIECE_VERSION,
328
+ kind: "action",
329
+ name: "pick_doc",
330
+ },
331
+ propName: "document",
332
+ ...(reactorConnectionId ? { reactorConnectionId } : {}),
333
+ },
334
+ ctxOf(caller),
335
+ )) as { options: { value: string }[] }
336
+ ).options.map((option) => option.value);
337
+
338
+ expect(await options(PUBLISHER, "conn-open")).toEqual(
339
+ expect.arrayContaining(["doc-publisher", "doc-shared"]),
340
+ );
341
+ // Both grant only named principals; the read gate serves OTHER neither.
342
+ const asOther = await options(OTHER, "conn-open");
343
+ expect(asOther).not.toContain("doc-publisher");
344
+ expect(asOther).not.toContain("doc-shared");
345
+ });
346
+
347
+ it("grants the host on a document a run creates, so it can write it later", async () => {
348
+ const result = (await mutations.testStep(
349
+ {},
350
+ { workflowId: "wf-create", stepId: "create" },
351
+ ctxOf(PUBLISHER),
352
+ )) as { status: string; error?: string | null; output: { id: string } };
353
+ expect(result.error ?? undefined).toBeUndefined();
354
+ expect(result.status).toBe("SUCCEEDED");
355
+ const { id } = result.output;
356
+
357
+ const created = await module.client.get(id);
358
+ expect(created.state.auth.grants.map((g) => g.principal)).toEqual(
359
+ expect.arrayContaining([{ address: PUBLISHER }, { address: HOST }]),
360
+ );
361
+ // The host signs this write; under enforcement it lands only with its grant.
362
+ const renamed = await module.client.execute(id, "main", [
363
+ documentModelDocumentModelModule.actions.setModelName({ name: "Host" }),
364
+ ]);
365
+ expect(
366
+ (renamed.state as unknown as { global: { name: string } }).global.name,
367
+ ).toBe("Host");
368
+ });
369
+
370
+ it("checks a REACTOR connection's config instead of calling it unconfigured", async () => {
371
+ const result = (await mutations.checkConnection(
372
+ {},
373
+ { connectionId: "conn-open" },
374
+ ctxOf(PUBLISHER),
375
+ )) as { ok: boolean; detail: string | null };
376
+
377
+ expect(result).toMatchObject({
378
+ ok: true,
379
+ detail: "Local reactor, write access",
380
+ });
381
+ const connection = await module.client.get<ConnectionDocument>("conn-open");
382
+ expect(connection.state.global.status).toBe("OK");
383
+ });
384
+ });