@powerhousedao/switchboard 6.2.3-dev.49 → 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.
package/dist/server.d.mts CHANGED
@@ -1,14 +1,21 @@
1
1
  import { ILogger } from "document-model";
2
- import { ChannelScheme, IDocumentModelLoader, IReactorClient, InProcessReactorClientModule, JwtHandler, ModelManifestEntry, ReactorBuilder, ReactorClientBuilder, ReactorFeatureFlags, SignerConfig, UnsupportedStoredDocuments, UnsupportedStoredProtocolError } from "@powerhousedao/reactor";
2
+ import "@powerhousedao/reactor-api/pglite-node";
3
+ import { ChannelScheme, Database, IDocumentModelLoader, IReactorClient, InProcessReactorClientModule, JwtHandler, ModelManifestEntry, PoolInstrumentation, ReactorBuilder, ReactorClientBuilder, ReactorFeatureFlags, SignerConfig, UnsupportedStoredDocuments, UnsupportedStoredProtocolError } from "@powerhousedao/reactor";
3
4
  import { AttachmentReferenceProjectionCapability } from "@powerhousedao/reactor-api";
4
5
  import { IAttachmentService } from "@powerhousedao/reactor-attachments";
5
6
  import { IRenown } from "@renown/sdk/node";
7
+ import { Kysely } from "kysely";
6
8
  import { IErasureService } from "@powerhousedao/reactor-privacy";
7
9
  import { DriveInput } from "@powerhousedao/shared/document-drive";
8
10
  import { DocumentModelModule, ISigner, SignaturePolicy, UpgradeManifest } from "@powerhousedao/shared/document-model";
11
+ import "@electric-sql/pglite";
9
12
  import "@powerhousedao/reactor-workflow";
10
13
  import "@powerhousedao/shared/processors";
11
14
  import { IRenown as IRenown$1 } from "@renown/sdk";
15
+ //#region src/pglite-version.d.ts
16
+ declare const SUPPORTED_PG_MAJORS: readonly [16, 17];
17
+ type SupportedPgMajor = (typeof SUPPORTED_PG_MAJORS)[number];
18
+ //#endregion
12
19
  //#region src/privacy.d.mts
