@frockbot/plugin-routines 0.0.0 → 0.1.1
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/frockbot.json +39 -0
- package/package.json +53 -6
- package/src/agent.test.ts +206 -0
- package/src/agent.ts +345 -0
- package/src/backend.test.ts +181 -0
- package/src/backend.ts +375 -0
- package/src/client/RoutineInboxBadge.vue +216 -0
- package/src/client/RoutinesSection.vue +601 -0
- package/src/client/RoutinesSummary.vue +150 -0
- package/src/client/index.test.ts +209 -0
- package/src/client/index.ts +289 -0
- package/src/client/state.ts +65 -0
- package/src/cron.test.ts +217 -0
- package/src/cron.ts +246 -0
- package/src/env.d.ts +6 -0
- package/src/firing.ts +222 -0
- package/src/hook.test.ts +394 -0
- package/src/hook.ts +405 -0
- package/src/inbox-store.ts +405 -0
- package/src/inbox.test.ts +402 -0
- package/src/inbox.ts +405 -0
- package/src/index.ts +9 -0
- package/src/manifest.ts +3 -0
- package/src/records.test.ts +138 -0
- package/src/records.ts +341 -0
- package/src/scheduler.test.ts +482 -0
- package/src/scheduler.ts +551 -0
- package/src/shared.test.ts +141 -0
- package/src/shared.ts +988 -0
- package/src/storage-keys.ts +202 -0
- package/src/store.test.ts +261 -0
- package/src/store.ts +789 -0
- package/src/testing.ts +55 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +31 -0
- package/README.md +0 -3
package/src/hook.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// The webhook door: the key a caller presents, and the two places it is checked.
|
|
2
|
+
//
|
|
3
|
+
// An external caller has no session, so the delivery route runs before gateway
|
|
4
|
+
// authentication. That makes the key the only thing standing between the open
|
|
5
|
+
// internet and a Durable Object, and one check is not enough:
|
|
6
|
+
//
|
|
7
|
+
// 1. **At the edge**, the key is a *self-describing signed token*. Its payload
|
|
8
|
+
// names the User, the Bot, the Routine and the key version; its signature is
|
|
9
|
+
// `HMAC-SHA256(ROUTINE_HOOK_SECRET, payload)`. The gateway is stateless and
|
|
10
|
+
// cannot map a Bot to its User, so without this it could not address a
|
|
11
|
+
// Durable Object at all without first creating one — which would hand an
|
|
12
|
+
// anonymous caller Durable Object creation. A token that does not verify
|
|
13
|
+
// never reaches an object.
|
|
14
|
+
// 2. **In the Durable Object**, `SHA-256(token)` is compared against the
|
|
15
|
+
// durable `routine-key:<routineId>` record and its `keyVersion`. That record
|
|
16
|
+
// is the authority: rotation bumps the version and revocation deletes it, so
|
|
17
|
+
// a token that verified at the edge is still refused the instant its key is
|
|
18
|
+
// no longer the Routine's.
|
|
19
|
+
//
|
|
20
|
+
// The token is derived, never stored: given the secret and the four payload
|
|
21
|
+
// fields it is reproducible, and the digest is what the Bot keeps. Nothing here
|
|
22
|
+
// writes key material to durable storage, and no view carries any.
|
|
23
|
+
import {
|
|
24
|
+
isRoutineIdV1,
|
|
25
|
+
RoutineDecodeError,
|
|
26
|
+
routineExactKeys,
|
|
27
|
+
} from "./records.js";
|
|
28
|
+
|
|
29
|
+
/** Longest delivery body the door accepts, before anything is parsed. */
|
|
30
|
+
export const ROUTINE_HOOK_BODY_MAX_BYTES = 64 * 1024;
|
|
31
|
+
|
|
32
|
+
/** Longest rendering of a delivery that reaches the cue. */
|
|
33
|
+
export const ROUTINE_HOOK_CUE_MAX_BYTES = 4 * 1024;
|
|
34
|
+
|
|
35
|
+
/** Most delivery receipts retained for replay detection. */
|
|
36
|
+
export const ROUTINE_DELIVERY_LIMIT = 256;
|
|
37
|
+
|
|
38
|
+
/** How long a delivery receipt guards against a replay. */
|
|
39
|
+
export const ROUTINE_DELIVERY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
/** The self-describing claims a hook token carries. */
|
|
42
|
+
export interface RoutineHookClaimsV1 {
|
|
43
|
+
/** User. */
|
|
44
|
+
u: string;
|
|
45
|
+
/** Bot. */
|
|
46
|
+
b: string;
|
|
47
|
+
/** Routine. */
|
|
48
|
+
r: string;
|
|
49
|
+
/** Key version. */
|
|
50
|
+
v: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The durable key record. Holds a digest, never the token. */
|
|
54
|
+
export interface RoutineHookKeyV1 {
|
|
55
|
+
schemaVersion: 1;
|
|
56
|
+
routineId: string;
|
|
57
|
+
keyVersion: number;
|
|
58
|
+
digest: string;
|
|
59
|
+
createdAt: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One delivery already accepted, kept so a replay answers with its firing. */
|
|
63
|
+
export interface RoutineDeliveryReceiptV1 {
|
|
64
|
+
schemaVersion: 1;
|
|
65
|
+
routineId: string;
|
|
66
|
+
fireId: string;
|
|
67
|
+
acceptedAt: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class RoutineHookError extends Error {
|
|
71
|
+
override readonly name = "RoutineHookError";
|
|
72
|
+
readonly status: number;
|
|
73
|
+
constructor(status: number, message: string) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.status = status;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const TEXT = new TextEncoder();
|
|
80
|
+
|
|
81
|
+
function base64url(bytes: Uint8Array): string {
|
|
82
|
+
let binary = "";
|
|
83
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
84
|
+
return btoa(binary)
|
|
85
|
+
.replace(/\+/g, "-")
|
|
86
|
+
.replace(/\//g, "_")
|
|
87
|
+
.replace(/=+$/, "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fromBase64url(value: string): Uint8Array {
|
|
91
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
92
|
+
const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
|
|
93
|
+
const bytes = new Uint8Array(binary.length);
|
|
94
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
95
|
+
bytes[index] = binary.charCodeAt(index);
|
|
96
|
+
}
|
|
97
|
+
return bytes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hex(bytes: ArrayBuffer): string {
|
|
101
|
+
return [...new Uint8Array(bytes)]
|
|
102
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
103
|
+
.join("");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Constant-time comparison. A signature check that returns early on the first
|
|
108
|
+
* differing byte leaks the signature one byte at a time to anyone willing to
|
|
109
|
+
* time it, and this check is the whole of the edge's authority.
|
|
110
|
+
*/
|
|
111
|
+
export function constantTimeEqualsV1(left: string, right: string): boolean {
|
|
112
|
+
const a = TEXT.encode(left);
|
|
113
|
+
const b = TEXT.encode(right);
|
|
114
|
+
// The lengths themselves are not secret; the contents are, so the loop runs
|
|
115
|
+
// over a fixed span either way.
|
|
116
|
+
let mismatch = a.length ^ b.length;
|
|
117
|
+
const span = Math.max(a.length, b.length);
|
|
118
|
+
for (let index = 0; index < span; index += 1) {
|
|
119
|
+
mismatch |= (a[index] ?? 0) ^ (b[index] ?? 0);
|
|
120
|
+
}
|
|
121
|
+
return mismatch === 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function signingKey(secret: string): Promise<CryptoKey> {
|
|
125
|
+
if (typeof secret !== "string" || secret.length < 16) {
|
|
126
|
+
throw new RoutineHookError(
|
|
127
|
+
500,
|
|
128
|
+
"ROUTINE_HOOK_SECRET is missing or too short for webhook delivery",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return crypto.subtle.importKey(
|
|
132
|
+
"raw",
|
|
133
|
+
TEXT.encode(secret),
|
|
134
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
135
|
+
false,
|
|
136
|
+
["sign"],
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `SHA-256` of a token, hex. The only form of a key the Bot keeps. */
|
|
141
|
+
export async function routineHookDigestV1(token: string): Promise<string> {
|
|
142
|
+
return hex(await crypto.subtle.digest("SHA-256", TEXT.encode(token)));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Mint the token for one Routine at one key version. Deterministic. */
|
|
146
|
+
export async function mintRoutineHookTokenV1(
|
|
147
|
+
secret: string,
|
|
148
|
+
claims: RoutineHookClaimsV1,
|
|
149
|
+
): Promise<string> {
|
|
150
|
+
const payload = base64url(
|
|
151
|
+
TEXT.encode(
|
|
152
|
+
JSON.stringify({ u: claims.u, b: claims.b, r: claims.r, v: claims.v }),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
const signature = await crypto.subtle.sign(
|
|
156
|
+
"HMAC",
|
|
157
|
+
await signingKey(secret),
|
|
158
|
+
TEXT.encode(payload),
|
|
159
|
+
);
|
|
160
|
+
return `${payload}.${base64url(new Uint8Array(signature))}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Verify a presented token and answer with the claims it carries.
|
|
165
|
+
*
|
|
166
|
+
* This is the edge's whole decision. It says nothing about whether the Routine
|
|
167
|
+
* exists, is enabled, or still holds this key — those are the Bot's to answer,
|
|
168
|
+
* and are answered only after the token proved it was minted here.
|
|
169
|
+
*/
|
|
170
|
+
export async function verifyRoutineHookTokenV1(
|
|
171
|
+
secret: string,
|
|
172
|
+
token: string,
|
|
173
|
+
): Promise<RoutineHookClaimsV1> {
|
|
174
|
+
if (typeof token !== "string" || token.length === 0 || token.length > 2_048) {
|
|
175
|
+
throw new RoutineHookError(401, "webhook key is invalid");
|
|
176
|
+
}
|
|
177
|
+
const separator = token.lastIndexOf(".");
|
|
178
|
+
if (separator <= 0) throw new RoutineHookError(401, "webhook key is invalid");
|
|
179
|
+
const payload = token.slice(0, separator);
|
|
180
|
+
const presented = token.slice(separator + 1);
|
|
181
|
+
let expected: string;
|
|
182
|
+
try {
|
|
183
|
+
expected = base64url(
|
|
184
|
+
new Uint8Array(
|
|
185
|
+
await crypto.subtle.sign(
|
|
186
|
+
"HMAC",
|
|
187
|
+
await signingKey(secret),
|
|
188
|
+
TEXT.encode(payload),
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof RoutineHookError) throw error;
|
|
194
|
+
throw new RoutineHookError(401, "webhook key is invalid");
|
|
195
|
+
}
|
|
196
|
+
if (!constantTimeEqualsV1(expected, presented)) {
|
|
197
|
+
throw new RoutineHookError(401, "webhook key is invalid");
|
|
198
|
+
}
|
|
199
|
+
let decoded: unknown;
|
|
200
|
+
try {
|
|
201
|
+
decoded = JSON.parse(new TextDecoder().decode(fromBase64url(payload)));
|
|
202
|
+
} catch {
|
|
203
|
+
throw new RoutineHookError(401, "webhook key is invalid");
|
|
204
|
+
}
|
|
205
|
+
return decodeRoutineHookClaimsV1(decoded);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function decodeRoutineHookClaimsV1(value: unknown): RoutineHookClaimsV1 {
|
|
209
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
210
|
+
throw new RoutineHookError(401, "webhook key is invalid");
|
|
211
|
+
}
|
|
212
|
+
const candidate = value as Record<string, unknown>;
|
|
213
|
+
const identifier = (key: "u" | "b" | "r"): string => {
|
|
214
|
+
const held = candidate[key];
|
|
215
|
+
if (typeof held !== "string" || held.length === 0 || held.length > 256) {
|
|
216
|
+
throw new RoutineHookError(401, "webhook key is invalid");
|
|
217
|
+
}
|
|
218
|
+
if (key === "r" && !isRoutineIdV1(held)) {
|
|
219
|
+
throw new RoutineHookError(401, "webhook key is invalid");
|
|
220
|
+
}
|
|
221
|
+
return held;
|
|
222
|
+
};
|
|
223
|
+
if (
|
|
224
|
+
!Number.isSafeInteger(candidate.v) ||
|
|
225
|
+
(candidate.v as number) < 1 ||
|
|
226
|
+
(candidate.v as number) > 1_000_000
|
|
227
|
+
) {
|
|
228
|
+
throw new RoutineHookError(401, "webhook key is invalid");
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
u: identifier("u"),
|
|
232
|
+
b: identifier("b"),
|
|
233
|
+
r: identifier("r"),
|
|
234
|
+
v: candidate.v as number,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function decodeRoutineHookKeyV1(value: unknown): RoutineHookKeyV1 {
|
|
239
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
240
|
+
throw new RoutineDecodeError("Routine hook key must be an object");
|
|
241
|
+
}
|
|
242
|
+
const candidate = value as Record<string, unknown>;
|
|
243
|
+
routineExactKeys(
|
|
244
|
+
candidate,
|
|
245
|
+
["schemaVersion", "routineId", "keyVersion", "digest", "createdAt"],
|
|
246
|
+
[],
|
|
247
|
+
"Routine hook key",
|
|
248
|
+
);
|
|
249
|
+
if (candidate.schemaVersion !== 1) {
|
|
250
|
+
throw new RoutineDecodeError(
|
|
251
|
+
"Routine hook key schemaVersion is unsupported",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (!isRoutineIdV1(candidate.routineId)) {
|
|
255
|
+
throw new RoutineDecodeError("Routine hook key routineId is invalid");
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
!Number.isSafeInteger(candidate.keyVersion) ||
|
|
259
|
+
(candidate.keyVersion as number) < 1
|
|
260
|
+
) {
|
|
261
|
+
throw new RoutineDecodeError("Routine hook key keyVersion is invalid");
|
|
262
|
+
}
|
|
263
|
+
if (
|
|
264
|
+
typeof candidate.digest !== "string" ||
|
|
265
|
+
!/^[0-9a-f]{64}$/.test(candidate.digest)
|
|
266
|
+
) {
|
|
267
|
+
throw new RoutineDecodeError("Routine hook key digest is invalid");
|
|
268
|
+
}
|
|
269
|
+
if (
|
|
270
|
+
typeof candidate.createdAt !== "string" ||
|
|
271
|
+
Number.isNaN(Date.parse(candidate.createdAt))
|
|
272
|
+
) {
|
|
273
|
+
throw new RoutineDecodeError("Routine hook key createdAt is invalid");
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
schemaVersion: 1,
|
|
277
|
+
routineId: candidate.routineId,
|
|
278
|
+
keyVersion: candidate.keyVersion as number,
|
|
279
|
+
digest: candidate.digest,
|
|
280
|
+
createdAt: candidate.createdAt,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The delivery id one request is remembered by: the caller's `Idempotency-Key`
|
|
286
|
+
* when it sent one, and otherwise the content itself. Either way the same
|
|
287
|
+
* delivery twice is one firing.
|
|
288
|
+
*/
|
|
289
|
+
export async function routineDeliveryIdV1(
|
|
290
|
+
routineId: string,
|
|
291
|
+
body: string,
|
|
292
|
+
idempotencyKey?: string | null,
|
|
293
|
+
): Promise<string> {
|
|
294
|
+
if (idempotencyKey) {
|
|
295
|
+
const trimmed = idempotencyKey.trim().slice(0, 256);
|
|
296
|
+
if (trimmed.length > 0) {
|
|
297
|
+
return hex(
|
|
298
|
+
await crypto.subtle.digest(
|
|
299
|
+
"SHA-256",
|
|
300
|
+
TEXT.encode(`key${routineId}${trimmed}`),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return hex(
|
|
306
|
+
await crypto.subtle.digest(
|
|
307
|
+
"SHA-256",
|
|
308
|
+
TEXT.encode(`body${routineId}${body}`),
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What the Bot is told a webhook delivered. The body is data, never
|
|
315
|
+
* instructions: it is fenced and labelled, and truncated to 4 KiB so a large
|
|
316
|
+
* delivery cannot crowd out the Routine's own prompt.
|
|
317
|
+
*/
|
|
318
|
+
export function renderRoutineDeliveryV1(
|
|
319
|
+
body: string,
|
|
320
|
+
contentType?: string | null,
|
|
321
|
+
): string {
|
|
322
|
+
const bytes = TEXT.encode(body);
|
|
323
|
+
const truncated = bytes.length > ROUTINE_HOOK_CUE_MAX_BYTES;
|
|
324
|
+
const rendered = truncated
|
|
325
|
+
? new TextDecoder().decode(bytes.slice(0, ROUTINE_HOOK_CUE_MAX_BYTES))
|
|
326
|
+
: body;
|
|
327
|
+
return [
|
|
328
|
+
`Webhook POST${contentType ? ` (${contentType.slice(0, 100)})` : ""}:`,
|
|
329
|
+
rendered,
|
|
330
|
+
...(truncated
|
|
331
|
+
? [`… truncated at ${ROUTINE_HOOK_CUE_MAX_BYTES} bytes.`]
|
|
332
|
+
: []),
|
|
333
|
+
].join("\n");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** One delivery as it crosses the gateway-to-Durable-Object seam. */
|
|
337
|
+
export interface RoutineHookDeliveryV1 {
|
|
338
|
+
routineId: string;
|
|
339
|
+
keyVersion: number;
|
|
340
|
+
digest: string;
|
|
341
|
+
deliveryId: string;
|
|
342
|
+
body: string;
|
|
343
|
+
contentType?: string | null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function decodeRoutineHookDeliveryV1(
|
|
347
|
+
value: unknown,
|
|
348
|
+
): RoutineHookDeliveryV1 {
|
|
349
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
350
|
+
throw new RoutineDecodeError("Routine hook delivery must be an object");
|
|
351
|
+
}
|
|
352
|
+
const candidate = value as Record<string, unknown>;
|
|
353
|
+
routineExactKeys(
|
|
354
|
+
candidate,
|
|
355
|
+
["routineId", "keyVersion", "digest", "deliveryId", "body"],
|
|
356
|
+
["contentType"],
|
|
357
|
+
"Routine hook delivery",
|
|
358
|
+
);
|
|
359
|
+
if (!isRoutineIdV1(candidate.routineId)) {
|
|
360
|
+
throw new RoutineDecodeError("Routine hook delivery routineId is invalid");
|
|
361
|
+
}
|
|
362
|
+
if (
|
|
363
|
+
!Number.isSafeInteger(candidate.keyVersion) ||
|
|
364
|
+
(candidate.keyVersion as number) < 1
|
|
365
|
+
) {
|
|
366
|
+
throw new RoutineDecodeError("Routine hook delivery keyVersion is invalid");
|
|
367
|
+
}
|
|
368
|
+
if (
|
|
369
|
+
typeof candidate.digest !== "string" ||
|
|
370
|
+
!/^[0-9a-f]{64}$/.test(candidate.digest)
|
|
371
|
+
) {
|
|
372
|
+
throw new RoutineDecodeError("Routine hook delivery digest is invalid");
|
|
373
|
+
}
|
|
374
|
+
if (
|
|
375
|
+
typeof candidate.deliveryId !== "string" ||
|
|
376
|
+
!/^[0-9a-f]{64}$/.test(candidate.deliveryId)
|
|
377
|
+
) {
|
|
378
|
+
throw new RoutineDecodeError("Routine hook delivery deliveryId is invalid");
|
|
379
|
+
}
|
|
380
|
+
if (typeof candidate.body !== "string") {
|
|
381
|
+
throw new RoutineDecodeError("Routine hook delivery body must be a string");
|
|
382
|
+
}
|
|
383
|
+
if (TEXT.encode(candidate.body).length > ROUTINE_HOOK_BODY_MAX_BYTES) {
|
|
384
|
+
throw new RoutineDecodeError("Routine hook delivery body is too large");
|
|
385
|
+
}
|
|
386
|
+
if (
|
|
387
|
+
candidate.contentType !== undefined &&
|
|
388
|
+
candidate.contentType !== null &&
|
|
389
|
+
typeof candidate.contentType !== "string"
|
|
390
|
+
) {
|
|
391
|
+
throw new RoutineDecodeError(
|
|
392
|
+
"Routine hook delivery contentType must be a string",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
routineId: candidate.routineId,
|
|
397
|
+
keyVersion: candidate.keyVersion as number,
|
|
398
|
+
digest: candidate.digest,
|
|
399
|
+
deliveryId: candidate.deliveryId,
|
|
400
|
+
body: candidate.body,
|
|
401
|
+
...(candidate.contentType === undefined
|
|
402
|
+
? {}
|
|
403
|
+
: { contentType: candidate.contentType as string | null }),
|
|
404
|
+
};
|
|
405
|
+
}
|