@frockbot/plugin-routines 0.0.0 → 0.1.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.
@@ -0,0 +1,394 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ constantTimeEqualsV1,
4
+ decodeRoutineHookDeliveryV1,
5
+ mintRoutineHookTokenV1,
6
+ renderRoutineDeliveryV1,
7
+ routineDeliveryIdV1,
8
+ routineHookDigestV1,
9
+ RoutineHookError,
10
+ verifyRoutineHookTokenV1,
11
+ ROUTINE_HOOK_CUE_MAX_BYTES,
12
+ } from "./hook.js";
13
+ import { RoutineScheduler } from "./scheduler.js";
14
+ import { RoutineStore } from "./store.js";
15
+ import { createMemoryRoutineStorageV1 } from "./testing.js";
16
+ import type { RoutineCommandV1 } from "./shared.js";
17
+
18
+ const SECRET = "a-signing-secret-long-enough-to-be-a-secret";
19
+ const CLAIMS = { u: "tim", b: "scout", r: "brief", v: 1 } as const;
20
+ const USER = { kind: "user" } as const;
21
+
22
+ describe("the hook token", () => {
23
+ test("verifies a token it minted, and returns its claims", async () => {
24
+ const token = await mintRoutineHookTokenV1(SECRET, CLAIMS);
25
+ expect(await verifyRoutineHookTokenV1(SECRET, token)).toEqual({
26
+ ...CLAIMS,
27
+ });
28
+ });
29
+
30
+ test("is deterministic, so it never has to be stored", async () => {
31
+ expect(await mintRoutineHookTokenV1(SECRET, CLAIMS)).toBe(
32
+ await mintRoutineHookTokenV1(SECRET, CLAIMS),
33
+ );
34
+ });
35
+
36
+ test("refuses a tampered payload, signature, or secret", async () => {
37
+ const token = await mintRoutineHookTokenV1(SECRET, CLAIMS);
38
+ const [payload, signature] = token.split(".") as [string, string];
39
+ const forgedPayload = `${await mintRoutineHookTokenV1(SECRET, {
40
+ ...CLAIMS,
41
+ r: "other",
42
+ }).then((other) => other.split(".")[0])}.${signature}`;
43
+
44
+ for (const bad of [
45
+ forgedPayload,
46
+ `${payload}.${signature.slice(0, -2)}AA`,
47
+ `${payload}.`,
48
+ payload,
49
+ "",
50
+ "not-a-token",
51
+ ]) {
52
+ await expect(verifyRoutineHookTokenV1(SECRET, bad)).rejects.toThrow(
53
+ /webhook key is invalid/,
54
+ );
55
+ }
56
+ await expect(
57
+ verifyRoutineHookTokenV1(`${SECRET}-different`, token),
58
+ ).rejects.toThrow(/webhook key is invalid/);
59
+ });
60
+
61
+ test("refuses to sign or verify without a real secret", async () => {
62
+ await expect(mintRoutineHookTokenV1("", CLAIMS)).rejects.toThrow(
63
+ /ROUTINE_HOOK_SECRET/,
64
+ );
65
+ const refusal = await verifyRoutineHookTokenV1("short", "a.b").catch(
66
+ (error: unknown) => error,
67
+ );
68
+ expect(refusal).toBeInstanceOf(RoutineHookError);
69
+ expect((refusal as RoutineHookError).status).toBe(500);
70
+ });
71
+
72
+ test("a key version is part of the token, so a rotation is a new token", async () => {
73
+ expect(await mintRoutineHookTokenV1(SECRET, CLAIMS)).not.toBe(
74
+ await mintRoutineHookTokenV1(SECRET, { ...CLAIMS, v: 2 }),
75
+ );
76
+ expect(
77
+ (
78
+ await verifyRoutineHookTokenV1(
79
+ SECRET,
80
+ await mintRoutineHookTokenV1(SECRET, { ...CLAIMS, v: 2 }),
81
+ )
82
+ ).v,
83
+ ).toBe(2);
84
+ });
85
+
86
+ test("compares in constant time, and still compares correctly", () => {
87
+ expect(constantTimeEqualsV1("abc", "abc")).toBe(true);
88
+ expect(constantTimeEqualsV1("abc", "abd")).toBe(false);
89
+ expect(constantTimeEqualsV1("abc", "abcd")).toBe(false);
90
+ expect(constantTimeEqualsV1("", "")).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe("delivery identity", () => {
95
+ test("is the content when the caller sent no idempotency key", async () => {
96
+ expect(await routineDeliveryIdV1("brief", '{"a":1}')).toBe(
97
+ await routineDeliveryIdV1("brief", '{"a":1}'),
98
+ );
99
+ expect(await routineDeliveryIdV1("brief", '{"a":1}')).not.toBe(
100
+ await routineDeliveryIdV1("brief", '{"a":2}'),
101
+ );
102
+ // Two Routines receiving the same body are two deliveries.
103
+ expect(await routineDeliveryIdV1("brief", "{}")).not.toBe(
104
+ await routineDeliveryIdV1("other", "{}"),
105
+ );
106
+ });
107
+
108
+ test("is the caller's key when it sent one, whatever the body says", async () => {
109
+ expect(await routineDeliveryIdV1("brief", "{}", "abc")).toBe(
110
+ await routineDeliveryIdV1("brief", '{"different":true}', "abc"),
111
+ );
112
+ expect(await routineDeliveryIdV1("brief", "{}", "abc")).not.toBe(
113
+ await routineDeliveryIdV1("brief", "{}", "def"),
114
+ );
115
+ });
116
+ });
117
+
118
+ describe("the delivery rendering", () => {
119
+ test("truncates at 4 KiB and says so", () => {
120
+ const rendered = renderRoutineDeliveryV1("x".repeat(10_000), "text/plain");
121
+ expect(rendered).toContain("(text/plain)");
122
+ expect(rendered).toContain("truncated at 4096 bytes");
123
+ expect(rendered.length).toBeLessThan(ROUTINE_HOOK_CUE_MAX_BYTES + 200);
124
+ });
125
+
126
+ test("leaves a small body whole", () => {
127
+ expect(renderRoutineDeliveryV1('{"ok":true}')).toContain('{"ok":true}');
128
+ });
129
+ });
130
+
131
+ describe("the delivery codec", () => {
132
+ test("refuses anything that is not exactly a delivery", () => {
133
+ const valid = {
134
+ routineId: "brief",
135
+ keyVersion: 1,
136
+ digest: "a".repeat(64),
137
+ deliveryId: "b".repeat(64),
138
+ body: "{}",
139
+ };
140
+ expect(decodeRoutineHookDeliveryV1(valid)).toEqual(valid);
141
+ for (const bad of [
142
+ { ...valid, keyVersion: 0 },
143
+ { ...valid, digest: "not-a-digest" },
144
+ { ...valid, deliveryId: "short" },
145
+ { ...valid, routineId: "" },
146
+ { ...valid, body: 1 },
147
+ { ...valid, extra: true },
148
+ ]) {
149
+ expect(() => decodeRoutineHookDeliveryV1(bad)).toThrow();
150
+ }
151
+ });
152
+ });
153
+
154
+ function harness() {
155
+ const storage = createMemoryRoutineStorageV1();
156
+ const scheduler = new RoutineScheduler(storage);
157
+ const store = new RoutineStore(storage, {
158
+ firings: scheduler,
159
+ hookKeys: {
160
+ async mint({ routineId, keyVersion }) {
161
+ const token = await mintRoutineHookTokenV1(SECRET, {
162
+ u: "tim",
163
+ b: "scout",
164
+ r: routineId,
165
+ v: keyVersion,
166
+ });
167
+ return {
168
+ token,
169
+ digest: await routineHookDigestV1(token),
170
+ path: `/api/bots/scout/routines/${routineId}/hook`,
171
+ };
172
+ },
173
+ },
174
+ });
175
+ const create: RoutineCommandV1 = {
176
+ schemaVersion: 1,
177
+ type: "routine/create",
178
+ commandId: "cmd-create",
179
+ botId: "scout",
180
+ routineId: "brief",
181
+ name: "Delivered brief",
182
+ prompt: "Summarize the payload.",
183
+ trigger: { kind: "webhook" },
184
+ timezone: "UTC",
185
+ };
186
+ return { storage, scheduler, store, create };
187
+ }
188
+
189
+ async function deliver(
190
+ store: RoutineStore,
191
+ token: string,
192
+ body: string,
193
+ idempotencyKey?: string,
194
+ ) {
195
+ const claims = await verifyRoutineHookTokenV1(SECRET, token);
196
+ return store.deliverHook({
197
+ routineId: claims.r,
198
+ keyVersion: claims.v,
199
+ digest: await routineHookDigestV1(token),
200
+ deliveryId: await routineDeliveryIdV1(claims.r, body, idempotencyKey),
201
+ body,
202
+ contentType: "application/json",
203
+ });
204
+ }
205
+
206
+ describe("the durable half of the check", () => {
207
+ test("mints a key once, on the receipt and nowhere else", async () => {
208
+ const { store, create } = harness();
209
+ const receipt = await store.execute(create, USER);
210
+ expect(receipt).toMatchObject({ status: "applied" });
211
+ const minted = receipt.status === "applied" ? receipt.hook : undefined;
212
+ expect(minted).toMatchObject({
213
+ routineId: "brief",
214
+ keyVersion: 1,
215
+ path: "/api/bots/scout/routines/brief/hook",
216
+ });
217
+
218
+ // A replay of the same command id answers without the key: a key a replay
219
+ // could re-read would not be a secret.
220
+ const replay = await store.execute(create, USER);
221
+ expect(replay.status === "applied" && replay.hook).toBeUndefined();
222
+
223
+ // The listing says a key exists and never what it is.
224
+ const listed = await store.list("scout");
225
+ expect(listed.routines[0]).toMatchObject({ hookKeyVersion: 1 });
226
+ expect(JSON.stringify(listed)).not.toContain(minted!.token);
227
+ // And the durable record holds a digest, not the token.
228
+ const key = await store.readHookKey("brief");
229
+ expect(key?.digest).toBe(await routineHookDigestV1(minted!.token));
230
+ expect(JSON.stringify(key)).not.toContain(minted!.token);
231
+ });
232
+
233
+ test("accepts a good key once and answers a replay with the same firing", async () => {
234
+ const { scheduler, store, create } = harness();
235
+ const receipt = await store.execute(create, USER);
236
+ const token = (receipt as { hook: { token: string } }).hook.token;
237
+
238
+ const first = await deliver(store, token, '{"event":"push"}');
239
+ expect(first.status).toBe("accepted");
240
+ const second = await deliver(store, token, '{"event":"push"}');
241
+ expect(second).toEqual({ status: "duplicate", fireId: first.fireId });
242
+
243
+ const fired: string[] = [];
244
+ await scheduler.settle(async (fire) => {
245
+ fired.push(fire.fireId);
246
+ expect(fire.trigger).toBe("webhook");
247
+ expect(fire.cue).toContain('{"event":"push"}');
248
+ return { status: "ok" };
249
+ });
250
+ expect(fired).toEqual([first.fireId]);
251
+ });
252
+
253
+ test("a rotated key retires the one before it", async () => {
254
+ const { store, create } = harness();
255
+ const created = await store.execute(create, USER);
256
+ const old = (created as { hook: { token: string } }).hook.token;
257
+
258
+ const rotated = await store.execute(
259
+ {
260
+ schemaVersion: 1,
261
+ type: "routine/rotate-key",
262
+ commandId: "cmd-rotate",
263
+ botId: "scout",
264
+ routineId: "brief",
265
+ },
266
+ USER,
267
+ );
268
+ const fresh = (rotated as { hook: { token: string; keyVersion: number } })
269
+ .hook;
270
+ expect(fresh.keyVersion).toBe(2);
271
+
272
+ // The old key still carries a perfectly good signature, and is refused
273
+ // anyway: the durable record is the authority.
274
+ await expect(deliver(store, old, "{}")).rejects.toThrow(
275
+ /webhook key is invalid/,
276
+ );
277
+ expect((await deliver(store, fresh.token, "{}")).status).toBe("accepted");
278
+ });
279
+
280
+ test("a revoked key leaves the door shut", async () => {
281
+ const { store, create } = harness();
282
+ const created = await store.execute(create, USER);
283
+ const token = (created as { hook: { token: string } }).hook.token;
284
+
285
+ await store.execute(
286
+ {
287
+ schemaVersion: 1,
288
+ type: "routine/revoke-key",
289
+ commandId: "cmd-revoke",
290
+ botId: "scout",
291
+ routineId: "brief",
292
+ },
293
+ USER,
294
+ );
295
+ await expect(deliver(store, token, "{}")).rejects.toThrow(
296
+ /webhook key is invalid/,
297
+ );
298
+ expect(
299
+ (await store.list("scout")).routines[0]?.hookKeyVersion,
300
+ ).toBeUndefined();
301
+ });
302
+
303
+ test("a paused Routine says so, and an unknown one does not", async () => {
304
+ const { store, create } = harness();
305
+ const created = await store.execute(create, USER);
306
+ const token = (created as { hook: { token: string } }).hook.token;
307
+
308
+ await store.execute(
309
+ {
310
+ schemaVersion: 1,
311
+ type: "routine/pause",
312
+ commandId: "cmd-pause",
313
+ botId: "scout",
314
+ routineId: "brief",
315
+ },
316
+ USER,
317
+ );
318
+ const paused = await deliver(store, token, "{}").catch(
319
+ (error: unknown) => error,
320
+ );
321
+ expect((paused as RoutineHookError).status).toBe(409);
322
+
323
+ await store.execute(
324
+ {
325
+ schemaVersion: 1,
326
+ type: "routine/delete",
327
+ commandId: "cmd-delete",
328
+ botId: "scout",
329
+ routineId: "brief",
330
+ },
331
+ USER,
332
+ );
333
+ const gone = await deliver(store, token, "{}").catch(
334
+ (error: unknown) => error,
335
+ );
336
+ expect((gone as RoutineHookError).status).toBe(404);
337
+ });
338
+
339
+ test("a wrong key version is refused even with the right digest", async () => {
340
+ const { store, create } = harness();
341
+ const created = await store.execute(create, USER);
342
+ const token = (created as { hook: { token: string } }).hook.token;
343
+ const refusal = await store
344
+ .deliverHook({
345
+ routineId: "brief",
346
+ keyVersion: 2,
347
+ digest: await routineHookDigestV1(token),
348
+ deliveryId: "c".repeat(64),
349
+ body: "{}",
350
+ })
351
+ .catch((error: unknown) => error);
352
+ expect((refusal as RoutineHookError).status).toBe(401);
353
+ });
354
+
355
+ test("a caller's idempotency key collapses two different bodies into one firing", async () => {
356
+ const { scheduler, store, create } = harness();
357
+ const created = await store.execute(create, USER);
358
+ const token = (created as { hook: { token: string } }).hook.token;
359
+
360
+ const first = await deliver(store, token, '{"a":1}', "delivery-7");
361
+ const second = await deliver(store, token, '{"a":2}', "delivery-7");
362
+ expect(second).toEqual({ status: "duplicate", fireId: first.fireId });
363
+
364
+ let fired = 0;
365
+ await scheduler.settle(() => {
366
+ fired += 1;
367
+ return Promise.resolve({ status: "ok" as const });
368
+ });
369
+ expect(fired).toBe(1);
370
+ });
371
+
372
+ test("a Bot with no signing secret records the Routine and refuses the key", async () => {
373
+ const storage = createMemoryRoutineStorageV1();
374
+ const scheduler = new RoutineScheduler(storage);
375
+ const store = new RoutineStore(storage, { firings: scheduler });
376
+ const { create } = harness();
377
+
378
+ const receipt = await store.execute(create, USER);
379
+ expect(receipt.status === "applied" && receipt.hook).toBeUndefined();
380
+ expect((await store.list("scout")).routines).toHaveLength(1);
381
+ await expect(
382
+ store.execute(
383
+ {
384
+ schemaVersion: 1,
385
+ type: "routine/rotate-key",
386
+ commandId: "cmd-rotate",
387
+ botId: "scout",
388
+ routineId: "brief",
389
+ },
390
+ USER,
391
+ ),
392
+ ).rejects.toThrow(/ROUTINE_HOOK_SECRET/);
393
+ });
394
+ });