@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/reserve-metadata-parity.test.d.ts +2 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.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-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.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/boot-workflow-singleton-conflict.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts +2 -0
- package/.tsbuild/test/boot-workflow-singleton-retry.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/durable-node-fs-pg16.test.d.ts +2 -0
- package/.tsbuild/test/durable-node-fs-pg16.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/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
- package/.tsbuild/test/observability-signals.test.d.ts +2 -0
- package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
- package/.tsbuild/test/pglite-preflight.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/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +56 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
- package/.tsbuild/test/workflow/oauth.test.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-trigger-test.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.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-reactor-access.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-enforcement.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 +541 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +48 -49
- 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-J9fpd42v.mjs +3549 -0
- package/dist/server-J9fpd42v.mjs.map +1 -0
- package/dist/server.d.mts +155 -30
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +4 -5
- 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 +22 -18
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +290 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +201 -32
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +60 -4
- package/test/attachments/routes.test.ts +166 -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-mcp-enabled.test.ts +121 -0
- package/test/boot-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +213 -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/boot-workflow-singleton-conflict.test.ts +107 -0
- package/test/boot-workflow-singleton-retry.test.ts +133 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +165 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +217 -2
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/snapshot-writer.ts +94 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +357 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-trigger-test.test.ts +82 -0
- package/test/workflow/webhook-unsigned.test.ts +345 -0
- package/test/workflow-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +808 -0
- package/test/workflow-read-gate.test.ts +213 -0
- package/test/workflow-runtime.test.ts +1067 -0
- package/tsconfig.json +9 -3
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs +0 -1555
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Test-only v1 snapshot.bin writer over a loose on-disk tree.
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import type { FileHandle } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const MAGIC = new Uint8Array([0x50, 0x47, 0x4c, 0x41]);
|
|
7
|
+
const FORMAT_VERSION = 1;
|
|
8
|
+
const HEADER_SIZE = 12;
|
|
9
|
+
const ENTRY_PREFIX_SIZE = 9;
|
|
10
|
+
const DEFAULT_SKIP = ["snapshot.bin", "snapshot.bin.tmp"];
|
|
11
|
+
|
|
12
|
+
export interface SnapshotEntry {
|
|
13
|
+
type: 0 | 1;
|
|
14
|
+
mode: number;
|
|
15
|
+
relPath: string;
|
|
16
|
+
data?: Buffer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WriteSnapshotOptions {
|
|
20
|
+
/** Root-level names to leave out. Defaults to the snapshot files. */
|
|
21
|
+
skip?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function collectDiskEntries(
|
|
25
|
+
dataDir: string,
|
|
26
|
+
options: WriteSnapshotOptions = {},
|
|
27
|
+
): Promise<SnapshotEntry[]> {
|
|
28
|
+
const skip = new Set(options.skip ?? DEFAULT_SKIP);
|
|
29
|
+
const entries: SnapshotEntry[] = [];
|
|
30
|
+
const walk = async (dir: string, rel: string) => {
|
|
31
|
+
const names = (await fs.readdir(dir)).sort();
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
if (rel === "" && skip.has(name)) continue;
|
|
34
|
+
const full = path.join(dir, name);
|
|
35
|
+
const relPath = rel === "" ? name : `${rel}/${name}`;
|
|
36
|
+
const stat = await fs.lstat(full);
|
|
37
|
+
if (stat.isDirectory()) {
|
|
38
|
+
entries.push({ type: 0, mode: stat.mode & 0o7777, relPath });
|
|
39
|
+
await walk(full, relPath);
|
|
40
|
+
} else if (stat.isFile()) {
|
|
41
|
+
entries.push({
|
|
42
|
+
type: 1,
|
|
43
|
+
mode: stat.mode & 0o7777,
|
|
44
|
+
relPath,
|
|
45
|
+
data: await fs.readFile(full),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
await walk(dataDir, "");
|
|
51
|
+
return entries;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function encodeSnapshot(entries: SnapshotEntry[]): Buffer {
|
|
55
|
+
const encoder = new TextEncoder();
|
|
56
|
+
const parts: Buffer[] = [];
|
|
57
|
+
const header = Buffer.alloc(HEADER_SIZE);
|
|
58
|
+
header.set(MAGIC, 0);
|
|
59
|
+
header.writeUInt32LE(FORMAT_VERSION, 4);
|
|
60
|
+
header.writeUInt32LE(entries.length, 8);
|
|
61
|
+
parts.push(header);
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const pathBytes = encoder.encode(entry.relPath);
|
|
64
|
+
const dataLen = entry.data?.byteLength ?? 0;
|
|
65
|
+
const prefix = Buffer.alloc(ENTRY_PREFIX_SIZE + pathBytes.byteLength + 4);
|
|
66
|
+
prefix.writeUInt8(entry.type, 0);
|
|
67
|
+
prefix.writeUInt32LE(entry.mode, 1);
|
|
68
|
+
prefix.writeUInt32LE(pathBytes.byteLength, 5);
|
|
69
|
+
prefix.set(pathBytes, ENTRY_PREFIX_SIZE);
|
|
70
|
+
prefix.writeUInt32LE(dataLen, ENTRY_PREFIX_SIZE + pathBytes.byteLength);
|
|
71
|
+
parts.push(prefix);
|
|
72
|
+
if (entry.data && dataLen > 0) parts.push(entry.data);
|
|
73
|
+
}
|
|
74
|
+
return Buffer.concat(parts);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Serializes `dataDir` into `snapshotPath`. Returns the entry count and size. */
|
|
78
|
+
export async function writeSnapshotFromDir(
|
|
79
|
+
dataDir: string,
|
|
80
|
+
snapshotPath: string,
|
|
81
|
+
options: WriteSnapshotOptions = {},
|
|
82
|
+
): Promise<{ entries: number; bytes: number }> {
|
|
83
|
+
const entries = await collectDiskEntries(dataDir, options);
|
|
84
|
+
const bytes = encodeSnapshot(entries);
|
|
85
|
+
let fh: FileHandle | undefined;
|
|
86
|
+
try {
|
|
87
|
+
fh = await fs.open(snapshotPath, "w");
|
|
88
|
+
await fh.writeFile(bytes);
|
|
89
|
+
await fh.sync();
|
|
90
|
+
} finally {
|
|
91
|
+
await fh?.close();
|
|
92
|
+
}
|
|
93
|
+
return { entries: entries.length, bytes: bytes.byteLength };
|
|
94
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { UnsupportedStoredProtocolError } from "@powerhousedao/reactor";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
StoredDocumentsRefusedError,
|
|
5
|
+
resolveUnsupportedStoredDocuments,
|
|
6
|
+
} from "../src/unsupported-stored-documents.mjs";
|
|
7
|
+
|
|
8
|
+
describe("resolveUnsupportedStoredDocuments", () => {
|
|
9
|
+
it("refuses by default", () => {
|
|
10
|
+
expect(resolveUnsupportedStoredDocuments(undefined, {})).toBe("refuse");
|
|
11
|
+
expect(
|
|
12
|
+
resolveUnsupportedStoredDocuments(undefined, {
|
|
13
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: " ",
|
|
14
|
+
}),
|
|
15
|
+
).toBe("refuse");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("reads REACTOR_UNSUPPORTED_STORED_DOCUMENTS", () => {
|
|
19
|
+
expect(
|
|
20
|
+
resolveUnsupportedStoredDocuments(undefined, {
|
|
21
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: " read-only ",
|
|
22
|
+
}),
|
|
23
|
+
).toBe("read-only");
|
|
24
|
+
expect(
|
|
25
|
+
resolveUnsupportedStoredDocuments(undefined, {
|
|
26
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: "refuse",
|
|
27
|
+
}),
|
|
28
|
+
).toBe("refuse");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("lets the option win over the env var", () => {
|
|
32
|
+
expect(
|
|
33
|
+
resolveUnsupportedStoredDocuments("refuse", {
|
|
34
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: "read-only",
|
|
35
|
+
}),
|
|
36
|
+
).toBe("refuse");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("refuses an unknown value", () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
resolveUnsupportedStoredDocuments(undefined, {
|
|
42
|
+
REACTOR_UNSUPPORTED_STORED_DOCUMENTS: "readonly",
|
|
43
|
+
}),
|
|
44
|
+
).toThrow(
|
|
45
|
+
'REACTOR_UNSUPPORTED_STORED_DOCUMENTS must be "refuse" or "read-only", got "readonly"',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("StoredDocumentsRefusedError", () => {
|
|
51
|
+
it("names the versions, the count and both ways forward", () => {
|
|
52
|
+
const cause = new UnsupportedStoredProtocolError(
|
|
53
|
+
[
|
|
54
|
+
{ protocol: "base-reducer", version: 3 },
|
|
55
|
+
{ protocol: "base-reducer", version: 7 },
|
|
56
|
+
],
|
|
57
|
+
4,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const error = new StoredDocumentsRefusedError(cause);
|
|
61
|
+
|
|
62
|
+
expect(error.message).toBe(
|
|
63
|
+
"Refusing to start: 4 stored document(s) require base-reducer 3, base-reducer 7, which this switchboard does not run. " +
|
|
64
|
+
"Either start a switchboard build that runs base-reducer 3, base-reducer 7, or set REACTOR_UNSUPPORTED_STORED_DOCUMENTS=read-only to start with those documents read-only.",
|
|
65
|
+
);
|
|
66
|
+
expect(error.documents).toBe(4);
|
|
67
|
+
expect(error.versions).toBe(cause.versions);
|
|
68
|
+
expect(error.cause).toBe(cause);
|
|
69
|
+
expect(StoredDocumentsRefusedError.isError(error)).toBe(true);
|
|
70
|
+
expect(StoredDocumentsRefusedError.isError(cause)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/test/worker-pool.test.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
import {
|
|
22
|
+
CORE_PIECE_NAME,
|
|
23
|
+
CORE_PIECE_VERSION,
|
|
24
|
+
} from "@powerhousedao/reactor-workflow";
|
|
25
|
+
|
|
26
|
+
export const WORKFLOW_TYPE = "powerhouse/workflow";
|
|
27
|
+
export const PACKAGE_NAME = "@powerhousedao/workflow";
|
|
28
|
+
/** A ref the harness's secret store resolves; anything else rejects. */
|
|
29
|
+
export const SECRET_REF = "secret://v1:00112233445566778899aabbccddeeff";
|
|
30
|
+
export const SECRET = "s3cret";
|
|
31
|
+
// Minting an endpoint is a read of the workflow, so the harness asks as one.
|
|
32
|
+
const CALLER = { headers: {}, db: {}, user: { address: "0xabc" } } as never;
|
|
33
|
+
|
|
34
|
+
export interface FiredRun {
|
|
35
|
+
workflowId: string;
|
|
36
|
+
payload: unknown;
|
|
37
|
+
kind: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TriggerFields {
|
|
41
|
+
pieceName: string;
|
|
42
|
+
pieceVersion: string;
|
|
43
|
+
triggerName: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The core piece's webhook trigger, pinned to the installed core piece.
|
|
47
|
+
export const WEBHOOK_TRIGGER: TriggerFields = {
|
|
48
|
+
pieceName: CORE_PIECE_NAME,
|
|
49
|
+
pieceVersion: CORE_PIECE_VERSION,
|
|
50
|
+
triggerName: "webhook",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface WebhookHost {
|
|
54
|
+
/** Origin the endpoint is served from, e.g. `http://127.0.0.1:53124`. */
|
|
55
|
+
readonly url: string;
|
|
56
|
+
/** Runs the trigger started, in order. Cleared by `arm`. */
|
|
57
|
+
readonly fired: FiredRun[];
|
|
58
|
+
readonly service: WorkflowRuntimeService;
|
|
59
|
+
/** Publishes an ENABLED workflow with `config`; returns its endpoint. */
|
|
60
|
+
arm(
|
|
61
|
+
config: Record<string, unknown>,
|
|
62
|
+
options?: { trigger?: TriggerFields; workflowId?: string },
|
|
63
|
+
): Promise<{ token: string; url: string }>;
|
|
64
|
+
/** Publishes the same workflow as DISABLED, keeping its endpoint row. */
|
|
65
|
+
disarm(workflowId?: string): Promise<void>;
|
|
66
|
+
/** Deletes the workflow document, as the reactor reports it. */
|
|
67
|
+
remove(workflowId?: string): Promise<void>;
|
|
68
|
+
/** Whether the reactor's webhook store still holds this token. */
|
|
69
|
+
hasToken(token: string): Promise<boolean>;
|
|
70
|
+
/** The policy the service hands the reactor for one endpoint. */
|
|
71
|
+
policyFor(workflowId?: string): Promise<unknown>;
|
|
72
|
+
deliver(
|
|
73
|
+
token: string,
|
|
74
|
+
init?: {
|
|
75
|
+
method?: string;
|
|
76
|
+
headers?: Record<string, string>;
|
|
77
|
+
body?: string;
|
|
78
|
+
query?: Record<string, string>;
|
|
79
|
+
},
|
|
80
|
+
): Promise<Response>;
|
|
81
|
+
stop(): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const DEFAULT_WORKFLOW = "wf-integration";
|
|
85
|
+
|
|
86
|
+
// Module-scoped: the service ignores any ordinal at or below the highest it
|
|
87
|
+
// has seen, so a per-host counter would make a file's second host see nothing.
|
|
88
|
+
let ordinal = 0;
|
|
89
|
+
|
|
90
|
+
export function workflowOperation(
|
|
91
|
+
state: Record<string, unknown>,
|
|
92
|
+
workflowId = DEFAULT_WORKFLOW,
|
|
93
|
+
): OperationWithContext {
|
|
94
|
+
ordinal += 1;
|
|
95
|
+
return {
|
|
96
|
+
operation: {
|
|
97
|
+
index: ordinal,
|
|
98
|
+
timestampUtcMs: `${ordinal}`,
|
|
99
|
+
action: { type: "SET_WORKFLOW_NAME", input: {} },
|
|
100
|
+
resultingState: JSON.stringify(state),
|
|
101
|
+
},
|
|
102
|
+
context: {
|
|
103
|
+
documentId: workflowId,
|
|
104
|
+
documentType: WORKFLOW_TYPE,
|
|
105
|
+
scope: "global",
|
|
106
|
+
branch: "main",
|
|
107
|
+
ordinal,
|
|
108
|
+
},
|
|
109
|
+
} as unknown as OperationWithContext;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// `publicUrl` is known only after `listen`: a family registered against the
|
|
113
|
+
// wrong origin advertises URLs no caller can reach.
|
|
114
|
+
export async function startWebhookHost(
|
|
115
|
+
options: {
|
|
116
|
+
fire?: (run: FiredRun) => unknown;
|
|
117
|
+
// Reads and writes of the workflow documents; none by default.
|
|
118
|
+
reactorClient?: unknown;
|
|
119
|
+
} = {},
|
|
120
|
+
): Promise<WebhookHost> {
|
|
121
|
+
// The public factory, not the adapter class: the class is internal, so
|
|
122
|
+
// constructing it would test a path no consumer can take.
|
|
123
|
+
const { adapter } = await createHttpAdapter("express");
|
|
124
|
+
adapter.setupMiddleware({});
|
|
125
|
+
const server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
126
|
+
const { port } = server.address() as { port: number };
|
|
127
|
+
const url = `http://127.0.0.1:${port}`;
|
|
128
|
+
|
|
129
|
+
const webhookStore = new MemoryWebhookStore();
|
|
130
|
+
const webhooks = new WebhookService({ store: webhookStore });
|
|
131
|
+
const routes = new HttpRouteService({
|
|
132
|
+
httpAdapter: adapter,
|
|
133
|
+
webhooks,
|
|
134
|
+
publicUrl: url,
|
|
135
|
+
});
|
|
136
|
+
// Core serves the endpoint family from its own host scope, exactly as the
|
|
137
|
+
// reactor wires it: nothing here touches the adapter directly.
|
|
138
|
+
webhooks.attach(routes.hostScope("@powerhousedao/reactor-api", "/webhooks"));
|
|
139
|
+
|
|
140
|
+
const { db } = getDbClient();
|
|
141
|
+
const fired: FiredRun[] = [];
|
|
142
|
+
|
|
143
|
+
// Every host surface the runtime asks for, and nothing it does not: no run
|
|
144
|
+
// here reaches the reactor, and the secret store answers one fixed ref.
|
|
145
|
+
const service = createWorkflowRuntime({
|
|
146
|
+
relationalDb: createRelationalDb(
|
|
147
|
+
db as unknown as Kysely<unknown>,
|
|
148
|
+
) as IRelationalDb,
|
|
149
|
+
reactorClient: options.reactorClient ?? {
|
|
150
|
+
get: () => Promise.reject(new Error("not used")),
|
|
151
|
+
find: () => Promise.resolve({ results: [] }),
|
|
152
|
+
},
|
|
153
|
+
assertCanRead: () => Promise.resolve(undefined),
|
|
154
|
+
assertCanWrite: () => Promise.resolve(undefined),
|
|
155
|
+
webhooks: routes.scopeFor(PACKAGE_NAME).webhooks,
|
|
156
|
+
secrets: {
|
|
157
|
+
get: (ref: string) =>
|
|
158
|
+
ref === SECRET_REF
|
|
159
|
+
? Promise.resolve(SECRET)
|
|
160
|
+
: Promise.reject(new Error(`No secret found for ref "${ref}"`)),
|
|
161
|
+
},
|
|
162
|
+
} as never);
|
|
163
|
+
vi.spyOn(service, "fire").mockImplementation(
|
|
164
|
+
(workflowId: string, payload?: unknown, kind = "manual") => {
|
|
165
|
+
const run = { workflowId, payload, kind };
|
|
166
|
+
fired.push(run);
|
|
167
|
+
const outcome = options.fire?.(run);
|
|
168
|
+
return Promise.resolve(
|
|
169
|
+
(outcome ?? {
|
|
170
|
+
runId: `run-${fired.length}`,
|
|
171
|
+
status: "SUCCEEDED",
|
|
172
|
+
steps: [],
|
|
173
|
+
}) as never,
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await service.registerWebhookEndpoint();
|
|
179
|
+
|
|
180
|
+
const publish = async (
|
|
181
|
+
config: Record<string, unknown>,
|
|
182
|
+
status: string,
|
|
183
|
+
trigger: TriggerFields,
|
|
184
|
+
workflowId: string,
|
|
185
|
+
) => {
|
|
186
|
+
await service.onOperations([
|
|
187
|
+
workflowOperation(
|
|
188
|
+
{
|
|
189
|
+
name: "Integration",
|
|
190
|
+
status,
|
|
191
|
+
version: ordinal + 1,
|
|
192
|
+
trigger: { id: "t1", ...trigger, config },
|
|
193
|
+
steps: [],
|
|
194
|
+
edges: [],
|
|
195
|
+
variables: [],
|
|
196
|
+
},
|
|
197
|
+
workflowId,
|
|
198
|
+
),
|
|
199
|
+
]);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
url,
|
|
204
|
+
fired,
|
|
205
|
+
service,
|
|
206
|
+
async arm(config, opts = {}) {
|
|
207
|
+
const workflowId = opts.workflowId ?? DEFAULT_WORKFLOW;
|
|
208
|
+
// The runtime requires a scheme; an unsigned suite names "none".
|
|
209
|
+
await publish(
|
|
210
|
+
{ scheme: "none", ...config },
|
|
211
|
+
"ENABLED",
|
|
212
|
+
opts.trigger ?? WEBHOOK_TRIGGER,
|
|
213
|
+
workflowId,
|
|
214
|
+
);
|
|
215
|
+
const endpoint = await service.webhookEndpoint(workflowId, CALLER);
|
|
216
|
+
if (!endpoint) throw new Error(`No endpoint minted for ${workflowId}`);
|
|
217
|
+
fired.length = 0;
|
|
218
|
+
return {
|
|
219
|
+
url: endpoint.url,
|
|
220
|
+
token: endpoint.url.slice(endpoint.url.lastIndexOf("/") + 1),
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
async disarm(workflowId = DEFAULT_WORKFLOW) {
|
|
224
|
+
await publish({}, "DISABLED", WEBHOOK_TRIGGER, workflowId);
|
|
225
|
+
},
|
|
226
|
+
async remove(workflowId = DEFAULT_WORKFLOW) {
|
|
227
|
+
ordinal += 1;
|
|
228
|
+
await service.onOperations([
|
|
229
|
+
{
|
|
230
|
+
operation: {
|
|
231
|
+
index: ordinal,
|
|
232
|
+
timestampUtcMs: `${ordinal}`,
|
|
233
|
+
action: {
|
|
234
|
+
type: "DELETE_DOCUMENT",
|
|
235
|
+
input: { documentId: workflowId },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
context: {
|
|
239
|
+
documentId: workflowId,
|
|
240
|
+
documentType: WORKFLOW_TYPE,
|
|
241
|
+
scope: "document",
|
|
242
|
+
branch: "main",
|
|
243
|
+
ordinal,
|
|
244
|
+
},
|
|
245
|
+
} as unknown as OperationWithContext,
|
|
246
|
+
]);
|
|
247
|
+
},
|
|
248
|
+
async hasToken(token) {
|
|
249
|
+
return (await webhookStore.find(token)) !== undefined;
|
|
250
|
+
},
|
|
251
|
+
policyFor(workflowId = DEFAULT_WORKFLOW) {
|
|
252
|
+
return service.webhookPolicy(workflowId);
|
|
253
|
+
},
|
|
254
|
+
deliver(token, init = {}) {
|
|
255
|
+
const query = init.query
|
|
256
|
+
? `?${new URLSearchParams(init.query).toString()}`
|
|
257
|
+
: "";
|
|
258
|
+
return fetch(`${url}/webhooks/${token}${query}`, {
|
|
259
|
+
method: init.method ?? "POST",
|
|
260
|
+
headers: init.headers,
|
|
261
|
+
body: init.body,
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
stop() {
|
|
265
|
+
return new Promise<void>((resolve, reject) =>
|
|
266
|
+
server.close((error) => (error ? reject(error) : resolve())),
|
|
267
|
+
);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A delivery is answered before its run starts, so asserting on `fired`
|
|
273
|
+
// straight after a 202 is a race that passes for the wrong reason.
|
|
274
|
+
export async function waitForRuns(
|
|
275
|
+
host: WebhookHost,
|
|
276
|
+
count: number,
|
|
277
|
+
timeoutMs = 2000,
|
|
278
|
+
): Promise<FiredRun[]> {
|
|
279
|
+
const deadline = Date.now() + timeoutMs;
|
|
280
|
+
while (host.fired.length < count && Date.now() < deadline) {
|
|
281
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
282
|
+
}
|
|
283
|
+
if (host.fired.length < count) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Expected ${count} run(s), saw ${host.fired.length} within ${timeoutMs}ms`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return host.fired;
|
|
289
|
+
}
|