@aletheia-dev/plugin-sdk 0.3.0 → 0.4.0
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/CHANGELOG.md +15 -0
- package/README.md +74 -19
- package/dist/chunk-FEZWGVUS.js +86 -0
- package/dist/chunk-FEZWGVUS.js.map +1 -0
- package/dist/index.js +11 -73
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +387 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +167 -0
- package/dist/testing.d.ts +167 -0
- package/dist/testing.js +312 -0
- package/dist/testing.js.map +1 -0
- package/package.json +7 -2
package/dist/testing.cjs
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/testing/index.ts
|
|
21
|
+
var testing_exports = {};
|
|
22
|
+
__export(testing_exports, {
|
|
23
|
+
DEFAULT_SIGNATURE_HEADER: () => DEFAULT_SIGNATURE_HEADER,
|
|
24
|
+
DEFAULT_TENANT_ID: () => DEFAULT_TENANT_ID,
|
|
25
|
+
MemoryDocuments: () => MemoryDocuments,
|
|
26
|
+
assertConformance: () => assertConformance,
|
|
27
|
+
callbackUrlFor: () => callbackUrlFor,
|
|
28
|
+
conformance: () => conformance,
|
|
29
|
+
createFetchStub: () => createFetchStub,
|
|
30
|
+
createRecordingLogger: () => createRecordingLogger,
|
|
31
|
+
createTestContext: () => createTestContext,
|
|
32
|
+
documentHandle: () => documentHandle,
|
|
33
|
+
webhookRequest: () => webhookRequest
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(testing_exports);
|
|
36
|
+
|
|
37
|
+
// src/testing/context.ts
|
|
38
|
+
function createRecordingLogger(bindings = {}, entries = []) {
|
|
39
|
+
const record = (level) => (objOrMsg, msg) => {
|
|
40
|
+
const isObject = typeof objOrMsg === "object" && objOrMsg !== null;
|
|
41
|
+
entries.push({
|
|
42
|
+
level,
|
|
43
|
+
message: isObject ? msg ?? "" : String(objOrMsg),
|
|
44
|
+
data: isObject ? { ...objOrMsg } : {},
|
|
45
|
+
bindings
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
entries,
|
|
50
|
+
bindings,
|
|
51
|
+
debug: record("debug"),
|
|
52
|
+
info: record("info"),
|
|
53
|
+
warn: record("warn"),
|
|
54
|
+
error: record("error"),
|
|
55
|
+
child: (more) => createRecordingLogger({ ...bindings, ...more }, entries)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
var notFound = () => new Response("not found", { status: 404 });
|
|
59
|
+
function headersOf(input, init) {
|
|
60
|
+
const out = {};
|
|
61
|
+
const add = (name, value) => {
|
|
62
|
+
out[name.toLowerCase()] = value;
|
|
63
|
+
};
|
|
64
|
+
if (input instanceof Request) input.headers.forEach((value, name) => add(name, value));
|
|
65
|
+
const headers = init?.headers;
|
|
66
|
+
if (headers instanceof Headers) headers.forEach((value, name) => add(name, value));
|
|
67
|
+
else if (Array.isArray(headers)) {
|
|
68
|
+
for (const pair of headers) {
|
|
69
|
+
if (pair[0] !== void 0 && pair[1] !== void 0) add(pair[0], pair[1]);
|
|
70
|
+
}
|
|
71
|
+
} else if (headers) {
|
|
72
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
73
|
+
add(name, Array.isArray(value) ? value.join(", ") : String(value));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function bodyOf(body) {
|
|
79
|
+
if (body === null || body === void 0) return null;
|
|
80
|
+
if (typeof body === "string") return new TextEncoder().encode(body);
|
|
81
|
+
if (body instanceof Uint8Array) return body;
|
|
82
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
|
83
|
+
if (body instanceof URLSearchParams) return new TextEncoder().encode(body.toString());
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
function createFetchStub(handler = notFound) {
|
|
87
|
+
const calls = [];
|
|
88
|
+
const stub = async (input, init) => {
|
|
89
|
+
calls.push({
|
|
90
|
+
url: input instanceof Request ? input.url : String(input),
|
|
91
|
+
method: (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(),
|
|
92
|
+
headers: headersOf(input, init),
|
|
93
|
+
body: bodyOf(init?.body),
|
|
94
|
+
init
|
|
95
|
+
});
|
|
96
|
+
return handler(input, init);
|
|
97
|
+
};
|
|
98
|
+
return Object.assign(stub, { calls });
|
|
99
|
+
}
|
|
100
|
+
function documentHandle(doc) {
|
|
101
|
+
const bytes = doc.bytes ?? new TextEncoder().encode(doc.text ?? "");
|
|
102
|
+
return {
|
|
103
|
+
id: doc.id,
|
|
104
|
+
fileName: doc.fileName ?? `${doc.id}.bin`,
|
|
105
|
+
contentType: doc.contentType ?? "application/octet-stream",
|
|
106
|
+
sizeBytes: bytes.byteLength,
|
|
107
|
+
bytes
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
var MemoryDocuments = class {
|
|
111
|
+
reads = [];
|
|
112
|
+
docs = /* @__PURE__ */ new Map();
|
|
113
|
+
constructor(documents = []) {
|
|
114
|
+
for (const doc of documents) this.add(doc);
|
|
115
|
+
}
|
|
116
|
+
add(doc) {
|
|
117
|
+
this.docs.set(doc.id, doc);
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
async read(documentId) {
|
|
121
|
+
this.reads.push(documentId);
|
|
122
|
+
const doc = this.docs.get(documentId);
|
|
123
|
+
if (!doc) throw new Error(`document ${documentId} not found or not clean`);
|
|
124
|
+
return doc;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
var DEFAULT_TENANT_ID = "tenant-test";
|
|
128
|
+
var DEFAULT_CALLBACK_BASE = "http://localhost:4000";
|
|
129
|
+
function callbackUrlFor(pluginName, base = DEFAULT_CALLBACK_BASE) {
|
|
130
|
+
return `${base.replace(/\/$/, "")}/webhooks/plugins/${encodeURIComponent(pluginName)}`;
|
|
131
|
+
}
|
|
132
|
+
function createTestContext(manifest, options = {}) {
|
|
133
|
+
const config = manifest.configSchema.parse(options.config ?? {});
|
|
134
|
+
const tenantId = options.tenantId ?? DEFAULT_TENANT_ID;
|
|
135
|
+
const secrets = {};
|
|
136
|
+
const missing = [];
|
|
137
|
+
for (const name of manifest.secrets) {
|
|
138
|
+
const value = options.secrets?.[name];
|
|
139
|
+
if (value === void 0) missing.push(name);
|
|
140
|
+
else secrets[name] = value;
|
|
141
|
+
}
|
|
142
|
+
if (missing.length > 0) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`createTestContext: ${manifest.name} declares secrets that were not supplied: ${missing.join(", ")}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const documents = options.documents === null ? void 0 : options.documents instanceof MemoryDocuments ? options.documents : new MemoryDocuments(options.documents);
|
|
148
|
+
const callbackUrl = options.callbackUrl === null ? void 0 : options.callbackUrl ?? callbackUrlFor(manifest.name);
|
|
149
|
+
return {
|
|
150
|
+
tenantId,
|
|
151
|
+
config,
|
|
152
|
+
secrets,
|
|
153
|
+
logger: createRecordingLogger({ plugin: manifest.name, tenantId }),
|
|
154
|
+
fetch: createFetchStub(options.fetch),
|
|
155
|
+
signal: options.signal ?? new AbortController().signal,
|
|
156
|
+
...options.idempotencyKey !== void 0 && { idempotencyKey: options.idempotencyKey },
|
|
157
|
+
...callbackUrl !== void 0 && { callbackUrl },
|
|
158
|
+
...documents !== void 0 && { documents }
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/index.ts
|
|
163
|
+
var import_zod = require("zod");
|
|
164
|
+
|
|
165
|
+
// src/webhooks.ts
|
|
166
|
+
var import_node_crypto = require("crypto");
|
|
167
|
+
function signHmacSha256(secret, rawBody, encoding = "hex") {
|
|
168
|
+
return (0, import_node_crypto.createHmac)("sha256", secret).update(rawBody).digest(encoding);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/index.ts
|
|
172
|
+
function isPending(value) {
|
|
173
|
+
return typeof value === "object" && value !== null && value.pending === true && typeof value.externalId === "string";
|
|
174
|
+
}
|
|
175
|
+
var isZodSchema = (value) => typeof value === "object" && value !== null && "_zod" in value;
|
|
176
|
+
var ZodSchemaValue = import_zod.z.custom(isZodSchema, { message: "expected a zod schema" });
|
|
177
|
+
var PluginManifestSchema = import_zod.z.object({
|
|
178
|
+
name: import_zod.z.string().min(1).max(214).regex(
|
|
179
|
+
/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/,
|
|
180
|
+
"expected an npm package name"
|
|
181
|
+
),
|
|
182
|
+
version: import_zod.z.string().min(1),
|
|
183
|
+
description: import_zod.z.string().optional(),
|
|
184
|
+
capabilities: import_zod.z.array(import_zod.z.string().min(1)),
|
|
185
|
+
configSchema: ZodSchemaValue,
|
|
186
|
+
secrets: import_zod.z.array(import_zod.z.string().min(1)),
|
|
187
|
+
actions: import_zod.z.record(
|
|
188
|
+
import_zod.z.string().min(1),
|
|
189
|
+
import_zod.z.object({
|
|
190
|
+
description: import_zod.z.string().optional(),
|
|
191
|
+
input: ZodSchemaValue,
|
|
192
|
+
output: ZodSchemaValue,
|
|
193
|
+
timeoutMs: import_zod.z.number().int().positive().optional(),
|
|
194
|
+
retry: import_zod.z.object({
|
|
195
|
+
maxAttempts: import_zod.z.number().int().min(1).max(5),
|
|
196
|
+
backoffMs: import_zod.z.number().int().nonnegative()
|
|
197
|
+
}).optional(),
|
|
198
|
+
idempotent: import_zod.z.boolean().optional(),
|
|
199
|
+
async: import_zod.z.object({
|
|
200
|
+
callbackTimeoutSeconds: import_zod.z.number().int().min(1).max(7 * 24 * 3600)
|
|
201
|
+
}).optional()
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// src/testing/conformance.ts
|
|
207
|
+
var UNKNOWN_ACTION = "__conformance_unknown_action__";
|
|
208
|
+
var isContext = (value) => "logger" in value && "signal" in value;
|
|
209
|
+
var describeError = (error) => error instanceof Error ? error.message : String(error);
|
|
210
|
+
var issues = (error) => error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
211
|
+
var isZodSchema2 = (value) => typeof value === "object" && value !== null && "_zod" in value;
|
|
212
|
+
var Checks = class {
|
|
213
|
+
list = [];
|
|
214
|
+
pass(name, message) {
|
|
215
|
+
this.list.push({ name, ok: true, ...message !== void 0 && { message } });
|
|
216
|
+
}
|
|
217
|
+
fail(name, message) {
|
|
218
|
+
this.list.push({ name, ok: false, message });
|
|
219
|
+
}
|
|
220
|
+
/** Runs `fn`; a thrown error fails the check with its message. */
|
|
221
|
+
async run(name, fn) {
|
|
222
|
+
try {
|
|
223
|
+
const note = await fn();
|
|
224
|
+
this.pass(name, note ?? void 0);
|
|
225
|
+
return true;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
this.fail(name, describeError(error));
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
async function conformance(plugin, options) {
|
|
233
|
+
const checks = new Checks();
|
|
234
|
+
const { manifest } = plugin;
|
|
235
|
+
const manifestOk = await checks.run("manifest parses with PluginManifestSchema", () => {
|
|
236
|
+
const parsed = PluginManifestSchema.safeParse(manifest);
|
|
237
|
+
if (!parsed.success) throw new Error(issues(parsed.error));
|
|
238
|
+
});
|
|
239
|
+
const actions = Object.entries(manifest.actions ?? {});
|
|
240
|
+
for (const [name, action] of actions) {
|
|
241
|
+
await checks.run(`action ${name}: input and output are zod schemas`, () => {
|
|
242
|
+
const bad = [
|
|
243
|
+
...isZodSchema2(action?.input) ? [] : ["input"],
|
|
244
|
+
...isZodSchema2(action?.output) ? [] : ["output"]
|
|
245
|
+
];
|
|
246
|
+
if (bad.length > 0) throw new Error(`${bad.join(" and ")} is not a zod schema`);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (actions.some(([, action]) => action?.async)) {
|
|
250
|
+
await checks.run("asynchronous actions: handleWebhook is implemented", () => {
|
|
251
|
+
if (typeof plugin.handleWebhook !== "function") {
|
|
252
|
+
throw new Error(
|
|
253
|
+
"the manifest declares an async action but the plugin has no handleWebhook"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
let ctx;
|
|
259
|
+
const contextOk = await checks.run("context: config parses and secrets resolve", () => {
|
|
260
|
+
ctx = options.context && isContext(options.context) ? options.context : createTestContext(manifest, options.context);
|
|
261
|
+
});
|
|
262
|
+
if (manifestOk && contextOk && ctx) {
|
|
263
|
+
await checks.run("invoke rejects an unknown action", async () => {
|
|
264
|
+
let outcome;
|
|
265
|
+
try {
|
|
266
|
+
outcome = await plugin.invoke(UNKNOWN_ACTION, {}, ctx);
|
|
267
|
+
} catch {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
throw new Error(
|
|
271
|
+
`invoke(${JSON.stringify(UNKNOWN_ACTION)}) resolved with ${JSON.stringify(outcome)}`
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
for (const [index, sample] of options.samples.entries()) {
|
|
275
|
+
await runSample(plugin, ctx, sample, `sample ${index + 1} (${sample.action})`, checks);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const failures = checks.list.filter((check) => !check.ok);
|
|
279
|
+
return { ok: failures.length === 0, checks: checks.list, failures };
|
|
280
|
+
}
|
|
281
|
+
async function runSample(plugin, ctx, sample, label, checks) {
|
|
282
|
+
const action = plugin.manifest.actions[sample.action];
|
|
283
|
+
if (!action) {
|
|
284
|
+
checks.fail(`${label}: action exists`, `the manifest declares no action ${sample.action}`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const inputOk = await checks.run(`${label}: input validates against the input schema`, () => {
|
|
288
|
+
const parsed = action.input.safeParse(sample.input);
|
|
289
|
+
if (!parsed.success) throw new Error(issues(parsed.error));
|
|
290
|
+
});
|
|
291
|
+
if (!inputOk) return;
|
|
292
|
+
const input = action.input.parse(sample.input);
|
|
293
|
+
let result;
|
|
294
|
+
const invoked = await checks.run(`${label}: invoke resolves`, async () => {
|
|
295
|
+
result = await plugin.invoke(sample.action, input, ctx);
|
|
296
|
+
});
|
|
297
|
+
if (!invoked) return;
|
|
298
|
+
if (!action.async) {
|
|
299
|
+
await checks.run(`${label}: output validates against the output schema`, () => {
|
|
300
|
+
if (isPending(result)) throw new Error("a synchronous action returned pending()");
|
|
301
|
+
const parsed = action.output.safeParse(result);
|
|
302
|
+
if (!parsed.success) throw new Error(issues(parsed.error));
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const pendingOk = await checks.run(`${label}: returns pending() with a non-empty id`, () => {
|
|
307
|
+
if (!isPending(result))
|
|
308
|
+
throw new Error(`expected pending(externalId), got ${JSON.stringify(result)}`);
|
|
309
|
+
if (result.externalId.length === 0) throw new Error("externalId is empty");
|
|
310
|
+
});
|
|
311
|
+
if (!pendingOk || !sample.webhook || !isPending(result)) return;
|
|
312
|
+
const { externalId } = result;
|
|
313
|
+
await checks.run(`${label}: handleWebhook resolves the same external id`, async () => {
|
|
314
|
+
if (!plugin.handleWebhook) throw new Error("the plugin has no handleWebhook");
|
|
315
|
+
const request = await sample.webhook(externalId, ctx);
|
|
316
|
+
const event = await plugin.handleWebhook(request, ctx);
|
|
317
|
+
if (!event) throw new Error("handleWebhook ignored the sample webhook (returned null)");
|
|
318
|
+
if (event.externalId !== externalId) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`event.externalId is ${JSON.stringify(event.externalId)}, expected ${JSON.stringify(externalId)}`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (event.status === "completed") {
|
|
324
|
+
const parsed = action.output.safeParse(event.output);
|
|
325
|
+
if (!parsed.success) throw new Error(`completed output is invalid: ${issues(parsed.error)}`);
|
|
326
|
+
}
|
|
327
|
+
return `status ${event.status}`;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
async function assertConformance(plugin, options) {
|
|
331
|
+
const result = await conformance(plugin, options);
|
|
332
|
+
if (!result.ok) {
|
|
333
|
+
const lines = result.failures.map((check) => ` - ${check.name}: ${check.message ?? "failed"}`);
|
|
334
|
+
throw new Error(
|
|
335
|
+
`${plugin.manifest?.name ?? "plugin"} fails ${result.failures.length} conformance check(s):
|
|
336
|
+
${lines.join("\n")}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/testing/webhook.ts
|
|
343
|
+
var DEFAULT_SIGNATURE_HEADER = "x-signature";
|
|
344
|
+
function encodeBody(body) {
|
|
345
|
+
if (body instanceof Uint8Array) return { rawBody: body, isJson: false };
|
|
346
|
+
if (typeof body === "string") return { rawBody: new TextEncoder().encode(body), isJson: false };
|
|
347
|
+
return { rawBody: new TextEncoder().encode(JSON.stringify(body)), isJson: true };
|
|
348
|
+
}
|
|
349
|
+
function webhookRequest(plugin, options) {
|
|
350
|
+
const { rawBody, isJson } = encodeBody(options.body);
|
|
351
|
+
const headers = {
|
|
352
|
+
"content-type": options.contentType ?? (isJson ? "application/json" : "application/octet-stream")
|
|
353
|
+
};
|
|
354
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
355
|
+
headers[name.toLowerCase()] = value;
|
|
356
|
+
}
|
|
357
|
+
if (options.secret !== void 0) {
|
|
358
|
+
const headerName = (options.headerName ?? DEFAULT_SIGNATURE_HEADER).toLowerCase();
|
|
359
|
+
headers[headerName] = signHmacSha256(options.secret, rawBody, options.encoding ?? "hex");
|
|
360
|
+
}
|
|
361
|
+
const query = { ...options.query };
|
|
362
|
+
const externalId = options.externalId ?? externalIdOf(options.body, isJson);
|
|
363
|
+
if (externalId !== void 0 && !plugin.webhookExternalId && query.externalId === void 0) {
|
|
364
|
+
query.externalId = externalId;
|
|
365
|
+
}
|
|
366
|
+
return { method: options.method ?? "POST", headers, rawBody, query };
|
|
367
|
+
}
|
|
368
|
+
function externalIdOf(body, isJson) {
|
|
369
|
+
if (!isJson || typeof body !== "object" || body === null) return void 0;
|
|
370
|
+
const value = body.externalId;
|
|
371
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
372
|
+
}
|
|
373
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
374
|
+
0 && (module.exports = {
|
|
375
|
+
DEFAULT_SIGNATURE_HEADER,
|
|
376
|
+
DEFAULT_TENANT_ID,
|
|
377
|
+
MemoryDocuments,
|
|
378
|
+
assertConformance,
|
|
379
|
+
callbackUrlFor,
|
|
380
|
+
conformance,
|
|
381
|
+
createFetchStub,
|
|
382
|
+
createRecordingLogger,
|
|
383
|
+
createTestContext,
|
|
384
|
+
documentHandle,
|
|
385
|
+
webhookRequest
|
|
386
|
+
});
|
|
387
|
+
//# sourceMappingURL=testing.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/testing/index.ts","../src/testing/context.ts","../src/index.ts","../src/webhooks.ts","../src/testing/conformance.ts","../src/testing/webhook.ts"],"sourcesContent":["/**\n * Test helpers for plugin authors (`@aletheia-dev/plugin-sdk/testing`): a recording\n * `PluginContext`, the contract checks the runtime applies, and signed webhook requests. No\n * dependency on the platform; zod stays the SDK's only peer.\n */\nexport {\n DEFAULT_TENANT_ID,\n MemoryDocuments,\n callbackUrlFor,\n createFetchStub,\n createRecordingLogger,\n createTestContext,\n documentHandle,\n type FetchCall,\n type FetchHandler,\n type FetchStub,\n type LogEntry,\n type LogLevel,\n type RecordingLogger,\n type TestContext,\n type TestContextOptions,\n} from './context.js';\nexport {\n assertConformance,\n conformance,\n type ConformanceCheck,\n type ConformanceOptions,\n type ConformanceResult,\n type ConformanceSample,\n} from './conformance.js';\nexport { DEFAULT_SIGNATURE_HEADER, webhookRequest, type WebhookRequestOptions } from './webhook.js';\n","import type { DocumentHandle, PluginDocuments } from '../documents.js';\nimport type { PluginConfig, PluginContext, PluginManifest } from '../index.js';\nimport type { Logger } from '../logger.js';\n\nexport type LogLevel = 'debug' | 'info' | 'warn' | 'error';\n\n/** One call to the recording logger, with the bindings of the child logger that made it. */\nexport interface LogEntry {\n level: LogLevel;\n message: string;\n /** The object passed first (pino style), `{}` when the call started with the message. */\n data: Record<string, unknown>;\n bindings: Record<string, unknown>;\n}\n\n/** A `Logger` that keeps every call in `entries`; children share the array and merge bindings. */\nexport interface RecordingLogger extends Logger {\n entries: LogEntry[];\n bindings: Record<string, unknown>;\n child(bindings: Record<string, unknown>): RecordingLogger;\n}\n\nexport function createRecordingLogger(\n bindings: Record<string, unknown> = {},\n entries: LogEntry[] = [],\n): RecordingLogger {\n const record =\n (level: LogLevel) =>\n (objOrMsg: object | string, msg?: string): void => {\n const isObject = typeof objOrMsg === 'object' && objOrMsg !== null;\n entries.push({\n level,\n message: isObject ? (msg ?? '') : String(objOrMsg),\n data: isObject ? { ...(objOrMsg as Record<string, unknown>) } : {},\n bindings,\n });\n };\n return {\n entries,\n bindings,\n debug: record('debug'),\n info: record('info'),\n warn: record('warn'),\n error: record('error'),\n child: (more) => createRecordingLogger({ ...bindings, ...more }, entries),\n };\n}\n\n/** One call a plugin made through `ctx.fetch`, normalised for assertions. */\nexport interface FetchCall {\n url: string;\n method: string;\n /** Header names lower-cased, as the vendor would see them. */\n headers: Record<string, string>;\n /** The request body as bytes; `null` when there was none or it was not a string or bytes. */\n body: Uint8Array | null;\n /** The `init` the plugin passed, untouched, for anything the normalised view drops. */\n init: RequestInit | undefined;\n}\n\n/** What answers the plugin's requests; the global `fetch` type fits. */\nexport type FetchHandler = (\n input: string | URL | Request,\n init?: RequestInit,\n) => Response | Promise<Response>;\n\n/** A `fetch` that records its calls. The default handler answers every request with 404. */\nexport type FetchStub = typeof fetch & { calls: FetchCall[] };\n\nconst notFound: FetchHandler = () => new Response('not found', { status: 404 });\n\nfunction headersOf(input: string | URL | Request, init: RequestInit | undefined) {\n const out: Record<string, string> = {};\n const add = (name: string, value: string) => {\n out[name.toLowerCase()] = value;\n };\n if (input instanceof Request) input.headers.forEach((value, name) => add(name, value));\n const headers = init?.headers;\n if (headers instanceof Headers) headers.forEach((value, name) => add(name, value));\n else if (Array.isArray(headers)) {\n for (const pair of headers) {\n if (pair[0] !== undefined && pair[1] !== undefined) add(pair[0], pair[1]);\n }\n } else if (headers) {\n for (const [name, value] of Object.entries(headers)) {\n add(name, Array.isArray(value) ? value.join(', ') : String(value));\n }\n }\n return out;\n}\n\nfunction bodyOf(body: RequestInit['body']): Uint8Array | null {\n if (body === null || body === undefined) return null;\n if (typeof body === 'string') return new TextEncoder().encode(body);\n if (body instanceof Uint8Array) return body;\n if (body instanceof ArrayBuffer) return new Uint8Array(body);\n if (body instanceof URLSearchParams) return new TextEncoder().encode(body.toString());\n return null;\n}\n\nexport function createFetchStub(handler: FetchHandler = notFound): FetchStub {\n const calls: FetchCall[] = [];\n const stub = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {\n calls.push({\n url: input instanceof Request ? input.url : String(input),\n method: (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(),\n headers: headersOf(input, init),\n body: bodyOf(init?.body),\n init,\n });\n return handler(input, init);\n };\n return Object.assign(stub as typeof fetch, { calls });\n}\n\n/** Builds a `DocumentHandle` from bytes or text, filling in the size. */\nexport function documentHandle(doc: {\n id: string;\n bytes?: Uint8Array;\n text?: string;\n fileName?: string;\n contentType?: string;\n}): DocumentHandle {\n const bytes = doc.bytes ?? new TextEncoder().encode(doc.text ?? '');\n return {\n id: doc.id,\n fileName: doc.fileName ?? `${doc.id}.bin`,\n contentType: doc.contentType ?? 'application/octet-stream',\n sizeBytes: bytes.byteLength,\n bytes,\n };\n}\n\n/**\n * In-memory `PluginDocuments`: holds the tenant's clean documents by id and records which ids a\n * plugin read. Like the platform, an unknown id throws rather than returning `undefined`.\n */\nexport class MemoryDocuments implements PluginDocuments {\n readonly reads: string[] = [];\n private readonly docs = new Map<string, DocumentHandle>();\n\n constructor(documents: DocumentHandle[] = []) {\n for (const doc of documents) this.add(doc);\n }\n\n add(doc: DocumentHandle): this {\n this.docs.set(doc.id, doc);\n return this;\n }\n\n async read(documentId: string): Promise<DocumentHandle> {\n this.reads.push(documentId);\n const doc = this.docs.get(documentId);\n if (!doc) throw new Error(`document ${documentId} not found or not clean`);\n return doc;\n }\n}\n\nexport interface TestContextOptions {\n /** Raw tenant configuration; parsed through `configSchema`, so defaults apply. Default `{}`. */\n config?: unknown;\n /** Secret values by name. Every secret the manifest declares must be present, as at runtime. */\n secrets?: Record<string, string>;\n /**\n * Documents the plugin may read. Default: an empty in-memory source. Pass `null` for a\n * deployment without object storage (`ctx.documents` is then absent).\n */\n documents?: DocumentHandle[] | MemoryDocuments | null;\n /** Answers `ctx.fetch`; calls are recorded either way. Default: 404 for everything. */\n fetch?: FetchHandler;\n tenantId?: string;\n /** Default: `http://localhost:4000/webhooks/plugins/<encoded plugin name>`. */\n callbackUrl?: string | null;\n idempotencyKey?: string;\n /** Default: a signal that never aborts. */\n signal?: AbortSignal;\n}\n\n/** A `PluginContext` whose logger, fetch and documents record what the plugin did. */\nexport interface TestContext<C = unknown> extends PluginContext<C> {\n logger: RecordingLogger;\n fetch: FetchStub;\n documents?: MemoryDocuments;\n}\n\nexport const DEFAULT_TENANT_ID = 'tenant-test';\nconst DEFAULT_CALLBACK_BASE = 'http://localhost:4000';\n\n/** The callback URL the platform hands a plugin for a given API base URL. */\nexport function callbackUrlFor(pluginName: string, base = DEFAULT_CALLBACK_BASE): string {\n return `${base.replace(/\\/$/, '')}/webhooks/plugins/${encodeURIComponent(pluginName)}`;\n}\n\n/**\n * Builds the context the runtime would hand the plugin for one tenant: the config parsed through\n * the manifest's `configSchema`, the declared secrets resolved from `options.secrets`, a logger\n * bound to the plugin and tenant that records every entry, a `fetch` stub that records every\n * call and an in-memory document source. Throws when the config is invalid or a secret is\n * missing, which is what the runtime does at worker start.\n */\nexport function createTestContext<M extends PluginManifest>(\n manifest: M,\n options: TestContextOptions = {},\n): TestContext<PluginConfig<M>> {\n const config = manifest.configSchema.parse(options.config ?? {}) as PluginConfig<M>;\n const tenantId = options.tenantId ?? DEFAULT_TENANT_ID;\n const secrets: Record<string, string> = {};\n const missing: string[] = [];\n for (const name of manifest.secrets) {\n const value = options.secrets?.[name];\n if (value === undefined) missing.push(name);\n else secrets[name] = value;\n }\n if (missing.length > 0) {\n throw new Error(\n `createTestContext: ${manifest.name} declares secrets that were not supplied: ${missing.join(', ')}`,\n );\n }\n const documents =\n options.documents === null\n ? undefined\n : options.documents instanceof MemoryDocuments\n ? options.documents\n : new MemoryDocuments(options.documents);\n const callbackUrl =\n options.callbackUrl === null\n ? undefined\n : (options.callbackUrl ?? callbackUrlFor(manifest.name));\n return {\n tenantId,\n config,\n secrets,\n logger: createRecordingLogger({ plugin: manifest.name, tenantId }),\n fetch: createFetchStub(options.fetch),\n signal: options.signal ?? new AbortController().signal,\n ...(options.idempotencyKey !== undefined && { idempotencyKey: options.idempotencyKey }),\n ...(callbackUrl !== undefined && { callbackUrl }),\n ...(documents !== undefined && { documents }),\n };\n}\n","import { z } from 'zod';\nimport type { Logger } from './logger.js';\nimport type { PluginDocuments } from './documents.js';\nimport type { WebhookEvent, WebhookRequest } from './webhooks.js';\n\nexport { noopLogger, type LogFn, type Logger } from './logger.js';\nexport type { DocumentHandle, PluginDocuments } from './documents.js';\nexport {\n WebhookRejectedError,\n signHmacSha256,\n verifyHmacSha256,\n type WebhookEvent,\n type WebhookRequest,\n} from './webhooks.js';\n\n/** Retry hints for an action; applied by the runtime only when the call is safe to repeat. */\nexport interface PluginRetryPolicy {\n /** Total attempts including the first (1 to 5). */\n maxAttempts: number;\n /** Base delay between attempts; multiplied by the attempt number. */\n backoffMs: number;\n}\n\n/** One callable action a plugin exposes. Input and output are validated by the runtime. */\nexport interface PluginAction<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodType> {\n description?: string;\n input: I;\n output: O;\n /** Deadline for one attempt; the runtime aborts `ctx.signal` and fails the call when exceeded. */\n timeoutMs?: number;\n /** Retried by the runtime when `idempotent` is true or the caller supplies an idempotency key. */\n retry?: PluginRetryPolicy;\n /** Declares that repeating the action with the same input has no additional effect. */\n idempotent?: boolean;\n /**\n * The action starts work at the vendor and completes later through a webhook. `invoke` must\n * return `pending(externalId)`; the runtime waits for `handleWebhook` to report that id.\n */\n async?: { callbackTimeoutSeconds: number };\n}\n\n/** What an asynchronous action returns after starting work at the vendor. */\nexport interface PendingResult {\n pending: true;\n /** The vendor's identifier for the session, job or check; webhooks must carry it back. */\n externalId: string;\n}\n\nexport function pending(externalId: string): PendingResult {\n return { pending: true, externalId };\n}\n\nexport function isPending(value: unknown): value is PendingResult {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { pending?: unknown }).pending === true &&\n typeof (value as { externalId?: unknown }).externalId === 'string'\n );\n}\n\nexport type PluginActions = Record<string, PluginAction>;\n\n/** Static description of a plugin package: what it needs (config, secrets) and what it offers. */\nexport interface PluginManifest<\n C extends z.ZodType = z.ZodType,\n A extends PluginActions = PluginActions,\n> {\n /** npm package name, e.g. '@aletheia-dev/plugin-mock-sanctions'. */\n name: string;\n version: string;\n description?: string;\n /** Capability tags, e.g. ['sanctions.screen']. */\n capabilities: string[];\n /** Validates the tenant-provided, non-secret config. */\n configSchema: C;\n /** Names of secrets the plugin needs; resolved by the runtime per tenant. */\n secrets: string[];\n actions: A;\n}\n\n/** Per-tenant runtime context handed to every plugin call. */\nexport interface PluginContext<C = unknown> {\n tenantId: string;\n config: C;\n secrets: Record<string, string>;\n logger: Logger;\n fetch: typeof fetch;\n /** Aborted when the action's deadline passes; pass it to `fetch` so vendor calls stop too. */\n signal: AbortSignal;\n /** Stable key for the logical call (same across retries), when the caller provided one. */\n idempotencyKey?: string;\n /** Where the vendor must send webhooks for this plugin, when the deployment exposes one. */\n callbackUrl?: string;\n /**\n * Read-only access to the tenant's clean documents (by document id). Present when the\n * deployment has object storage; absent otherwise, so plugins must check before relying on it.\n */\n documents?: PluginDocuments;\n}\n\n/** Parsed config type of a manifest. */\nexport type PluginConfig<M extends PluginManifest> = z.output<M['configSchema']>;\n/** Union of a manifest's action names. */\nexport type ActionName<M extends PluginManifest> = keyof M['actions'] & string;\n/** Parsed input type of one action. */\nexport type ActionInput<M extends PluginManifest, K extends ActionName<M>> = z.output<\n M['actions'][K]['input']\n>;\n/** Output type of one action. */\nexport type ActionOutput<M extends PluginManifest, K extends ActionName<M>> = z.output<\n M['actions'][K]['output']\n>;\n\nexport interface Plugin<M extends PluginManifest = PluginManifest> {\n manifest: M;\n onInit?(ctx: PluginContext<PluginConfig<M>>): Promise<void> | void;\n onShutdown?(): Promise<void> | void;\n /**\n * Dispatch one action. The runtime validates `input` against the action's input schema\n * before calling and the return value against its output schema afterwards, so the\n * signature stays loose here; use `ActionInput`/`ActionOutput` to type the body.\n */\n invoke(\n action: ActionName<M>,\n input: unknown,\n ctx: PluginContext<PluginConfig<M>>,\n ): Promise<unknown>;\n /**\n * Verifies and decodes a vendor webhook for one of this plugin's asynchronous actions.\n * Return `null` to ignore the request, throw `WebhookRejectedError` for bad signatures.\n */\n handleWebhook?(\n request: WebhookRequest,\n ctx: PluginContext<PluginConfig<M>>,\n ): Promise<WebhookEvent | null>;\n /**\n * Extracts the vendor's external id from a webhook whose URL carries no `?externalId=`\n * (vendors with one dashboard-level webhook URL). Must be pure and need no secrets: it runs\n * before the tenant is known, so it only decodes the body or headers. The platform resolves\n * the tenant from the id and only then calls `handleWebhook` with the tenant context, which\n * must still verify the signature. Return `null` when the request carries no id.\n */\n webhookExternalId?(request: WebhookRequest): string | null;\n}\n\n/** Identity helper so `manifest` drives inference for `onInit`/`invoke`. */\nexport function definePlugin<M extends PluginManifest>(plugin: Plugin<M>): Plugin<M> {\n return plugin;\n}\n\n/** Identity helper that preserves the concrete schema and action types. */\nexport function defineManifest<C extends z.ZodType, A extends PluginActions>(\n manifest: PluginManifest<C, A>,\n): PluginManifest<C, A> {\n return manifest;\n}\n\nconst isZodSchema = (value: unknown): value is z.ZodType =>\n typeof value === 'object' && value !== null && '_zod' in value;\n\nconst ZodSchemaValue = z.custom<z.ZodType>(isZodSchema, { message: 'expected a zod schema' });\n\n/** Runtime validation of a manifest's data parts (schemas are only checked to be zod schemas). */\nexport const PluginManifestSchema = z.object({\n name: z\n .string()\n .min(1)\n .max(214)\n .regex(\n /^(@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/,\n 'expected an npm package name',\n ),\n version: z.string().min(1),\n description: z.string().optional(),\n capabilities: z.array(z.string().min(1)),\n configSchema: ZodSchemaValue,\n secrets: z.array(z.string().min(1)),\n actions: z.record(\n z.string().min(1),\n z.object({\n description: z.string().optional(),\n input: ZodSchemaValue,\n output: ZodSchemaValue,\n timeoutMs: z.number().int().positive().optional(),\n retry: z\n .object({\n maxAttempts: z.number().int().min(1).max(5),\n backoffMs: z.number().int().nonnegative(),\n })\n .optional(),\n idempotent: z.boolean().optional(),\n async: z\n .object({\n callbackTimeoutSeconds: z\n .number()\n .int()\n .min(1)\n .max(7 * 24 * 3600),\n })\n .optional(),\n }),\n ),\n});\n","import { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** A vendor webhook as received by the API, with the raw body for signature verification. */\nexport interface WebhookRequest {\n method: string;\n /** Header names lower-cased. */\n headers: Record<string, string>;\n rawBody: Uint8Array;\n query: Record<string, string>;\n}\n\n/** What a plugin extracted from a webhook. `null` from `handleWebhook` means \"ignore\". */\nexport interface WebhookEvent {\n /** The vendor session id returned by the asynchronous action. */\n externalId: string;\n /** Vendor event id (or a stable hash) used for de-duplication. */\n eventId: string;\n status: 'completed' | 'failed' | 'pending';\n /** Validated against the action's output schema when `status` is `completed`. */\n output?: unknown;\n error?: string;\n}\n\n/** Thrown by `handleWebhook` when the signature or payload is not acceptable (HTTP 401). */\nexport class WebhookRejectedError extends Error {\n constructor(message = 'webhook rejected') {\n super(message);\n this.name = 'WebhookRejectedError';\n }\n}\n\n/** Constant-time HMAC-SHA256 check of a raw body against a hex (or base64) signature. */\nexport function verifyHmacSha256(\n secret: string,\n rawBody: Uint8Array,\n signature: string,\n encoding: 'hex' | 'base64' = 'hex',\n): boolean {\n const expected = createHmac('sha256', secret).update(rawBody).digest(encoding);\n const a = Buffer.from(expected);\n const b = Buffer.from(signature.trim());\n return a.length === b.length && timingSafeEqual(a, b);\n}\n\nexport function signHmacSha256(\n secret: string,\n rawBody: Uint8Array,\n encoding: 'hex' | 'base64' = 'hex',\n): string {\n return createHmac('sha256', secret).update(rawBody).digest(encoding);\n}\n","import type { z } from 'zod';\nimport { PluginManifestSchema, isPending, type Plugin, type PluginContext } from '../index.js';\nimport type { WebhookRequest } from '../webhooks.js';\nimport { createTestContext, type TestContextOptions } from './context.js';\n\n/** One input an author knows the plugin handles, used to exercise the runtime's contract. */\nexport interface ConformanceSample {\n action: string;\n input: unknown;\n /**\n * For an asynchronous action: builds the vendor's webhook for the external id `invoke`\n * returned (use `webhookRequest` to sign it). `handleWebhook` must then resolve the same id.\n */\n webhook?: (externalId: string, ctx: PluginContext) => WebhookRequest | Promise<WebhookRequest>;\n}\n\nexport interface ConformanceOptions {\n samples: ConformanceSample[];\n /** The context the samples run with: options for `createTestContext`, or a ready context. */\n context?: TestContextOptions | PluginContext;\n}\n\nexport interface ConformanceCheck {\n name: string;\n ok: boolean;\n /** Why the check failed, or a note on what passed. */\n message?: string;\n}\n\nexport interface ConformanceResult {\n ok: boolean;\n checks: ConformanceCheck[];\n failures: ConformanceCheck[];\n}\n\nconst UNKNOWN_ACTION = '__conformance_unknown_action__';\n\nconst isContext = (value: TestContextOptions | PluginContext): value is PluginContext =>\n 'logger' in value && 'signal' in value;\n\nconst describeError = (error: unknown): string =>\n error instanceof Error ? error.message : String(error);\n\nconst issues = (error: z.ZodError): string =>\n error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`).join('; ');\n\nconst isZodSchema = (value: unknown): value is z.ZodType =>\n typeof value === 'object' && value !== null && '_zod' in value;\n\nclass Checks {\n readonly list: ConformanceCheck[] = [];\n\n pass(name: string, message?: string): void {\n this.list.push({ name, ok: true, ...(message !== undefined && { message }) });\n }\n\n fail(name: string, message: string): void {\n this.list.push({ name, ok: false, message });\n }\n\n /** Runs `fn`; a thrown error fails the check with its message. */\n async run(name: string, fn: () => Promise<string | void> | string | void): Promise<boolean> {\n try {\n const note = await fn();\n this.pass(name, note ?? undefined);\n return true;\n } catch (error) {\n this.fail(name, describeError(error));\n return false;\n }\n }\n}\n\n/**\n * Runs the contract checks the platform applies to a plugin, without the platform: the manifest\n * parses with `PluginManifestSchema`; every action's `input` and `output` are zod schemas;\n * `invoke` rejects an unknown action; for each sample, the input validates and the output\n * validates against the action's output schema; an asynchronous action returns `pending()` with\n * a non-empty id and, when the sample supplies a webhook, `handleWebhook` resolves that id (and\n * a completed event's output validates). Never throws for a failing plugin: read `result.ok` or\n * use `assertConformance` with any test runner.\n */\nexport async function conformance(\n plugin: Plugin,\n options: ConformanceOptions,\n): Promise<ConformanceResult> {\n const checks = new Checks();\n const { manifest } = plugin;\n\n const manifestOk = await checks.run('manifest parses with PluginManifestSchema', () => {\n const parsed = PluginManifestSchema.safeParse(manifest);\n if (!parsed.success) throw new Error(issues(parsed.error));\n });\n\n const actions = Object.entries(manifest.actions ?? {});\n for (const [name, action] of actions) {\n await checks.run(`action ${name}: input and output are zod schemas`, () => {\n const bad = [\n ...(isZodSchema(action?.input) ? [] : ['input']),\n ...(isZodSchema(action?.output) ? [] : ['output']),\n ];\n if (bad.length > 0) throw new Error(`${bad.join(' and ')} is not a zod schema`);\n });\n }\n if (actions.some(([, action]) => action?.async)) {\n await checks.run('asynchronous actions: handleWebhook is implemented', () => {\n if (typeof plugin.handleWebhook !== 'function') {\n throw new Error(\n 'the manifest declares an async action but the plugin has no handleWebhook',\n );\n }\n });\n }\n\n let ctx: PluginContext | undefined;\n const contextOk = await checks.run('context: config parses and secrets resolve', () => {\n ctx =\n options.context && isContext(options.context)\n ? options.context\n : createTestContext(manifest, options.context);\n });\n\n if (manifestOk && contextOk && ctx) {\n await checks.run('invoke rejects an unknown action', async () => {\n let outcome: unknown;\n try {\n outcome = await plugin.invoke(UNKNOWN_ACTION, {}, ctx!);\n } catch {\n return;\n }\n throw new Error(\n `invoke(${JSON.stringify(UNKNOWN_ACTION)}) resolved with ${JSON.stringify(outcome)}`,\n );\n });\n for (const [index, sample] of options.samples.entries()) {\n await runSample(plugin, ctx, sample, `sample ${index + 1} (${sample.action})`, checks);\n }\n }\n\n const failures = checks.list.filter((check) => !check.ok);\n return { ok: failures.length === 0, checks: checks.list, failures };\n}\n\nasync function runSample(\n plugin: Plugin,\n ctx: PluginContext,\n sample: ConformanceSample,\n label: string,\n checks: Checks,\n): Promise<void> {\n const action = plugin.manifest.actions[sample.action];\n if (!action) {\n checks.fail(`${label}: action exists`, `the manifest declares no action ${sample.action}`);\n return;\n }\n const inputOk = await checks.run(`${label}: input validates against the input schema`, () => {\n const parsed = action.input.safeParse(sample.input);\n if (!parsed.success) throw new Error(issues(parsed.error));\n });\n if (!inputOk) return;\n const input = action.input.parse(sample.input);\n\n let result: unknown;\n const invoked = await checks.run(`${label}: invoke resolves`, async () => {\n result = await plugin.invoke(sample.action, input, ctx);\n });\n if (!invoked) return;\n\n if (!action.async) {\n await checks.run(`${label}: output validates against the output schema`, () => {\n if (isPending(result)) throw new Error('a synchronous action returned pending()');\n const parsed = action.output.safeParse(result);\n if (!parsed.success) throw new Error(issues(parsed.error));\n });\n return;\n }\n\n const pendingOk = await checks.run(`${label}: returns pending() with a non-empty id`, () => {\n if (!isPending(result))\n throw new Error(`expected pending(externalId), got ${JSON.stringify(result)}`);\n if (result.externalId.length === 0) throw new Error('externalId is empty');\n });\n if (!pendingOk || !sample.webhook || !isPending(result)) return;\n const { externalId } = result;\n\n await checks.run(`${label}: handleWebhook resolves the same external id`, async () => {\n if (!plugin.handleWebhook) throw new Error('the plugin has no handleWebhook');\n const request = await sample.webhook!(externalId, ctx);\n const event = await plugin.handleWebhook(request, ctx);\n if (!event) throw new Error('handleWebhook ignored the sample webhook (returned null)');\n if (event.externalId !== externalId) {\n throw new Error(\n `event.externalId is ${JSON.stringify(event.externalId)}, expected ${JSON.stringify(externalId)}`,\n );\n }\n if (event.status === 'completed') {\n const parsed = action.output.safeParse(event.output);\n if (!parsed.success) throw new Error(`completed output is invalid: ${issues(parsed.error)}`);\n }\n return `status ${event.status}`;\n });\n}\n\n/** `conformance`, throwing one error that lists every failed check. Works with any test runner. */\nexport async function assertConformance(\n plugin: Plugin,\n options: ConformanceOptions,\n): Promise<ConformanceResult> {\n const result = await conformance(plugin, options);\n if (!result.ok) {\n const lines = result.failures.map((check) => ` - ${check.name}: ${check.message ?? 'failed'}`);\n throw new Error(\n `${plugin.manifest?.name ?? 'plugin'} fails ${result.failures.length} conformance check(s):\\n${lines.join('\\n')}`,\n );\n }\n return result;\n}\n","import type { Plugin } from '../index.js';\nimport { signHmacSha256, type WebhookRequest } from '../webhooks.js';\n\n/** The header `webhookRequest` signs into when the plugin does not name its own. */\nexport const DEFAULT_SIGNATURE_HEADER = 'x-signature';\n\nexport interface WebhookRequestOptions {\n /** An object is JSON-encoded; a string or bytes are sent as they are. */\n body: unknown;\n /** When set, the raw body is signed with HMAC-SHA256 into `headerName`. */\n secret?: string;\n /** Header that carries the signature. Default `x-signature`. */\n headerName?: string;\n /** Signature encoding passed to `signHmacSha256`. Default `hex`. */\n encoding?: 'hex' | 'base64';\n /** Extra headers; names are lower-cased like the API does. */\n headers?: Record<string, string>;\n /** Extra query parameters, as the API passes them through to the plugin. */\n query?: Record<string, string>;\n /**\n * The vendor session the webhook is about. Put on the URL as `?externalId=` unless the plugin\n * implements `webhookExternalId` (its vendor cannot carry a query string). When omitted, a\n * string `externalId` field of a JSON body is used.\n */\n externalId?: string;\n /** Default `POST`. */\n method?: string;\n /** Default `application/json` for an object body, `application/octet-stream` otherwise. */\n contentType?: string;\n}\n\nfunction encodeBody(body: unknown): { rawBody: Uint8Array; isJson: boolean } {\n if (body instanceof Uint8Array) return { rawBody: body, isJson: false };\n if (typeof body === 'string') return { rawBody: new TextEncoder().encode(body), isJson: false };\n return { rawBody: new TextEncoder().encode(JSON.stringify(body)), isJson: true };\n}\n\n/**\n * Builds the `WebhookRequest` the API would hand `handleWebhook` for a vendor callback: raw\n * body bytes, lower-cased headers, `POST`, and the query string with the external id when the\n * plugin relies on the `?externalId=` convention. With `secret`, the raw body is signed with\n * the SDK's `signHmacSha256`, so `verifyHmacSha256` in the plugin accepts it.\n */\nexport function webhookRequest(plugin: Plugin, options: WebhookRequestOptions): WebhookRequest {\n const { rawBody, isJson } = encodeBody(options.body);\n const headers: Record<string, string> = {\n 'content-type':\n options.contentType ?? (isJson ? 'application/json' : 'application/octet-stream'),\n };\n for (const [name, value] of Object.entries(options.headers ?? {})) {\n headers[name.toLowerCase()] = value;\n }\n if (options.secret !== undefined) {\n const headerName = (options.headerName ?? DEFAULT_SIGNATURE_HEADER).toLowerCase();\n headers[headerName] = signHmacSha256(options.secret, rawBody, options.encoding ?? 'hex');\n }\n const query: Record<string, string> = { ...options.query };\n const externalId = options.externalId ?? externalIdOf(options.body, isJson);\n if (externalId !== undefined && !plugin.webhookExternalId && query.externalId === undefined) {\n query.externalId = externalId;\n }\n return { method: options.method ?? 'POST', headers, rawBody, query };\n}\n\nfunction externalIdOf(body: unknown, isJson: boolean): string | undefined {\n if (!isJson || typeof body !== 'object' || body === null) return undefined;\n const value = (body as { externalId?: unknown }).externalId;\n return typeof value === 'string' && value.length > 0 ? value : undefined;\n}\n"],"mappings":"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","names":["isZodSchema"]}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { DocumentHandle, PluginDocuments, Logger, PluginContext, PluginManifest, PluginConfig, WebhookRequest, Plugin } from './index.cjs';
|
|
2
|
+
import 'zod';
|
|
3
|
+
|
|
4
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
5
|
+
/** One call to the recording logger, with the bindings of the child logger that made it. */
|
|
6
|
+
interface LogEntry {
|
|
7
|
+
level: LogLevel;
|
|
8
|
+
message: string;
|
|
9
|
+
/** The object passed first (pino style), `{}` when the call started with the message. */
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
bindings: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
/** A `Logger` that keeps every call in `entries`; children share the array and merge bindings. */
|
|
14
|
+
interface RecordingLogger extends Logger {
|
|
15
|
+
entries: LogEntry[];
|
|
16
|
+
bindings: Record<string, unknown>;
|
|
17
|
+
child(bindings: Record<string, unknown>): RecordingLogger;
|
|
18
|
+
}
|
|
19
|
+
declare function createRecordingLogger(bindings?: Record<string, unknown>, entries?: LogEntry[]): RecordingLogger;
|
|
20
|
+
/** One call a plugin made through `ctx.fetch`, normalised for assertions. */
|
|
21
|
+
interface FetchCall {
|
|
22
|
+
url: string;
|
|
23
|
+
method: string;
|
|
24
|
+
/** Header names lower-cased, as the vendor would see them. */
|
|
25
|
+
headers: Record<string, string>;
|
|
26
|
+
/** The request body as bytes; `null` when there was none or it was not a string or bytes. */
|
|
27
|
+
body: Uint8Array | null;
|
|
28
|
+
/** The `init` the plugin passed, untouched, for anything the normalised view drops. */
|
|
29
|
+
init: RequestInit | undefined;
|
|
30
|
+
}
|
|
31
|
+
/** What answers the plugin's requests; the global `fetch` type fits. */
|
|
32
|
+
type FetchHandler = (input: string | URL | Request, init?: RequestInit) => Response | Promise<Response>;
|
|
33
|
+
/** A `fetch` that records its calls. The default handler answers every request with 404. */
|
|
34
|
+
type FetchStub = typeof fetch & {
|
|
35
|
+
calls: FetchCall[];
|
|
36
|
+
};
|
|
37
|
+
declare function createFetchStub(handler?: FetchHandler): FetchStub;
|
|
38
|
+
/** Builds a `DocumentHandle` from bytes or text, filling in the size. */
|
|
39
|
+
declare function documentHandle(doc: {
|
|
40
|
+
id: string;
|
|
41
|
+
bytes?: Uint8Array;
|
|
42
|
+
text?: string;
|
|
43
|
+
fileName?: string;
|
|
44
|
+
contentType?: string;
|
|
45
|
+
}): DocumentHandle;
|
|
46
|
+
/**
|
|
47
|
+
* In-memory `PluginDocuments`: holds the tenant's clean documents by id and records which ids a
|
|
48
|
+
* plugin read. Like the platform, an unknown id throws rather than returning `undefined`.
|
|
49
|
+
*/
|
|
50
|
+
declare class MemoryDocuments implements PluginDocuments {
|
|
51
|
+
readonly reads: string[];
|
|
52
|
+
private readonly docs;
|
|
53
|
+
constructor(documents?: DocumentHandle[]);
|
|
54
|
+
add(doc: DocumentHandle): this;
|
|
55
|
+
read(documentId: string): Promise<DocumentHandle>;
|
|
56
|
+
}
|
|
57
|
+
interface TestContextOptions {
|
|
58
|
+
/** Raw tenant configuration; parsed through `configSchema`, so defaults apply. Default `{}`. */
|
|
59
|
+
config?: unknown;
|
|
60
|
+
/** Secret values by name. Every secret the manifest declares must be present, as at runtime. */
|
|
61
|
+
secrets?: Record<string, string>;
|
|
62
|
+
/**
|
|
63
|
+
* Documents the plugin may read. Default: an empty in-memory source. Pass `null` for a
|
|
64
|
+
* deployment without object storage (`ctx.documents` is then absent).
|
|
65
|
+
*/
|
|
66
|
+
documents?: DocumentHandle[] | MemoryDocuments | null;
|
|
67
|
+
/** Answers `ctx.fetch`; calls are recorded either way. Default: 404 for everything. */
|
|
68
|
+
fetch?: FetchHandler;
|
|
69
|
+
tenantId?: string;
|
|
70
|
+
/** Default: `http://localhost:4000/webhooks/plugins/<encoded plugin name>`. */
|
|
71
|
+
callbackUrl?: string | null;
|
|
72
|
+
idempotencyKey?: string;
|
|
73
|
+
/** Default: a signal that never aborts. */
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
/** A `PluginContext` whose logger, fetch and documents record what the plugin did. */
|
|
77
|
+
interface TestContext<C = unknown> extends PluginContext<C> {
|
|
78
|
+
logger: RecordingLogger;
|
|
79
|
+
fetch: FetchStub;
|
|
80
|
+
documents?: MemoryDocuments;
|
|
81
|
+
}
|
|
82
|
+
declare const DEFAULT_TENANT_ID = "tenant-test";
|
|
83
|
+
/** The callback URL the platform hands a plugin for a given API base URL. */
|
|
84
|
+
declare function callbackUrlFor(pluginName: string, base?: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* Builds the context the runtime would hand the plugin for one tenant: the config parsed through
|
|
87
|
+
* the manifest's `configSchema`, the declared secrets resolved from `options.secrets`, a logger
|
|
88
|
+
* bound to the plugin and tenant that records every entry, a `fetch` stub that records every
|
|
89
|
+
* call and an in-memory document source. Throws when the config is invalid or a secret is
|
|
90
|
+
* missing, which is what the runtime does at worker start.
|
|
91
|
+
*/
|
|
92
|
+
declare function createTestContext<M extends PluginManifest>(manifest: M, options?: TestContextOptions): TestContext<PluginConfig<M>>;
|
|
93
|
+
|
|
94
|
+
/** One input an author knows the plugin handles, used to exercise the runtime's contract. */
|
|
95
|
+
interface ConformanceSample {
|
|
96
|
+
action: string;
|
|
97
|
+
input: unknown;
|
|
98
|
+
/**
|
|
99
|
+
* For an asynchronous action: builds the vendor's webhook for the external id `invoke`
|
|
100
|
+
* returned (use `webhookRequest` to sign it). `handleWebhook` must then resolve the same id.
|
|
101
|
+
*/
|
|
102
|
+
webhook?: (externalId: string, ctx: PluginContext) => WebhookRequest | Promise<WebhookRequest>;
|
|
103
|
+
}
|
|
104
|
+
interface ConformanceOptions {
|
|
105
|
+
samples: ConformanceSample[];
|
|
106
|
+
/** The context the samples run with: options for `createTestContext`, or a ready context. */
|
|
107
|
+
context?: TestContextOptions | PluginContext;
|
|
108
|
+
}
|
|
109
|
+
interface ConformanceCheck {
|
|
110
|
+
name: string;
|
|
111
|
+
ok: boolean;
|
|
112
|
+
/** Why the check failed, or a note on what passed. */
|
|
113
|
+
message?: string;
|
|
114
|
+
}
|
|
115
|
+
interface ConformanceResult {
|
|
116
|
+
ok: boolean;
|
|
117
|
+
checks: ConformanceCheck[];
|
|
118
|
+
failures: ConformanceCheck[];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Runs the contract checks the platform applies to a plugin, without the platform: the manifest
|
|
122
|
+
* parses with `PluginManifestSchema`; every action's `input` and `output` are zod schemas;
|
|
123
|
+
* `invoke` rejects an unknown action; for each sample, the input validates and the output
|
|
124
|
+
* validates against the action's output schema; an asynchronous action returns `pending()` with
|
|
125
|
+
* a non-empty id and, when the sample supplies a webhook, `handleWebhook` resolves that id (and
|
|
126
|
+
* a completed event's output validates). Never throws for a failing plugin: read `result.ok` or
|
|
127
|
+
* use `assertConformance` with any test runner.
|
|
128
|
+
*/
|
|
129
|
+
declare function conformance(plugin: Plugin, options: ConformanceOptions): Promise<ConformanceResult>;
|
|
130
|
+
/** `conformance`, throwing one error that lists every failed check. Works with any test runner. */
|
|
131
|
+
declare function assertConformance(plugin: Plugin, options: ConformanceOptions): Promise<ConformanceResult>;
|
|
132
|
+
|
|
133
|
+
/** The header `webhookRequest` signs into when the plugin does not name its own. */
|
|
134
|
+
declare const DEFAULT_SIGNATURE_HEADER = "x-signature";
|
|
135
|
+
interface WebhookRequestOptions {
|
|
136
|
+
/** An object is JSON-encoded; a string or bytes are sent as they are. */
|
|
137
|
+
body: unknown;
|
|
138
|
+
/** When set, the raw body is signed with HMAC-SHA256 into `headerName`. */
|
|
139
|
+
secret?: string;
|
|
140
|
+
/** Header that carries the signature. Default `x-signature`. */
|
|
141
|
+
headerName?: string;
|
|
142
|
+
/** Signature encoding passed to `signHmacSha256`. Default `hex`. */
|
|
143
|
+
encoding?: 'hex' | 'base64';
|
|
144
|
+
/** Extra headers; names are lower-cased like the API does. */
|
|
145
|
+
headers?: Record<string, string>;
|
|
146
|
+
/** Extra query parameters, as the API passes them through to the plugin. */
|
|
147
|
+
query?: Record<string, string>;
|
|
148
|
+
/**
|
|
149
|
+
* The vendor session the webhook is about. Put on the URL as `?externalId=` unless the plugin
|
|
150
|
+
* implements `webhookExternalId` (its vendor cannot carry a query string). When omitted, a
|
|
151
|
+
* string `externalId` field of a JSON body is used.
|
|
152
|
+
*/
|
|
153
|
+
externalId?: string;
|
|
154
|
+
/** Default `POST`. */
|
|
155
|
+
method?: string;
|
|
156
|
+
/** Default `application/json` for an object body, `application/octet-stream` otherwise. */
|
|
157
|
+
contentType?: string;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Builds the `WebhookRequest` the API would hand `handleWebhook` for a vendor callback: raw
|
|
161
|
+
* body bytes, lower-cased headers, `POST`, and the query string with the external id when the
|
|
162
|
+
* plugin relies on the `?externalId=` convention. With `secret`, the raw body is signed with
|
|
163
|
+
* the SDK's `signHmacSha256`, so `verifyHmacSha256` in the plugin accepts it.
|
|
164
|
+
*/
|
|
165
|
+
declare function webhookRequest(plugin: Plugin, options: WebhookRequestOptions): WebhookRequest;
|
|
166
|
+
|
|
167
|
+
export { type ConformanceCheck, type ConformanceOptions, type ConformanceResult, type ConformanceSample, DEFAULT_SIGNATURE_HEADER, DEFAULT_TENANT_ID, type FetchCall, type FetchHandler, type FetchStub, type LogEntry, type LogLevel, MemoryDocuments, type RecordingLogger, type TestContext, type TestContextOptions, type WebhookRequestOptions, assertConformance, callbackUrlFor, conformance, createFetchStub, createRecordingLogger, createTestContext, documentHandle, webhookRequest };
|