13
20
  type PrivacyOptions = {
14
21
  /** Wins over PH_PRIVACY_ENABLED. */
@@ -246,7 +253,8 @@ type SwitchboardReactorDefaultsOptions = {
246
253
  /**
247
254
  * Channel scheme. Defaults to `ChannelScheme.SWITCHBOARD`, which populates
248
255
  * `reactorModule.syncModule.syncManager` — required by reactor-api. Set
249
- * to `false` only if the caller will configure a scheme themselves.
256
+ * to `false` if the caller configures sync itself (`withChannelScheme` or
257
+ * `withSync`); the builder refuses both together.
250
258
  */
251
259
  channelScheme?: ChannelScheme | false;
252
260
  /** Defaults to true. Set false when the caller owns SIGINT handling. */
@@ -296,6 +304,29 @@ export declare class StoredDocumentsRefusedError extends Error {
296
304
  * surfaced so we don't silently mask real issues (permissions, bad host, …).
297
305
  */
298
306
  export declare function isPortAvailable(port: number): Promise<boolean>;
307
+ /**
308
+ * The reactor's storage handle, plus the pool instrumentation when that
309
+ * storage is Postgres. The PGlite branch has no `pg.Pool` at all, so the
310
+ * instrumentation is absent rather than optional-by-convention.
311
+ */
312
+ type ReactorStorage = {
313
+ kysely: Kysely<Database>;
314
+ poolInstrumentation: PoolInstrumentation | undefined;
315
+ };
316
+ export declare function createReactorKysely(opts: {
317
+ reactorDbUrl: string | undefined;
318
+ reactorPgliteDir: string | null;
319
+ reactorPgliteMajor: SupportedPgMajor | null;
320
+ inMemory: boolean;
321
+ /**
322
+ * Resolved lazily: only the Postgres branch has a host pool, and
323
+ * {@link resolveHostPoolSize} throws on a bad REACTOR_DB_POOL_SIZE_HOST.
324
+ * Called eagerly it would fail a PGlite-backed server over a value that
325
+ * path never reads.
326
+ */
327
+ hostPoolSize: () => number;
328
+ logger: ILogger;
329
+ }): Promise<ReactorStorage>;
299
330
  /** Derive the remote attachment service config for switchboard's own `/attachments/*` API. */
300
331
  export declare function deriveAttachmentServiceConfig(options: Pick<StartServerOptions, "attachmentServiceUrl" | "https">, serverPort: number, renown: IRenown | null): {
301
332
  remoteUrl: string;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.mts","names":[],"sources":["../src/privacy.mts","../src/workflow-runtime.mts","../src/types.ts","../src/builder-defaults.mts","../src/unsupported-stored-documents.mts","../src/server.mts"],"mappings":";;;;;;;;;;;;KAyBY;;EAEV;;EAEA;;EAEA,SAAS;;;;;;KC8BC;EACN;;EAEA;EACA;;;;;;;;;;YC3CM;;YAKA,wBAAwB;EAClC,eAAe;;YAGL;EACV;EACA;EACA;;YAGU;;EAEV;;;;;EAKA;;EAGA;;YAGU;;;;;;;;;;;;;;;;;;;;;EAqBV,UAAU;;;;;;;;EAQV;;;;;;;EAOA;EACA;EACA;;;;;;EAMA;EACA;EACA;EACA,QAAQ;EACR;EACA;EACA;IAEM;IACA;;;;;;;EASN,WAAW;;EAEX;;;EAGA;;;EAGA;IACE;;;EAGF,UAAU;EACV,kBAAkB;EAClB;EACA;;;;;;EAMA;EACA,SAAS;;;;;;;;;;EAUT;;;;;;;;;;EAUA;;;;;;;;;EASA;IACE;IACA;IACA;;;;;;;;;;;EAWF;IACE;IACA;;;;;;;;;EASF,6BAA6B;;YAGnB;EACV;EACA,SAAS;;EAET,mBAAmB;;EAEnB,+BAA+B;;;EAG/B,mBAAmB;;EAEnB;;EAEA,iBAAiB;;EAEjB;IAAY,SAAS;;;EAErB;;EAEA,QAAQ;;;;;EAKR;;;;;;;;;;;;;EAaA,gBAAgB;;;;KC/MN;EAIV,iBAAiB;;EAEjB,mBAAmB;;EAEnB;;;;;;EAMA,gBAAgB;;EAEhB;;EAEA;IACE;IACA,eAAe,QAAQ;;;EAGzB,sBAAsB;EACtB,SAAS;;;;;;EAMT,SAAS;;;;;EAKT,cAAc,KAAK;;EAEnB,wBAAwB;;wBAsBV,gCACd,gBAAgB,gBAChB,eAAe,sBACf,UAAS;;;;wBC3EK,kCACd,OAAO,wCACP,KAAK,OAAO,aACX;;qBAaU,oCAAoC;WACtC;IAAqB;IAAkB;;WACvC;EAET,YAAY,OAAO;SAcZ,QAAQ,iBAAiB,SAAS;;;;;;;;;wBCoK3B,gBAAgB,eAAe;;wBAoI/B,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBAm7BvB,mBAAgB,UAClB,uBACR,QAAQ"}
1
+ {"version":3,"file":"server.d.mts","names":[],"sources":["../src/pglite-version.ts","../src/privacy.mts","../src/workflow-runtime.mts","../src/types.ts","../src/builder-defaults.mts","../src/unsupported-stored-documents.mts","../src/server.mts"],"mappings":";;;;;;;;;;;;;;;cASa;KACD,2BAA2B;;;KCe3B;;EAEV;;EAEA;;EAEA,SAAS;;;;;;KCiCC;EACN;;EAEA;EACA;;;;;;;;;;YC9CM;;YAKA,wBAAwB;EAClC,eAAe;;YAGL;EACV;EACA;EACA;;YAGU;;EAEV;;;;;EAKA;;EAGA;;YAGU;;;;;;;;;;;;;;;;;;;;;EAqBV,UAAU;;;;;;;;EAQV;;;;;;;EAOA;EACA;EACA;;;;;;EAMA;EACA;EACA;EACA,QAAQ;EACR;EACA;EACA;IAEM;IACA;;;;;;;EASN,WAAW;;EAEX;;;EAGA;;;EAGA;IACE;;;EAGF,UAAU;EACV,kBAAkB;EAClB;EACA;;;;;;EAMA;EACA,SAAS;;;;;;;;;;EAUT;;;;;;;;;;EAUA;;;;;;;;;EASA;IACE;IACA;IACA;;;;;;;;;;;EAWF;IACE;IACA;;;;;;;;;EASF,6BAA6B;;YAGnB;EACV;EACA,SAAS;;EAET,mBAAmB;;EAEnB,+BAA+B;;;EAG/B,mBAAmB;;EAEnB;;EAEA,iBAAiB;;EAEjB;IAAY,SAAS;;;EAErB;;EAEA,QAAQ;;;;;EAKR;;;;;;;;;;;;;EAaA,gBAAgB;;;;KC/MN;EAIV,iBAAiB;;EAEjB,mBAAmB;;EAEnB;;;;;;;EAOA,gBAAgB;;EAEhB;;EAEA;IACE;IACA,eAAe,QAAQ;;;EAGzB,sBAAsB;EACtB,SAAS;;;;;;EAMT,SAAS;;;;;EAKT,cAAc,KAAK;;EAEnB,wBAAwB;;wBAsBV,gCACd,gBAAgB,gBAChB,eAAe,sBACf,UAAS;;;;wBC5EK,kCACd,OAAO,wCACP,KAAK,OAAO,aACX;;qBAaU,oCAAoC;WACtC;IAAqB;IAAkB;;WACvC;EAET,YAAY,OAAO;SAcZ,QAAQ,iBAAiB,SAAS;;;;;;;;;wBC0K3B,gBAAgB,eAAe;;;;;;KA6D1C;EACH,QAAQ,OAAO;EACf,qBAAqB;;wBAGD,oBAAoB;EACxC;EACA;EACA,oBAAoB;EACpB;;;;;;;EAOA;EACA,QAAQ;IACN,QAAQ;;wBAwDI,8BACd,SAAS,KAAK,uDACd,oBACA,QAAQ;EACL;EAAmB,YAAY;;;;;;;;;;;;;;;;;;;qBAy7BvB,mBAAgB,UAClB,uBACR,QAAQ"}
package/dist/server.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as resolveUnsupportedStoredDocuments, i as StoredDocumentsRefusedError, n as isPortAvailable, o as applySwitchboardReactorDefaults, r as startSwitchboard, t as deriveAttachmentServiceConfig } from "./server-Dvw8_NeM.mjs";
3
- export { StoredDocumentsRefusedError, applySwitchboardReactorDefaults, deriveAttachmentServiceConfig, isPortAvailable, resolveUnsupportedStoredDocuments, startSwitchboard };
4
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b5bdc179-e9e6-50c3-a581-a82e554e7130")}catch(e){}}();
5
- //# debugId=b5bdc179-e9e6-50c3-a581-a82e554e7130
2
+ import { a as StoredDocumentsRefusedError, i as startSwitchboard, n as deriveAttachmentServiceConfig, o as resolveUnsupportedStoredDocuments, r as isPortAvailable, s as applySwitchboardReactorDefaults, t as createReactorKysely } from "./server-Zz7SqTwO.mjs";
3
+ export { StoredDocumentsRefusedError, applySwitchboardReactorDefaults, createReactorKysely, deriveAttachmentServiceConfig, isPortAvailable, resolveUnsupportedStoredDocuments, startSwitchboard };
4
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="ecf769b3-3c50-5e91-915e-6761e372127f")}catch(e){}}();
5
+ //# debugId=ecf769b3-3c50-5e91-915e-6761e372127f
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@powerhousedao/switchboard",
3
3
  "type": "module",
