@powerhousedao/switchboard 6.2.3-dev.3 → 6.2.3-dev.30
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/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
- package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +45 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +94 -0
- package/CHANGELOG.md +272 -0
- package/README.md +14 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +6 -6
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/{server-B2r6AQ4Q.mjs → server-Cf0DB8sW.mjs} +1010 -65
- package/dist/server-Cf0DB8sW.mjs.map +1 -0
- package/dist/server.d.mts +74 -28
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +3 -4
- package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
- package/dist/utils-uaBlzyjS.mjs.map +1 -0
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +21 -17
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +287 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +194 -31
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/routes-integration.test.ts +59 -4
- package/test/attachments/routes.test.ts +165 -23
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/boot-create-signature-policy.test.ts +108 -0
- package/test/boot-without-renown.test.ts +81 -0
- package/test/boot-without-require-signatures.test.ts +74 -0
- package/test/boot-worker-trust-policy.test.ts +90 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/fatal-shutdown.test.ts +102 -0
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/workflow/harness.ts +237 -0
- package/test/workflow/resolvers.test.ts +105 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-unsigned.test.ts +332 -0
- package/test/workflow-read-gate.test.ts +145 -0
- package/test/workflow-runtime.test.ts +472 -0
- package/tsconfig.json +6 -0
- package/tsdown.config.ts +3 -1
- package/dist/server-B2r6AQ4Q.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Shared harness: the suites go over a real socket through the reactor's own
|
|
2
|
+
// adapter, route service and webhook service, which the unit tests cannot.
|
|
3
|
+
import {
|
|
4
|
+
createHttpAdapter,
|
|
5
|
+
getDbClient,
|
|
6
|
+
HttpRouteService,
|
|
7
|
+
MemoryWebhookStore,
|
|
8
|
+
WebhookService,
|
|
9
|
+
} from "@powerhousedao/reactor-api";
|
|
10
|
+
import {
|
|
11
|
+
createWorkflowRuntime,
|
|
12
|
+
type WorkflowRuntimeService,
|
|
13
|
+
} from "@powerhousedao/reactor-workflow";
|
|
14
|
+
import {
|
|
15
|
+
createRelationalDb,
|
|
16
|
+
type IRelationalDb,
|
|
17
|
+
} from "@powerhousedao/shared/processors";
|
|
18
|
+
import type { OperationWithContext } from "document-model";
|
|
19
|
+
import type { Kysely } from "kysely";
|
|
20
|
+
import { vi } from "vitest";
|
|
21
|
+
|
|
22
|
+
export const WORKFLOW_TYPE = "powerhouse/workflow";
|
|
23
|
+
export const PACKAGE_NAME = "@powerhousedao/workflow";
|
|
24
|
+
/** A ref the harness's secret store resolves; anything else rejects. */
|
|
25
|
+
export const SECRET_REF = "secret://v1:00112233445566778899aabbccddeeff";
|
|
26
|
+
export const SECRET = "s3cret";
|
|
27
|
+
// Minting an endpoint is a read of the workflow, so the harness asks as one.
|
|
28
|
+
const CALLER = { headers: {}, db: {}, user: { address: "0xabc" } } as never;
|
|
29
|
+
|
|
30
|
+
export interface FiredRun {
|
|
31
|
+
workflowId: string;
|
|
32
|
+
payload: unknown;
|
|
33
|
+
kind: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WebhookHost {
|
|
37
|
+
/** Origin the endpoint is served from, e.g. `http://127.0.0.1:53124`. */
|
|
38
|
+
readonly url: string;
|
|
39
|
+
/** Runs the trigger started, in order. Cleared by `arm`. */
|
|
40
|
+
readonly fired: FiredRun[];
|
|
41
|
+
readonly service: WorkflowRuntimeService;
|
|
42
|
+
/** Publishes an ENABLED workflow with `config`; returns its endpoint. */
|
|
43
|
+
arm(
|
|
44
|
+
config: Record<string, unknown>,
|
|
45
|
+
options?: { blockType?: string; workflowId?: string },
|
|
46
|
+
): Promise<{ token: string; url: string }>;
|
|
47
|
+
/** Publishes the same workflow as DISABLED, keeping its endpoint row. */
|
|
48
|
+
disarm(workflowId?: string): Promise<void>;
|
|
49
|
+
/** The policy the service hands the reactor for one endpoint. */
|
|
50
|
+
policyFor(workflowId?: string): Promise<unknown>;
|
|
51
|
+
deliver(
|
|
52
|
+
token: string,
|
|
53
|
+
init?: {
|
|
54
|
+
method?: string;
|
|
55
|
+
headers?: Record<string, string>;
|
|
56
|
+
body?: string;
|
|
57
|
+
query?: Record<string, string>;
|
|
58
|
+
},
|
|
59
|
+
): Promise<Response>;
|
|
60
|
+
stop(): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const DEFAULT_WORKFLOW = "wf-integration";
|
|
64
|
+
|
|
65
|
+
// Module-scoped: the service ignores any ordinal at or below the highest it
|
|
66
|
+
// has seen, so a per-host counter would make a file's second host see nothing.
|
|
67
|
+
let ordinal = 0;
|
|
68
|
+
|
|
69
|
+
export function workflowOperation(
|
|
70
|
+
state: Record<string, unknown>,
|
|
71
|
+
workflowId = DEFAULT_WORKFLOW,
|
|
72
|
+
): OperationWithContext {
|
|
73
|
+
ordinal += 1;
|
|
74
|
+
return {
|
|
75
|
+
operation: {
|
|
76
|
+
index: ordinal,
|
|
77
|
+
timestampUtcMs: `${ordinal}`,
|
|
78
|
+
action: { type: "SET_WORKFLOW_NAME", input: {} },
|
|
79
|
+
resultingState: JSON.stringify(state),
|
|
80
|
+
},
|
|
81
|
+
context: {
|
|
82
|
+
documentId: workflowId,
|
|
83
|
+
documentType: WORKFLOW_TYPE,
|
|
84
|
+
scope: "global",
|
|
85
|
+
branch: "main",
|
|
86
|
+
ordinal,
|
|
87
|
+
},
|
|
88
|
+
} as unknown as OperationWithContext;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `publicUrl` is known only after `listen`: a family registered against the
|
|
92
|
+
// wrong origin advertises URLs no caller can reach.
|
|
93
|
+
export async function startWebhookHost(
|
|
94
|
+
options: { fire?: (run: FiredRun) => unknown } = {},
|
|
95
|
+
): Promise<WebhookHost> {
|
|
96
|
+
// The public factory, not the adapter class: the class is internal, so
|
|
97
|
+
// constructing it would test a path no consumer can take.
|
|
98
|
+
const { adapter } = await createHttpAdapter("express");
|
|
99
|
+
adapter.setupMiddleware({});
|
|
100
|
+
const server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
101
|
+
const { port } = server.address() as { port: number };
|
|
102
|
+
const url = `http://127.0.0.1:${port}`;
|
|
103
|
+
|
|
104
|
+
const webhooks = new WebhookService({ store: new MemoryWebhookStore() });
|
|
105
|
+
const routes = new HttpRouteService({
|
|
106
|
+
httpAdapter: adapter,
|
|
107
|
+
webhooks,
|
|
108
|
+
publicUrl: url,
|
|
109
|
+
});
|
|
110
|
+
// Core serves the endpoint family from its own host scope, exactly as the
|
|
111
|
+
// reactor wires it: nothing here touches the adapter directly.
|
|
112
|
+
webhooks.attach(routes.hostScope("@powerhousedao/reactor-api", "/webhooks"));
|
|
113
|
+
|
|
114
|
+
const { db } = getDbClient();
|
|
115
|
+
const fired: FiredRun[] = [];
|
|
116
|
+
|
|
117
|
+
// Every host surface the runtime asks for, and nothing it does not: no run
|
|
118
|
+
// here reaches the reactor, and the secret store answers one fixed ref.
|
|
119
|
+
const service = createWorkflowRuntime({
|
|
120
|
+
relationalDb: createRelationalDb(
|
|
121
|
+
db as unknown as Kysely<unknown>,
|
|
122
|
+
) as IRelationalDb,
|
|
123
|
+
reactorClient: {
|
|
124
|
+
get: () => Promise.reject(new Error("not used")),
|
|
125
|
+
find: () => Promise.resolve({ results: [] }),
|
|
126
|
+
},
|
|
127
|
+
assertCanRead: () => Promise.resolve(undefined),
|
|
128
|
+
assertCanWrite: () => Promise.resolve(undefined),
|
|
129
|
+
webhooks: routes.scopeFor(PACKAGE_NAME).webhooks,
|
|
130
|
+
secrets: {
|
|
131
|
+
get: (ref: string) =>
|
|
132
|
+
ref === SECRET_REF
|
|
133
|
+
? Promise.resolve(SECRET)
|
|
134
|
+
: Promise.reject(new Error(`No secret found for ref "${ref}"`)),
|
|
135
|
+
},
|
|
136
|
+
} as never);
|
|
137
|
+
vi.spyOn(service, "fire").mockImplementation(
|
|
138
|
+
(workflowId: string, payload?: unknown, kind = "manual") => {
|
|
139
|
+
const run = { workflowId, payload, kind };
|
|
140
|
+
fired.push(run);
|
|
141
|
+
const outcome = options.fire?.(run);
|
|
142
|
+
return Promise.resolve(
|
|
143
|
+
(outcome ?? {
|
|
144
|
+
runId: `run-${fired.length}`,
|
|
145
|
+
status: "SUCCEEDED",
|
|
146
|
+
steps: [],
|
|
147
|
+
}) as never,
|
|
148
|
+
);
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
await service.registerWebhookEndpoint();
|
|
153
|
+
|
|
154
|
+
const publish = async (
|
|
155
|
+
config: Record<string, unknown>,
|
|
156
|
+
status: string,
|
|
157
|
+
blockType: string,
|
|
158
|
+
workflowId: string,
|
|
159
|
+
) => {
|
|
160
|
+
await service.onOperations([
|
|
161
|
+
workflowOperation(
|
|
162
|
+
{
|
|
163
|
+
name: "Integration",
|
|
164
|
+
status,
|
|
165
|
+
version: ordinal + 1,
|
|
166
|
+
trigger: { id: "t1", blockType, config },
|
|
167
|
+
steps: [],
|
|
168
|
+
edges: [],
|
|
169
|
+
variables: [],
|
|
170
|
+
},
|
|
171
|
+
workflowId,
|
|
172
|
+
),
|
|
173
|
+
]);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
url,
|
|
178
|
+
fired,
|
|
179
|
+
service,
|
|
180
|
+
async arm(config, opts = {}) {
|
|
181
|
+
const workflowId = opts.workflowId ?? DEFAULT_WORKFLOW;
|
|
182
|
+
await publish(
|
|
183
|
+
config,
|
|
184
|
+
"ENABLED",
|
|
185
|
+
opts.blockType ?? "core#webhook",
|
|
186
|
+
workflowId,
|
|
187
|
+
);
|
|
188
|
+
const endpoint = await service.webhookEndpoint(workflowId, CALLER);
|
|
189
|
+
if (!endpoint) throw new Error(`No endpoint minted for ${workflowId}`);
|
|
190
|
+
fired.length = 0;
|
|
191
|
+
return {
|
|
192
|
+
url: endpoint.url,
|
|
193
|
+
token: endpoint.url.slice(endpoint.url.lastIndexOf("/") + 1),
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
async disarm(workflowId = DEFAULT_WORKFLOW) {
|
|
197
|
+
await publish({}, "DISABLED", "core#webhook", workflowId);
|
|
198
|
+
},
|
|
199
|
+
policyFor(workflowId = DEFAULT_WORKFLOW) {
|
|
200
|
+
return service.webhookPolicy(workflowId);
|
|
201
|
+
},
|
|
202
|
+
deliver(token, init = {}) {
|
|
203
|
+
const query = init.query
|
|
204
|
+
? `?${new URLSearchParams(init.query).toString()}`
|
|
205
|
+
: "";
|
|
206
|
+
return fetch(`${url}/webhooks/${token}${query}`, {
|
|
207
|
+
method: init.method ?? "POST",
|
|
208
|
+
headers: init.headers,
|
|
209
|
+
body: init.body,
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
stop() {
|
|
213
|
+
return new Promise<void>((resolve, reject) =>
|
|
214
|
+
server.close((error) => (error ? reject(error) : resolve())),
|
|
215
|
+
);
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A delivery is answered before its run starts, so asserting on `fired`
|
|
221
|
+
// straight after a 202 is a race that passes for the wrong reason.
|
|
222
|
+
export async function waitForRuns(
|
|
223
|
+
host: WebhookHost,
|
|
224
|
+
count: number,
|
|
225
|
+
timeoutMs = 2000,
|
|
226
|
+
): Promise<FiredRun[]> {
|
|
227
|
+
const deadline = Date.now() + timeoutMs;
|
|
228
|
+
while (host.fired.length < count && Date.now() < deadline) {
|
|
229
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
230
|
+
}
|
|
231
|
+
if (host.fired.length < count) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Expected ${count} run(s), saw ${host.fired.length} within ${timeoutMs}ms`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return host.fired;
|
|
237
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// What the workflow subgraph does with the caller behind a request: every
|
|
2
|
+
// workflow-scoped field hands it to the runtime, and secret writes are admins'.
|
|
3
|
+
import type {
|
|
4
|
+
Context,
|
|
5
|
+
IAuthorizationService,
|
|
6
|
+
} from "@powerhousedao/reactor-api";
|
|
7
|
+
import type { WorkflowRuntimeService } from "@powerhousedao/reactor-workflow";
|
|
8
|
+
import { describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { getResolvers } from "../../src/workflow/resolvers.js";
|
|
10
|
+
|
|
11
|
+
const CTX = {
|
|
12
|
+
headers: {},
|
|
13
|
+
db: {},
|
|
14
|
+
user: { address: "0xadmin" },
|
|
15
|
+
} as unknown as Context;
|
|
16
|
+
|
|
17
|
+
type Resolver = (
|
|
18
|
+
parent: unknown,
|
|
19
|
+
args: unknown,
|
|
20
|
+
ctx: Context,
|
|
21
|
+
) => Promise<unknown>;
|
|
22
|
+
|
|
23
|
+
function fakeRuntime() {
|
|
24
|
+
return {
|
|
25
|
+
webhookEndpoint: vi.fn(() => Promise.resolve(null)),
|
|
26
|
+
triggerStates: vi.fn(() => Promise.resolve([])),
|
|
27
|
+
runs: vi.fn(() => Promise.resolve([])),
|
|
28
|
+
run: vi.fn(() => Promise.resolve(null)),
|
|
29
|
+
fire: vi.fn(() => Promise.resolve({})),
|
|
30
|
+
rerun: vi.fn(() => Promise.resolve({})),
|
|
31
|
+
secrets: vi.fn(() =>
|
|
32
|
+
Promise.resolve({
|
|
33
|
+
create: vi.fn(() => Promise.resolve({ ref: "secret://v1:00" })),
|
|
34
|
+
rotate: vi.fn(() => Promise.resolve({ ref: "secret://v1:00" })),
|
|
35
|
+
delete: vi.fn(() => Promise.resolve()),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function build(isAdmin: boolean) {
|
|
42
|
+
const runtime = fakeRuntime();
|
|
43
|
+
const authorizationService = {
|
|
44
|
+
isSupremeAdmin: vi.fn(() => isAdmin),
|
|
45
|
+
} as unknown as IAuthorizationService;
|
|
46
|
+
const resolvers = getResolvers(
|
|
47
|
+
runtime as unknown as WorkflowRuntimeService,
|
|
48
|
+
authorizationService,
|
|
49
|
+
) as Record<string, Record<string, Resolver>>;
|
|
50
|
+
return {
|
|
51
|
+
runtime,
|
|
52
|
+
queries: resolvers.WorkflowRuntimeQueries,
|
|
53
|
+
mutations: resolvers.WorkflowRuntimeMutations,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("the workflow resolvers and the caller", () => {
|
|
58
|
+
it("hands the caller to every workflow-scoped field", async () => {
|
|
59
|
+
const { runtime, queries, mutations } = build(true);
|
|
60
|
+
|
|
61
|
+
await queries.webhookEndpoint({}, { workflowId: "wf-1" }, CTX);
|
|
62
|
+
await queries.triggerStates({}, {}, CTX);
|
|
63
|
+
await queries.runs({}, { driveId: "drive-1" }, CTX);
|
|
64
|
+
await queries.run({}, { id: "run-1" }, CTX);
|
|
65
|
+
await mutations.fire({}, { workflowId: "wf-1", payload: { a: 1 } }, CTX);
|
|
66
|
+
await mutations.rerun({}, { runId: "run-1" }, CTX);
|
|
67
|
+
|
|
68
|
+
expect(runtime.webhookEndpoint).toHaveBeenCalledWith("wf-1", CTX);
|
|
69
|
+
expect(runtime.triggerStates).toHaveBeenCalledWith(CTX);
|
|
70
|
+
expect(runtime.runs).toHaveBeenCalledWith({ driveId: "drive-1" }, CTX);
|
|
71
|
+
expect(runtime.run).toHaveBeenCalledWith("run-1", CTX);
|
|
72
|
+
expect(runtime.fire).toHaveBeenCalledWith(
|
|
73
|
+
"wf-1",
|
|
74
|
+
{ a: 1 },
|
|
75
|
+
"manual",
|
|
76
|
+
undefined,
|
|
77
|
+
CTX,
|
|
78
|
+
);
|
|
79
|
+
expect(runtime.rerun).toHaveBeenCalledWith("run-1", CTX);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("refuses secret writes to a caller who does not administer the reactor", async () => {
|
|
83
|
+
const { runtime, mutations } = build(false);
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
mutations.createSecret({}, { value: "s3cret" }, CTX),
|
|
87
|
+
).rejects.toThrow("Admin access required");
|
|
88
|
+
await expect(
|
|
89
|
+
mutations.rotateSecret({}, { ref: "secret://v1:00", value: "s" }, CTX),
|
|
90
|
+
).rejects.toThrow("Admin access required");
|
|
91
|
+
await expect(
|
|
92
|
+
mutations.deleteSecret({}, { ref: "secret://v1:00" }, CTX),
|
|
93
|
+
).rejects.toThrow("Admin access required");
|
|
94
|
+
// Refused before the store is ever opened.
|
|
95
|
+
expect(runtime.secrets).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("lets an administrator write secrets", async () => {
|
|
99
|
+
const { runtime, mutations } = build(true);
|
|
100
|
+
|
|
101
|
+
await mutations.createSecret({}, { value: "s3cret", label: "slack" }, CTX);
|
|
102
|
+
|
|
103
|
+
expect(runtime.secrets).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
});
|