@tribe-nest/forge 2.2.0 → 3.2.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.
@@ -16,11 +16,26 @@
16
16
  * first, then use it for the part that genuinely needs owner-level access.
17
17
  */
18
18
 
19
+ import type {
20
+ PlatformEventName,
21
+ PlatformEventData,
22
+ } from "./platformEvents.generated";
23
+
19
24
  export type PlatformConfig = {
20
25
  /** The TribeNest public API base (VITE_API_URL). */
21
26
  apiUrl: string;
22
27
  /** The app's platform token, injected as a server-only Worker binding. */
23
28
  token: string;
29
+ /**
30
+ * The event being handled, when this client is used inside an event handler.
31
+ *
32
+ * Set for you by `handlePlatformEvent` — you should not need to pass it. Its
33
+ * only job is to make every write idempotent without you deriving a key: the
34
+ * key becomes a hash of this event, the action and the input, so the same
35
+ * write attempted again during a redelivery returns the first result instead of
36
+ * acting twice. See `run()`.
37
+ */
38
+ eventId?: string;
24
39
  };
25
40
 
26
41
  export type PlatformAction = {
@@ -105,19 +120,26 @@ export function createPlatformClient(config: PlatformConfig) {
105
120
  /**
106
121
  * Run an action.
107
122
  *
108
- * `idempotencyKey` is REQUIRED for anything that writes. Derive it from your
109
- * own record id: event delivery is at-least-once and jobs retry, so a write
110
- * without one duplicates the first time anything is redelivered. A replay
111
- * returns the original result instead of acting again.
123
+ * INSIDE AN EVENT HANDLER you do not need an idempotency key — one is derived
124
+ * from the event, the action and the input, so a redelivered event cannot
125
+ * make the same write twice. That is the whole reason handlers can be
126
+ * retried safely; you get it by doing nothing.
127
+ *
128
+ * ANYWHERE ELSE a write REQUIRES `idempotencyKey`, derived from your own
129
+ * record id. Jobs retry, so a write without one duplicates.
130
+ *
131
+ * Pass an explicit key to override the derived one — only needed in the rare
132
+ * case where one handler genuinely has to make the SAME write twice.
112
133
  */
113
134
  async run<T = unknown>(
114
135
  id: string,
115
136
  input: unknown,
116
137
  opts: { idempotencyKey?: string; dryRun?: boolean } = {},
117
138
  ): Promise<T> {
139
+ const key = opts.idempotencyKey ?? (config.eventId ? await derivedKey(config.eventId, id, input) : undefined);
118
140
  const body = await request(`/actions/${encodeURIComponent(id)}`, {
119
141
  method: "POST",
120
- headers: opts.idempotencyKey ? { "idempotency-key": opts.idempotencyKey } : {},
142
+ headers: key ? { "idempotency-key": key } : {},
121
143
  body: JSON.stringify({ input, ...(opts.dryRun ? { dryRun: true } : {}) }),
122
144
  });
123
145
  return body.result as T;
@@ -138,6 +160,44 @@ export function createPlatformClient(config: PlatformConfig) {
138
160
 
139
161
  export type PlatformClient = ReturnType<typeof createPlatformClient>;
140
162
 
163
+ /**
164
+ * The idempotency key for a write made while handling an event.
165
+ *
166
+ * Built from the event, the action and the input — NOT from a call counter,
167
+ * which would shift the moment a handler looped or branched differently and
168
+ * stop matching on the retry. Two genuinely different writes hash differently;
169
+ * the same write attempted again hashes the same and is refused a second run.
170
+ */
171
+ async function derivedKey(eventId: string, actionId: string, input: unknown): Promise<string> {
172
+ const digest = await sha256Hex(`${eventId}:${actionId}:${canonicalJson(input)}`);
173
+ // The column is varchar(200); a prefix is unique enough and keeps the audit
174
+ // trail readable, with the event id up front so a human can see where it came from.
175
+ return `evt_${eventId}_${digest.slice(0, 32)}`;
176
+ }
177
+
178
+ /**
179
+ * JSON with object keys sorted, so `{a,b}` and `{b,a}` hash alike.
180
+ *
181
+ * Key order is not stable across runs — an unsorted stringify would produce a
182
+ * different key for the same write and quietly reintroduce the duplicate this
183
+ * exists to prevent.
184
+ */
185
+ function canonicalJson(value: unknown): string {
186
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
187
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
188
+ const entries = Object.entries(value as Record<string, unknown>)
189
+ .filter(([, v]) => v !== undefined)
190
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
191
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
192
+ }
193
+
194
+ async function sha256Hex(input: string): Promise<string> {
195
+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
196
+ return Array.from(new Uint8Array(bytes))
197
+ .map((b) => b.toString(16).padStart(2, "0"))
198
+ .join("");
199
+ }
200
+
141
201
  // --------------------------------------------------------------------- events
142
202
 
143
203
  export type PlatformEvent<T = Record<string, unknown>> = {
@@ -146,10 +206,29 @@ export type PlatformEvent<T = Record<string, unknown>> = {
146
206
  eventId: string;
147
207
  appId: string;
148
208
  profileId: string;
209
+ /** When it HAPPENED — not when it was delivered. Stable across a redelivery. */
149
210
  occurredAt: string;
150
211
  data: T;
151
212
  };
152
213
 
214
+ /** One named event, with its body typed from the catalog. */
215
+ export type TypedPlatformEvent<E extends PlatformEventName = PlatformEventName> = Omit<
216
+ PlatformEvent,
217
+ "event" | "data"
218
+ > & {
219
+ event: E;
220
+ data: PlatformEventData<E>;
221
+ };
222
+
223
+ /**
224
+ * How long after signing a delivery is still accepted.
225
+ *
226
+ * Without a window a delivery captured off the wire could be pushed at the app
227
+ * for as long as the signing key lived. Generous enough to survive clock skew
228
+ * and a slow retry, short enough that a captured body stops being useful.
229
+ */
230
+ export const SIGNATURE_TOLERANCE_SECONDS = 300;
231
+
153
232
  /**
154
233
  * Verify and parse a platform event delivered to your app.
155
234
  *
@@ -173,17 +252,163 @@ export async function verifyPlatformEvent<T = Record<string, unknown>>(
173
252
  request: Request,
174
253
  secret: string,
175
254
  ): Promise<PlatformEvent<T>> {
176
- const signature = request.headers.get("x-tribenest-signature");
177
- if (!signature) throw new Error("Missing platform event signature.");
178
-
179
255
  const body = await request.text();
256
+
257
+ // Preferred: signed over a timestamp as well as the body, so a captured
258
+ // delivery cannot be pushed at the app days later.
259
+ const v2 = request.headers.get("x-tribenest-signature-v2");
260
+ if (v2) {
261
+ const parts = Object.fromEntries(
262
+ v2.split(",").map((p) => {
263
+ const i = p.indexOf("=");
264
+ return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
265
+ }),
266
+ );
267
+ const timestamp = Number(parts.t);
268
+ if (!Number.isFinite(timestamp) || !parts.v1) {
269
+ throw new Error("Platform event signature is malformed.");
270
+ }
271
+ const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
272
+ if (age > SIGNATURE_TOLERANCE_SECONDS) {
273
+ throw new Error("Platform event signature is outside the accepted time window.");
274
+ }
275
+ const expected = await hmacHex(secret, `${timestamp}.${body}`);
276
+ if (!timingSafeEqual(parts.v1, expected)) {
277
+ throw new Error("Platform event signature did not verify.");
278
+ }
279
+ return JSON.parse(body) as PlatformEvent<T>;
280
+ }
281
+
282
+ // Body-only signature. Still accepted so a site running an older Forge keeps
283
+ // receiving events through the rollout; the platform sends both headers.
284
+ const legacy = request.headers.get("x-tribenest-signature");
285
+ if (!legacy) throw new Error("Missing platform event signature.");
180
286
  const expected = await hmacHex(secret, body);
181
- if (!timingSafeEqual(signature, expected)) {
287
+ if (!timingSafeEqual(legacy, expected)) {
182
288
  throw new Error("Platform event signature did not verify.");
183
289
  }
184
290
  return JSON.parse(body) as PlatformEvent<T>;
185
291
  }
186
292
 
293
+ /**
294
+ * A map from event name to what your app does about it.
295
+ *
296
+ * `event.data` is typed from the event's name, so `handlers["order.paid"]` gets
297
+ * the order, its lines and the buyer without a cast.
298
+ */
299
+ export type PlatformEventHandlers = {
300
+ [E in PlatformEventName]?: (
301
+ event: TypedPlatformEvent<E>,
302
+ ctx: { platform: PlatformClient },
303
+ ) => Promise<void> | void;
304
+ };
305
+
306
+ export type HandlePlatformEventOptions = {
307
+ secret: string;
308
+ handlers: PlatformEventHandlers;
309
+ /** For the platform client handed to your handler. */
310
+ apiUrl: string;
311
+ token?: string;
312
+ /**
313
+ * How long a handler may run before the platform's own delivery attempt times
314
+ * out. Overrunning is reported as an error the agent can act on rather than an
315
+ * opaque timeout.
316
+ */
317
+ budgetMs?: number;
318
+ };
319
+
320
+ /** Handler budget. Under the platform's own 15s delivery timeout, deliberately. */
321
+ export const HANDLER_BUDGET_MS = 10_000;
322
+
323
+ /**
324
+ * Verify a delivery, run the matching handler, and answer.
325
+ *
326
+ * THE RULE THIS ENFORCES: a 200 means the work is done. Your handler never
327
+ * writes the response — this does, and only after your handler's promise has
328
+ * settled. Once the platform has a 200 it will never send that event again, so
329
+ * answering early would lose it.
330
+ *
331
+ * Which means: do not fire a promise you don't await, and don't reach for
332
+ * `waitUntil`. If the work is slow, `enqueueAppJob` it and return — the job
333
+ * runtime has its own retries, and a 200 then honestly means "durably queued".
334
+ *
335
+ * An unknown event answers 200 and does nothing, so events you have not written
336
+ * a handler for are not retried forever.
337
+ */
338
+ export async function handlePlatformEvent(
339
+ request: Request,
340
+ options: HandlePlatformEventOptions,
341
+ ): Promise<Response> {
342
+ let event: PlatformEvent;
343
+ try {
344
+ event = await verifyPlatformEvent(request, options.secret);
345
+ } catch (err) {
346
+ // An unverified body is untrusted input from the open internet. Refuse it
347
+ // without saying which check failed.
348
+ return new Response(JSON.stringify({ error: (err as Error).message }), {
349
+ status: 401,
350
+ headers: { "content-type": "application/json" },
351
+ });
352
+ }
353
+
354
+ const handler = options.handlers[event.event as PlatformEventName];
355
+ if (!handler) {
356
+ return new Response(JSON.stringify({ ok: true, ignored: event.event }), {
357
+ status: 200,
358
+ headers: { "content-type": "application/json" },
359
+ });
360
+ }
361
+
362
+ const platform = createPlatformClient({
363
+ apiUrl: options.apiUrl,
364
+ token: options.token ?? "",
365
+ // This is what makes every write in the handler idempotent for free.
366
+ eventId: event.eventId,
367
+ });
368
+
369
+ const budget = options.budgetMs ?? HANDLER_BUDGET_MS;
370
+ try {
371
+ await withTimeout(
372
+ Promise.resolve(
373
+ (handler as (e: PlatformEvent, c: { platform: PlatformClient }) => Promise<void> | void)(event, {
374
+ platform,
375
+ }),
376
+ ),
377
+ budget,
378
+ `Handler for "${event.event}" took longer than ${budget}ms. Move the slow part into ` +
379
+ `enqueueAppJob() and return — the platform's delivery attempt times out before this finishes.`,
380
+ );
381
+ } catch (err) {
382
+ // 500 so the platform retries. Writes already made carry keys derived from
383
+ // this same event, so the retry repeats none of them.
384
+ return new Response(JSON.stringify({ error: (err as Error).message, eventId: event.eventId }), {
385
+ status: 500,
386
+ headers: { "content-type": "application/json" },
387
+ });
388
+ }
389
+
390
+ return new Response(JSON.stringify({ ok: true, eventId: event.eventId }), {
391
+ status: 200,
392
+ headers: { "content-type": "application/json" },
393
+ });
394
+ }
395
+
396
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
397
+ return new Promise((resolve, reject) => {
398
+ const timer = setTimeout(() => reject(new Error(message)), ms);
399
+ promise.then(
400
+ (value) => {
401
+ clearTimeout(timer);
402
+ resolve(value);
403
+ },
404
+ (err) => {
405
+ clearTimeout(timer);
406
+ reject(err);
407
+ },
408
+ );
409
+ });
410
+ }
411
+
187
412
  async function hmacHex(secret: string, body: string): Promise<string> {
188
413
  const enc = new TextEncoder();
189
414
  const key = await crypto.subtle.importKey(