4
- "version": "6.2.3-dev.49",
4
+ "version": "6.2.3-dev.50",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
7
7
  ".": {
@@ -41,7 +41,7 @@
41
41
  "@openfeature/core": "1.9.1",
42
42
  "@openfeature/env-var-provider": "0.3.1",
43
43
  "@openfeature/server-sdk": "1.19.0",
44
- "@powerhousedao/config": "6.2.3-dev.49",
44
+ "@powerhousedao/config": "6.2.3-dev.50",
45
45
  "@opentelemetry/api": "^1.9.0",
46
46
  "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
47
47
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
@@ -55,22 +55,22 @@
55
55
  "@opentelemetry/sdk-node": "^0.218.0",
56
56
  "@opentelemetry/sdk-trace-base": "^2.7.1",
57
57
  "@opentelemetry/semantic-conventions": "^1.41.1",
58
- "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.49",
59
- "@powerhousedao/reactor": "6.2.3-dev.49",
60
- "@powerhousedao/shared": "6.2.3-dev.49",
61
- "@powerhousedao/vetra": "6.2.3-dev.49",
62
- "@powerhousedao/reactor-api": "6.2.3-dev.49",
63
- "@powerhousedao/reactor-attachments": "6.2.3-dev.49",
64
- "@powerhousedao/reactor-drive": "6.2.3-dev.49",
65
- "@powerhousedao/reactor-group": "6.2.3-dev.49",
66
- "@powerhousedao/reactor-privacy": "6.2.3-dev.49",
67
- "@powerhousedao/reactor-workflow": "6.2.3-dev.49",
68
- "@powerhousedao/workflow": "6.2.3-dev.49",
58
+ "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.50",
59
+ "@powerhousedao/reactor": "6.2.3-dev.50",
60
+ "@powerhousedao/shared": "6.2.3-dev.50",
61
+ "@powerhousedao/vetra": "6.2.3-dev.50",
62
+ "@powerhousedao/reactor-api": "6.2.3-dev.50",
63
+ "@powerhousedao/reactor-attachments": "6.2.3-dev.50",
64
+ "@powerhousedao/reactor-drive": "6.2.3-dev.50",
65
+ "@powerhousedao/reactor-group": "6.2.3-dev.50",
66
+ "@powerhousedao/reactor-privacy": "6.2.3-dev.50",
67
+ "@powerhousedao/reactor-workflow": "6.2.3-dev.50",
68
+ "@powerhousedao/workflow": "6.2.3-dev.50",
69
69
  "@pyroscope/nodejs": "^0.4.5",
70
- "@renown/sdk": "6.2.3-dev.49",
70
+ "@renown/sdk": "6.2.3-dev.50",
71
71
  "@sentry/node": "^10.53.1",
72
72
  "@sentry/opentelemetry": "^10.53.1",
73
- "document-model": "6.2.3-dev.49",
73
+ "document-model": "6.2.3-dev.50",
74
74
  "dotenv": "^16.4.7",
75
75
  "express": "^4.22.1",
76
76
  "graphql": "^16",
@@ -88,7 +88,7 @@
88
88
  "concurrently": "9.2.1",
89
89
  "nodemon": "3.1.11",
90
90
  "react": "19.2.6",
91
- "vitest": "4.1.1"
91
+ "vitest": "5.0.3"
92
92
  },
