@mybase-dev/sdk 1.0.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/src/cron.ts ADDED
@@ -0,0 +1,339 @@
1
+ // Verifying and handling the calls mybase makes for cron jobs.
2
+ //
3
+ // mybase POSTs JSON (a CronRun) to the job's URL with
4
+ // X-Mybase-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, `${t}.${body}`)>
5
+ // using the project's cron secret. Verify it before trusting the body. Uses Web Crypto
6
+ // only, so it runs in Bun, Node 20+, Deno, and edge runtimes.
7
+ //
8
+ // The other direction: `createCronClient` schedules one-off tasks (call this job once, at
9
+ // this time) by calling the mybase API with an API key.
10
+ import { DEFAULT_CRON_PATH, type MybaseConfig } from "./config";
11
+
12
+ /** `schedule`: a cron tick. `manual`: "Run now". `task`: a one-off task scheduled with `createCronClient`. */
13
+ export type CronTrigger = "schedule" | "manual" | "task";
14
+
15
+ /** The body of every cron call. */
16
+ export interface CronRun<Job extends string = string> {
17
+ job: Job;
18
+ /** Same across retries of one run, so you can dedupe. */
19
+ runId: string;
20
+ /** 1 for the first try, then 2, 3, … on retries. */
21
+ attempt: number;
22
+ trigger: CronTrigger;
23
+ /** When the schedule said to run (ISO). For manual runs, when it was requested. For tasks, the task's run time. */
24
+ scheduledAt: string;
25
+ /** When this attempt was sent (ISO). */
26
+ firedAt: string;
27
+ payload: unknown;
28
+ /** Tasks only: the key the task was scheduled with, if any. `runId` is the task id. */
29
+ key?: string | null;
30
+ }
31
+
32
+ export const SIGNATURE_HEADER = "X-Mybase-Signature";
33
+ export const JOB_HEADER = "X-Mybase-Job";
34
+ export const RUN_HEADER = "X-Mybase-Run";
35
+
36
+ /** Thrown when a request's signature is missing, malformed, stale, or wrong. */
37
+ export class CronVerificationError extends Error {
38
+ override name = "CronVerificationError";
39
+ }
40
+
41
+ const encoder = new TextEncoder();
42
+
43
+ async function hmacHex(secret: string, message: string): Promise<string> {
44
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
45
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
46
+ return Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
47
+ }
48
+
49
+ // Compares in time independent of where the strings first differ.
50
+ function safeEqual(a: string, b: string): boolean {
51
+ if (a.length !== b.length) return false;
52
+ let diff = 0;
53
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
54
+ return diff === 0;
55
+ }
56
+
57
+ /** The `X-Mybase-Signature` value for `body`. `t` is unix seconds (default: now). */
58
+ export async function signCronRequest(secret: string, body: string, t = Math.floor(Date.now() / 1000)): Promise<string> {
59
+ return `t=${t},v1=${await hmacHex(secret, `${t}.${body}`)}`;
60
+ }
61
+
62
+ export interface VerifyOptions {
63
+ /** The raw request body, exactly as received. */
64
+ body: string;
65
+ /** The `X-Mybase-Signature` header. */
66
+ signature: string | null | undefined;
67
+ /** The project's cron secret. Pass several while rotating (new first). */
68
+ secret: string | readonly string[];
69
+ /** Max age of the signature, in seconds. Default 300. */
70
+ toleranceSec?: number;
71
+ }
72
+
73
+ /** Checks a `t=…,v1=…` header against the message for its `t`, with any of the secrets. */
74
+ async function verifySigned(
75
+ signature: string | null | undefined,
76
+ secret: string | readonly string[],
77
+ toleranceSec: number,
78
+ message: (t: number) => string,
79
+ ): Promise<void> {
80
+ if (!signature) throw new CronVerificationError(`missing ${SIGNATURE_HEADER} header`);
81
+ let t: number | undefined;
82
+ const sigs: string[] = [];
83
+ for (const part of signature.split(",")) {
84
+ const [k, v] = part.trim().split("=", 2);
85
+ if (k === "t" && v && /^\d+$/.test(v)) t = Number(v);
86
+ else if (k === "v1" && v) sigs.push(v);
87
+ }
88
+ if (t === undefined || sigs.length === 0) throw new CronVerificationError(`malformed ${SIGNATURE_HEADER} header`);
89
+ if (Math.abs(Date.now() / 1000 - t) > toleranceSec) throw new CronVerificationError("signature timestamp outside tolerance");
90
+
91
+ const secrets = typeof secret === "string" ? [secret] : secret;
92
+ if (secrets.length === 0 || secrets.some((s) => !s)) throw new CronVerificationError("no cron secret configured");
93
+ for (const s of secrets) {
94
+ const expected = await hmacHex(s, message(t));
95
+ if (sigs.some((sig) => safeEqual(sig, expected))) return;
96
+ }
97
+ throw new CronVerificationError("signature does not match");
98
+ }
99
+
100
+ /** Throws CronVerificationError unless `signature` signs `body` with one of the secrets, recently. */
101
+ export async function verifyCronSignature({ body, signature, secret, toleranceSec = 300 }: VerifyOptions): Promise<void> {
102
+ await verifySigned(signature, secret, toleranceSec, (t) => `${t}.${body}`);
103
+ }
104
+
105
+ /** Verifies a Fetch API request and returns its parsed body. Consumes the body. */
106
+ export async function verifyCronRequest(req: Request, secret: string | readonly string[], options: { toleranceSec?: number } = {}): Promise<CronRun> {
107
+ const body = await req.text();
108
+ await verifyCronSignature({ body, signature: req.headers.get(SIGNATURE_HEADER), secret, ...options });
109
+ let run: CronRun;
110
+ try {
111
+ run = JSON.parse(body);
112
+ } catch {
113
+ throw new CronVerificationError("body is not JSON");
114
+ }
115
+ if (typeof run?.job !== "string" || typeof run.runId !== "string") throw new CronVerificationError("body is not a cron run");
116
+ return run;
117
+ }
118
+
119
+ /** Job names from a config type, or any string without one. */
120
+ type JobName<C extends MybaseConfig> = C extends { cron: { jobs: infer J } } ? Extract<keyof J, string> : string;
121
+
122
+ export type CronJobHandler<Job extends string = string> = (run: CronRun<Job>) => unknown | Promise<unknown>;
123
+
124
+ export interface CronHandlerOptions<C extends MybaseConfig> {
125
+ /** The project's cron secret. Pass several while rotating (new first). */
126
+ secret: string | readonly string[];
127
+ /** A function per job name. */
128
+ jobs: { [J in JobName<C>]: CronJobHandler<J> };
129
+ /** Called when a job throws, before the 500 goes back (mybase then retries). */
130
+ onError?: (err: unknown, run: CronRun) => void;
131
+ toleranceSec?: number;
132
+ }
133
+
134
+ const reply = (body: unknown, status: number) =>
135
+ new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
136
+
137
+ /**
138
+ * A Fetch API handler (Bun.serve, Next.js route handlers, Hono, Deno, Workers) that verifies
139
+ * the call and runs the named job. 401 bad signature, 404 unknown job, 500 job threw, 200 done.
140
+ * Pass your config type for typed job names: `cronHandler<typeof config>({ ... })`.
141
+ */
142
+ export function cronHandler<C extends MybaseConfig = MybaseConfig>(options: CronHandlerOptions<C>): (req: Request) => Promise<Response> {
143
+ const jobs = options.jobs as unknown as Record<string, CronJobHandler>;
144
+ return async (req) => {
145
+ if (req.method !== "POST") return reply({ error: "method not allowed" }, 405);
146
+ let run: CronRun;
147
+ try {
148
+ run = await verifyCronRequest(req, options.secret, { toleranceSec: options.toleranceSec });
149
+ } catch (err) {
150
+ if (err instanceof CronVerificationError) return reply({ error: err.message }, 401);
151
+ throw err;
152
+ }
153
+ const job = Object.hasOwn(jobs, run.job) ? jobs[run.job] : undefined;
154
+ if (!job) return reply({ error: `no handler for job "${run.job}"` }, 404);
155
+ try {
156
+ await job(run);
157
+ } catch (err) {
158
+ if (options.onError) options.onError(err, run);
159
+ else console.error(`mybase cron job "${run.job}" failed`, err);
160
+ return reply({ error: "job failed" }, 500);
161
+ }
162
+ return reply({ ok: true }, 200);
163
+ };
164
+ }
165
+
166
+ // ---- one-off tasks ----------------------------------------------------------
167
+
168
+ export type CronTaskStatus = "pending" | "running" | "success" | "failed" | "cancelled";
169
+
170
+ /** A one-off call: mybase POSTs a CronRun (trigger `task`) to `url` once, at `runAt`. */
171
+ export interface CronTask {
172
+ id: string;
173
+ /** The handler name sent as `run.job`. */
174
+ job: string;
175
+ key: string | null;
176
+ url: string;
177
+ payload: unknown;
178
+ runAt: string;
179
+ timeoutMs: number;
180
+ retries: number;
181
+ status: CronTaskStatus;
182
+ attempts: number;
183
+ /** Last attempt's status; `null` before the call or when it got no response. */
184
+ httpStatus: number | null;
185
+ durationMs: number | null;
186
+ error: string | null;
187
+ /** Start of the last response body, truncated. */
188
+ response: string | null;
189
+ createdAt: string;
190
+ startedAt: string | null;
191
+ finishedAt: string | null;
192
+ }
193
+
194
+ /** What the API takes to create a task. Give `runAt` or `delayMs`, or neither to run as soon as possible. */
195
+ export interface CreateCronTaskRequest {
196
+ job: string;
197
+ url: string;
198
+ runAt?: string;
199
+ delayMs?: number;
200
+ key?: string;
201
+ payload?: unknown;
202
+ timeoutMs?: number;
203
+ retries?: number;
204
+ }
205
+
206
+ export interface CronClientOptions {
207
+ /** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
208
+ apiUrl: string;
209
+ /** The mybase project. */
210
+ project: string;
211
+ /** A mybase API key (`mb_…`) with `cron:write` for this project. Server-side only. */
212
+ apiKey: string;
213
+ /** Your app, as reachable from the mybase server. Tasks POST to `baseUrl` + `path` unless they set `url`. */
214
+ baseUrl?: string;
215
+ /** Default `globalThis.fetch`. */
216
+ fetch?: typeof fetch;
217
+ }
218
+
219
+ export interface ScheduleOptions {
220
+ /** When to run. */
221
+ at?: Date | string;
222
+ /** Or how long from now: milliseconds, or `"30s"`, `"15m"`, `"2h"`, `"7d"`. Neither `at` nor `in`: as soon as possible. */
223
+ in?: number | string;
224
+ /** JSON sent to your handler as `run.payload` (up to 16 KB). */
225
+ payload?: unknown;
226
+ /**
227
+ * Dedupe key. While a task with this key is pending or running, scheduling the key again
228
+ * returns that task instead of making a new one. Also lets you `get`/`cancel` by key.
229
+ */
230
+ key?: string;
231
+ /** Appended to the client's `baseUrl`. Default `/api/cron`. */
232
+ path?: string;
233
+ /** Full URL to call instead of `baseUrl` + `path`. */
234
+ url?: string;
235
+ /** Extra tries after a failed call. 0–5, default 2. */
236
+ retries?: number;
237
+ /** 1000–120000, default 30000. */
238
+ timeoutMs?: number;
239
+ }
240
+
241
+ /** A non-2xx answer from the mybase API. */
242
+ export class MybaseApiError extends Error {
243
+ override name = "MybaseApiError";
244
+ constructor(
245
+ message: string,
246
+ readonly status: number,
247
+ ) {
248
+ super(message);
249
+ }
250
+ }
251
+
252
+ const UNITS: Record<string, number> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
253
+
254
+ /** `1500`, `"30s"`, `"15m"`, `"2h"`, `"7d"` → milliseconds. */
255
+ export function parseDelay(value: number | string): number {
256
+ if (typeof value === "number") {
257
+ if (!Number.isFinite(value) || value < 0) throw new RangeError("delay must be a non-negative number of ms");
258
+ return Math.round(value);
259
+ }
260
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(value);
261
+ if (!m) throw new RangeError(`invalid delay "${value}". Use ms, or a number with ms, s, m, h, or d (e.g. "15m")`);
262
+ return Math.round(Number(m[1]) * UNITS[m[2]!]!);
263
+ }
264
+
265
+ export interface CronClient {
266
+ /** Schedules `job` to be called once. Resolves to the new task (or the pending one with the same `key`). */
267
+ schedule(job: string, options?: ScheduleOptions): Promise<CronTask>;
268
+ /** A task by id, or `null` once it's gone (finished tasks are kept 7 days). */
269
+ get(id: string): Promise<CronTask | null>;
270
+ /** The newest task with this key, or `null`. */
271
+ getByKey(key: string): Promise<CronTask | null>;
272
+ /** Cancels a pending task. Resolves to the task, or `null` when there's no such task. Throws 409 if it already ran. */
273
+ cancel(id: string): Promise<CronTask | null>;
274
+ /** Cancels the pending task with this key. */
275
+ cancelByKey(key: string): Promise<CronTask | null>;
276
+ }
277
+
278
+ /**
279
+ * Schedules one-off calls to your cron handler from app code:
280
+ *
281
+ * const cron = createCronClient({ apiUrl, project: "shop", apiKey: process.env.MYBASE_API_KEY!, baseUrl: "https://shop.example.com" });
282
+ * await cron.schedule("send-reminder", { in: "24h", payload: { orderId }, key: `reminder-${orderId}` });
283
+ *
284
+ * Keep the API key server-side.
285
+ */
286
+ export function createCronClient(options: CronClientOptions): CronClient {
287
+ const doFetch = options.fetch ?? globalThis.fetch;
288
+ const apiUrl = options.apiUrl.replace(/\/+$/, "");
289
+ const base = `/cron/${encodeURIComponent(options.project)}/tasks`;
290
+
291
+ async function call<T>(method: string, path: string, body?: unknown): Promise<T | null> {
292
+ const raw = body === undefined ? "" : JSON.stringify(body);
293
+ const res = await doFetch(`${apiUrl}${path}`, {
294
+ method,
295
+ body: body === undefined ? undefined : raw,
296
+ headers: {
297
+ "Content-Type": "application/json",
298
+ Authorization: `Bearer ${options.apiKey}`,
299
+ },
300
+ });
301
+ if (res.status === 404 && method !== "POST") return null;
302
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
303
+ if (!res.ok) throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
304
+ return data as T;
305
+ }
306
+
307
+ const byId = (id: string) => `${base}/${encodeURIComponent(id)}`;
308
+ const byKey = (key: string) => `${base}/by-key/${encodeURIComponent(key)}`;
309
+
310
+ return {
311
+ async schedule(job, opts = {}) {
312
+ if (opts.at !== undefined && opts.in !== undefined) throw new TypeError("give `at` or `in`, not both");
313
+ let url = opts.url;
314
+ if (!url) {
315
+ if (!options.baseUrl) throw new TypeError("set `baseUrl` on the client, or `url` on the task");
316
+ // Concatenated, not new URL(path, base), so a base path like https://x.dev/app is kept.
317
+ url = `${options.baseUrl.replace(/\/+$/, "")}/${(opts.path ?? DEFAULT_CRON_PATH).replace(/^\/+/, "")}`;
318
+ }
319
+ const at = opts.at === undefined ? undefined : new Date(opts.at);
320
+ if (at && Number.isNaN(at.getTime())) throw new RangeError(`invalid date "${String(opts.at)}"`);
321
+ const req: CreateCronTaskRequest = {
322
+ job,
323
+ url,
324
+ runAt: at?.toISOString(),
325
+ // Sent as a delay so the server's clock decides when "in 15m" is.
326
+ delayMs: opts.in === undefined ? undefined : parseDelay(opts.in),
327
+ key: opts.key,
328
+ payload: opts.payload,
329
+ retries: opts.retries,
330
+ timeoutMs: opts.timeoutMs,
331
+ };
332
+ return (await call<CronTask>("POST", base, req))!;
333
+ },
334
+ get: (id) => call<CronTask>("GET", byId(id)),
335
+ getByKey: (key) => call<CronTask>("GET", byKey(key)),
336
+ cancel: (id) => call<CronTask>("DELETE", byId(id)),
337
+ cancelByKey: (key) => call<CronTask>("DELETE", byKey(key)),
338
+ };
339
+ }