93
93
  "peerDependencies": {
94
94
  "react": ">=19.0.0"
@@ -1,7 +1,54 @@
1
1
  import { PGlite } from "@electric-sql/pglite";
2
2
  import { Kysely, sql } from "kysely";
3
- import { afterEach, describe, expect, it } from "vitest";
4
- import { ClosablePGliteDialect } from "../src/pglite-dialect.js";
3
+ import type { ILogger } from "document-model";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { createReactorKysely } from "../src/server.mjs";
6
+ import { EventEmitter } from "node:events";
7
+ import { installFatalErrorShutdown } from "../src/fatal-shutdown.mjs";
8
+ import {
9
+ ClosablePGliteDialect,
10
+ reactorPgliteDialectOptions,
11
+ } from "../src/pglite-dialect.js";
12
+
13
+ type Row = { id: number };
14
+ type Schema = { t: Row };
15
+
16
+ function stubLogger(): ILogger {
17
+ const logger = {
18
+ verbose: vi.fn(),
19
+ debug: vi.fn(),
20
+ info: vi.fn(),
21
+ warn: vi.fn(),
22
+ error: vi.fn(),
23
+ child: vi.fn(),
24
+ };
25
+ logger.child.mockReturnValue(logger);
26
+ return logger as unknown as ILogger;
27
+ }
28
+
29
+ /** COMMIT on an aborted transaction silently rolls back unless the dialect guards it. */
30
+ async function commitOfAnAbortedTransaction(
31
+ db: Kysely<Schema>,
32
+ ): Promise<{ outcome: unknown; rows: Row[] }> {
33
+ await sql`create table t (id int primary key)`.execute(db);
34
+ const outcome = await db
35
+ .transaction()
36
+ .execute(async (trx) => {
37
+ await sql`insert into t (id) values (1)`.execute(trx);
38
+ try {
39
+ await sql`select 1 / 0`.execute(trx);
40
+ } catch {
41
+ /* swallowed, exactly as an unrelated caller would */
42
+ }
43
+ return "JOB-SUCCEEDED";
44
+ })
45
+ .then(
46
+ (value: unknown) => value,
47
+ (error: Error) => ({ error: error.message }),
48
+ );
49
+ const rows = await sql<Row>`select id from t`.execute(db);
50
+ return { outcome, rows: rows.rows };
51
+ }
5
52
 
6
53
  describe("ClosablePGliteDialect", () => {
7
54
  const created: PGlite[] = [];
@@ -65,4 +112,86 @@ describe("ClosablePGliteDialect", () => {
65
112
 
66
113
  await expect(db.destroy()).resolves.toBeUndefined();
67
114
  });
115
+
116
+ it("refuses to report a commit for an aborted transaction", async () => {
117
+ const pglite = new PGlite();
118
+ created.push(pglite);
119
+ const db = new Kysely<Schema>({
120
+ dialect: new ClosablePGliteDialect(pglite, {
121
+ onDiagnostic: () => undefined,
122
+ }),
123
+ });
124
+
125
+ const { outcome, rows } = await commitOfAnAbortedTransaction(db);
126
+ expect(outcome).not.toBe("JOB-SUCCEEDED");
127
+ expect(rows).toEqual([]);
128
+
129
+ await db.destroy();
130
+ });
131
+ });
132
+
133
+ describe("switchboard's reactor storage factory", () => {
134
+ it("builds a PGlite kysely with the commit guard", async () => {
135
+ const storage = await createReactorKysely({
136
+ reactorDbUrl: undefined,
137
+ reactorPgliteDir: ".ph/unused-by-the-in-memory-branch",
138
+ reactorPgliteMajor: 17,
139
+ inMemory: true,
140
+ hostPoolSize: () => {
141
+ throw new Error("the PGlite branch must not read the host pool size");
142
+ },
143
+ logger: stubLogger(),
144
+ });
145
+
146
+ const { outcome, rows } = await commitOfAnAbortedTransaction(
147
+ storage.kysely as unknown as Kysely<Schema>,
148
+ );
149
+ expect(outcome).not.toBe("JOB-SUCCEEDED");
150
+ expect(rows).toEqual([]);
151
+ expect(storage.poolInstrumentation).toBeUndefined();
152
+
153
+ await storage.kysely.destroy();
154
+ });
155
+ });
156
+
157
+ describe("a poisoned reactor PGlite session", () => {
158
+ it("takes the fatal shutdown path", async () => {
159
+ const emitter = new EventEmitter();
160
+ const proc = Object.assign(emitter, {
161
+ pid: 1234,
162
+ exitCode: undefined as number | undefined,
163
+ kill: vi.fn(() => true),
164
+ exit: vi.fn(),
165
+ });
166
+ const logger = stubLogger();
167
+ installFatalErrorShutdown(logger, proc as never);
168
+
169
+ const pglite = new PGlite();
170
+ const dead = new Proxy(pglite, {
171
+ get(target, prop, receiver) {
172
+ if (prop === "query") {
173
+ return (text: string, params?: unknown[]) =>
174
+ /dead_call/.test(text)
175
+ ? new Promise(() => undefined)
176
+ : target.query(text, params);
177
+ }
178
+ const value = Reflect.get(target, prop, receiver) as unknown;
179
+ return typeof value === "function"
180
+ ? (value as (...args: unknown[]) => unknown).bind(target)
181
+ : value;
182
+ },
183
+ });
184
+ const db = new Kysely<Schema>({
185
+ dialect: new ClosablePGliteDialect(dead, {
186
+ ...reactorPgliteDialectOptions(logger, proc as never),
187
+ statementTimeoutMs: 50,
188
+ }),
189
+ });
190
+
191
+ await expect(sql`select 1 as dead_call`.execute(db)).rejects.toThrow();
192
+
193
+ expect(proc.kill).toHaveBeenCalledWith(1234, "SIGTERM");
194
+ expect(proc.exitCode).toBe(1);
195
+ await pglite.close();
196
+ });
68
197
  });
@@ -5,12 +5,14 @@ import { join } from "node:path";
5
5
  import {
6
6
  ReactorBuilder,
7
7
  ReactorClientBuilder,
8
+ type AttachmentRef,
8
9
  type Database,
9
10
  type InProcessReactorClientModule,
10
11
  type IReadModelCoordinator,
11
12
  } from "@powerhousedao/reactor";
12
13
  import {
13
14
  BaseSubgraph,
15
+ type CanonicalDocumentId,
14
16
  type GraphQLManager,
15
17
  type PackagePieceEntry,
16
18
  } from "@powerhousedao/reactor-api";
@@ -30,7 +32,7 @@ import { startSwitchboard } from "../src/server.mjs";
30
32
  import {
31
33
  PH_WORKFLOWS_ENABLED,
32
34
  bindPackagePieces,
33
- canReadAttachmentRef,
35
+ attachmentReadPolicy,
34
36
  composeWorkflowRuntime,
35
37
  assertWorkflowPackageLoadable,
36
38
  hostPrincipalOf,
@@ -203,16 +205,98 @@ describe("composeWorkflowRuntime", () => {
203
205
  return composed;
204
206
  }
205
207
 
206
- it("lets a step read any well-formed attachment ref", async () => {
207
- const hash = "a".repeat(64);
208
- // No document references it: runs read attachments as they read documents.
209
- expect(await canReadAttachmentRef("wf-1", `attachment://v1:${hash}`)).toBe(
210
- true,
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,
211
228
  );
212
- expect(await canReadAttachmentRef("wf-1", `attachment://v2:${hash}`)).toBe(
213
- false,
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,
214
295
  );
215
- expect(await canReadAttachmentRef("wf-1", "not-a-ref")).toBe(false);
296
+ expect(
297
+ await policy({ workflowId: "wf-1", ref, documentIds: ["doc-a"] }),
298
+ ).toBe(true);
299
+ expect(calls).toBe(3);
216
300
  });
217
301
 
218
302
  it("serves a subgraph the GraphQL manager can construct", async () => {