@superlayer/webhooks 1.0.67

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,756 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Webhooks = exports.WebhooksManager = void 0;
4
+ const core_1 = require("@superlayer/core");
5
+ // Upstream signing keys belong only to their original service. Superlayer
6
+ // resolves its own keys from its HTTPS JWKS endpoint.
7
+ const knownKeys = {
8
+ 'https://api.instantdb.com': {
9
+ keys: [
10
+ {
11
+ kty: 'OKP',
12
+ crv: 'Ed25519',
13
+ alg: 'EdDSA',
14
+ use: 'sig',
15
+ kid: '1034696293',
16
+ x: 'N-C41432STKAKkXAWmeIOXMnZcGRR1b9u1L3bTVqI_o',
17
+ },
18
+ ],
19
+ },
20
+ 'http://localhost:8888': {
21
+ keys: [
22
+ {
23
+ kty: 'OKP',
24
+ crv: 'Ed25519',
25
+ alg: 'EdDSA',
26
+ use: 'sig',
27
+ kid: '503090235',
28
+ x: 'qrSkwDaMITRMF9nOgpueqxgaAiuFmJperYE3mkyl8Ow',
29
+ },
30
+ ],
31
+ },
32
+ };
33
+ function inferWebCryptoAlg(jwk) {
34
+ if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') {
35
+ return { name: 'Ed25519' };
36
+ }
37
+ if (jwk.kty === 'EC') {
38
+ return { name: 'ECDSA', namedCurve: jwk.crv }; // e.g., P-256, P-384
39
+ }
40
+ if (jwk.kty === 'RSA') {
41
+ // RSA keys often specify the exact hash in the 'alg' field (e.g., RS256)
42
+ const hashMap = {
43
+ RS256: 'SHA-256',
44
+ RS384: 'SHA-384',
45
+ RS512: 'SHA-512',
46
+ };
47
+ return {
48
+ name: 'RSASSA-PKCS1-v1_5',
49
+ hash: hashMap[jwk.alg] || 'SHA-256',
50
+ };
51
+ }
52
+ throw new Error(`Unsupported JWK configuration: kty=${jwk.kty}, crv=${jwk.crv}`);
53
+ }
54
+ async function importKey(jwk) {
55
+ const alg = inferWebCryptoAlg(jwk);
56
+ const key = await crypto.subtle.importKey('jwk', jwk, alg, false, ['verify']);
57
+ return { alg, key };
58
+ }
59
+ function verify(alg, key, signature, message) {
60
+ return crypto.subtle.verify(alg, key, signature, message);
61
+ }
62
+ function hexToUint8Array(hexString) {
63
+ const bytes = new Uint8Array(Math.ceil(hexString.length / 2));
64
+ for (let i = 0; i < bytes.length; i++) {
65
+ bytes[i] = parseInt(hexString.substring(i * 2, i * 2 + 2), 16);
66
+ }
67
+ return bytes;
68
+ }
69
+ function parseSignatureHeader(h) {
70
+ let t, kid, v1;
71
+ for (const part of h.split(',')) {
72
+ const [k, v] = part.split('=');
73
+ switch (k) {
74
+ case 't': {
75
+ t = v;
76
+ break;
77
+ }
78
+ case 'kid': {
79
+ kid = v;
80
+ break;
81
+ }
82
+ case 'v1': {
83
+ v1 = v;
84
+ break;
85
+ }
86
+ }
87
+ }
88
+ const missingKeys = [];
89
+ if (!t) {
90
+ missingKeys.push('t');
91
+ }
92
+ if (!kid) {
93
+ missingKeys.push('kid');
94
+ }
95
+ if (!v1) {
96
+ missingKeys.push('v1');
97
+ }
98
+ if (missingKeys.length || !t || !kid || !v1) {
99
+ throw new core_1.InstantError('Invalid Instant-Signature header.', {
100
+ header: h,
101
+ missingKeys,
102
+ });
103
+ }
104
+ return { t, kid, v1 };
105
+ }
106
+ function validateT(receivedAt, t, tolerance) {
107
+ const age = Math.floor(receivedAt.getTime() / 1000) - parseInt(t, 10);
108
+ if (age > tolerance) {
109
+ throw new core_1.InstantError('Webhook signature is too old', {
110
+ tolerance,
111
+ receivedAt,
112
+ t,
113
+ });
114
+ }
115
+ }
116
+ // We make this a global cache so that it will survive across
117
+ // restarts. It will only store valid signing keys from instant, and we don't
118
+ // create many of them, so the memory usage will be only 1 or 2 keys.
119
+ const keyCache = {};
120
+ const defaultTolerance = 300; // 5 minutes
121
+ async function jsonReject(rejectFn, res) {
122
+ const body = await res.text();
123
+ try {
124
+ const json = JSON.parse(body);
125
+ return rejectFn(new core_1.InstantAPIError({ status: res.status, body: json }));
126
+ }
127
+ catch (_e) {
128
+ return rejectFn(new core_1.InstantAPIError({
129
+ status: res.status,
130
+ body: { type: undefined, message: body },
131
+ }));
132
+ }
133
+ }
134
+ const defaultJsonFetch = async (input, init) => {
135
+ const headers = {
136
+ ...(init?.headers || {}),
137
+ 'Instant-Core-Version': core_1.version,
138
+ };
139
+ const res = await fetch(input, { ...init, headers });
140
+ if (res.status === 200) {
141
+ return res.json();
142
+ }
143
+ return jsonReject((x) => Promise.reject(x), res);
144
+ };
145
+ function parseDate(s) {
146
+ return s ? new Date(s) : null;
147
+ }
148
+ function toWebhookInfo(raw) {
149
+ return {
150
+ id: raw.id,
151
+ sink: raw.sink,
152
+ namespaces: raw.namespaces ?? [],
153
+ actions: raw.actions,
154
+ status: raw.status,
155
+ disabledReason: raw.disabled_reason ?? null,
156
+ createdAt: new Date(raw.created_at),
157
+ updatedAt: new Date(raw.updated_at),
158
+ };
159
+ }
160
+ function toWebhookAttempt(raw) {
161
+ return {
162
+ attemptAt: parseDate(raw['attempt-at']),
163
+ durationMs: raw['duration-ms'] ?? null,
164
+ success: raw['success?'] ?? null,
165
+ statusCode: raw['status-code'] ?? null,
166
+ responseText: raw['response-text'] ?? null,
167
+ errorType: raw['error-type'] ?? null,
168
+ errorMessage: raw['error-message'] ?? null,
169
+ };
170
+ }
171
+ function toWebhookEventInfo(raw) {
172
+ return {
173
+ isn: raw.isn,
174
+ status: raw.status,
175
+ attempts: raw.attempts ? raw.attempts.map(toWebhookAttempt) : null,
176
+ nextAttemptAfter: parseDate(raw.next_attempt_after),
177
+ createdAt: new Date(raw.created_at),
178
+ updatedAt: new Date(raw.updated_at),
179
+ };
180
+ }
181
+ class WebhooksManager {
182
+ #appId;
183
+ #apiURI;
184
+ #token;
185
+ #withAuth;
186
+ #jsonFetch;
187
+ constructor(opts) {
188
+ this.#appId = opts.appId;
189
+ this.#apiURI = opts.apiURI;
190
+ this.#token = opts.token;
191
+ this.#withAuth = opts.withAuth;
192
+ this.#jsonFetch = opts.jsonFetch;
193
+ }
194
+ #authedFetch(path, opts) {
195
+ if (!this.#appId) {
196
+ throw new core_1.InstantError('appId is required to manage webhooks. Pass it to the Webhooks constructor.');
197
+ }
198
+ const run = (token) => {
199
+ const init = {
200
+ method: opts?.method,
201
+ headers: {
202
+ authorization: `Bearer ${token}`,
203
+ 'content-type': 'application/json',
204
+ },
205
+ };
206
+ if (opts?.body !== undefined) {
207
+ init.body = JSON.stringify(opts.body);
208
+ }
209
+ return this.#jsonFetch(`${this.#apiURI}${path}`, init);
210
+ };
211
+ if (this.#withAuth) {
212
+ return this.#withAuth(run);
213
+ }
214
+ if (!this.#token) {
215
+ throw new core_1.InstantError('A token is required to manage webhooks. Pass `adminToken` or `token` to the Webhooks constructor.');
216
+ }
217
+ return run(this.#token);
218
+ }
219
+ /**
220
+ * Returns every webhook configured on the app, newest first. Includes both
221
+ * active and disabled webhooks.
222
+ */
223
+ async list() {
224
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks`);
225
+ return (res.webhooks || []).map(toWebhookInfo);
226
+ }
227
+ /**
228
+ * Creates a new webhook. The webhook is created in the `active` state and
229
+ * starts receiving matching events immediately.
230
+ *
231
+ * The server rejects the request if `url` is not an HTTPS URL pointing at a
232
+ * public host, if `namespaces` doesn't reference any entity in the app's
233
+ * schema, if `actions` is empty, or if the app has hit its webhook limit.
234
+ *
235
+ * An app may have at most **100 active webhooks** at a time; {@link delete}
236
+ * a webhook to free up a slot before creating another.
237
+ *
238
+ * @example
239
+ * const webhook = await db.webhooks.manager.create({
240
+ * url: 'https://example.com/instant',
241
+ * namespaces: ['posts', 'comments'],
242
+ * actions: ['create', 'update'],
243
+ * });
244
+ */
245
+ async create(params) {
246
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks`, {
247
+ method: 'POST',
248
+ body: params,
249
+ });
250
+ return toWebhookInfo(res.webhook);
251
+ }
252
+ /**
253
+ * Updates a webhook's `url`, `namespaces`, and/or `actions`. Pass only the
254
+ * fields you want to change; omitted fields keep their current value.
255
+ *
256
+ * Does not affect the webhook's status — use {@link enable} or
257
+ * {@link disable} for that.
258
+ */
259
+ async update(webhookId, params) {
260
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks/${webhookId}`, { method: 'POST', body: params });
261
+ return toWebhookInfo(res.webhook);
262
+ }
263
+ /**
264
+ * Deletes a webhook. No further events will be queued for it. Returns the
265
+ * webhook as it looked just before deletion.
266
+ */
267
+ async delete(webhookId) {
268
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks/${webhookId}`, { method: 'DELETE' });
269
+ return toWebhookInfo(res.webhook);
270
+ }
271
+ /**
272
+ * Re-enables a disabled webhook. Clears `disabledReason` and resumes
273
+ * delivery for new events. Has no effect if the webhook is already active.
274
+ *
275
+ * Events that occurred while the webhook was disabled are not retroactively
276
+ * delivered.
277
+ */
278
+ async enable(webhookId) {
279
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks/${webhookId}/enable`, { method: 'POST', body: {} });
280
+ return toWebhookInfo(res.webhook);
281
+ }
282
+ /**
283
+ * Disables a webhook. No new events will be queued until it is re-enabled
284
+ * via {@link enable}. In-flight events already being processed will still
285
+ * complete.
286
+ *
287
+ * @param opts.reason Optional human-readable note stored on the webhook
288
+ * and surfaced in the dashboard.
289
+ */
290
+ async disable(webhookId, opts) {
291
+ const body = opts?.reason ? { reason: opts.reason } : {};
292
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks/${webhookId}/disable`, { method: 'POST', body });
293
+ return toWebhookInfo(res.webhook);
294
+ }
295
+ /**
296
+ * Returns a page of events for a webhook, newest first.
297
+ *
298
+ * Events are retained for ~60 days. To paginate, pass the previous page's
299
+ * `pageInfo.endCursor` as `opts.after`; stop when `pageInfo.hasNextPage`
300
+ * is `false`.
301
+ */
302
+ async listEvents(webhookId, opts) {
303
+ const qs = opts?.after ? `?after=${encodeURIComponent(opts.after)}` : '';
304
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks/${webhookId}/events${qs}`);
305
+ return {
306
+ events: (res.events || []).map(toWebhookEventInfo),
307
+ pageInfo: {
308
+ startCursor: res.pageInfo?.startCursor ?? null,
309
+ endCursor: res.pageInfo?.endCursor ?? null,
310
+ hasNextPage: !!res.pageInfo?.hasNextPage,
311
+ },
312
+ };
313
+ }
314
+ /**
315
+ * Fetches a single webhook event by its `isn`.
316
+ */
317
+ async getEvent(webhookId, isn) {
318
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks/${webhookId}/events/${isn}`);
319
+ return toWebhookEventInfo(res.event);
320
+ }
321
+ /** Returns the full payload for an event. */
322
+ async getPayload(webhookId, isn) {
323
+ return this.#authedFetch(`/webhooks/payload/${this.#appId}/${webhookId}/${isn}`);
324
+ }
325
+ /**
326
+ * Re-queues an event for delivery, regardless of its current status. Use
327
+ * this to retry a `failed` event or force a redelivery of a `success` one.
328
+ *
329
+ * The server rate-limits resends; if the event was queued or resent very
330
+ * recently the call will fail with a validation error asking you to try
331
+ * again in about a minute.
332
+ */
333
+ async resendEvent(webhookId, isn) {
334
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks/${webhookId}/events/${isn}`, { method: 'POST', body: {} });
335
+ return toWebhookEventInfo(res.event);
336
+ }
337
+ }
338
+ exports.WebhooksManager = WebhooksManager;
339
+ /**
340
+ * Verify incoming webhook requests from Instant, dispatch their records to
341
+ * typed handlers, and manage webhook subscriptions (via {@link manager}).
342
+ *
343
+ * Usually accessed as `db.webhooks` on the admin or platform SDK rather than
344
+ * constructed directly.
345
+ */
346
+ class Webhooks {
347
+ /** App this instance is bound to. */
348
+ appId;
349
+ /** Schema used to type webhook payloads and handler records. */
350
+ schema;
351
+ #token;
352
+ /** Base URL for the Instant API. */
353
+ apiURI;
354
+ #jsonFetch;
355
+ /** Manage webhook subscriptions and inspect delivery events. */
356
+ manager;
357
+ /**
358
+ * Schema-bound helpers for building typed handler maps.
359
+ *
360
+ * - `typedHandlers(namespace, action, handler)` builds a single typed entry.
361
+ * Pass `'$default'` for `namespace` to register a catch-all handler.
362
+ * - `combineHandlers(...entries)` merges entries into a
363
+ * {@link WebhookHandlers} object suitable for {@link processPayload} and
364
+ * {@link processRequest}.
365
+ *
366
+ * If you already have a {@link Webhooks} instance, prefer the instance
367
+ * form (`db.webhooks.helpers()`) — it infers `Schema` automatically.
368
+ *
369
+ * @example
370
+ * const { typedHandlers, combineHandlers } = Webhooks.helpers<typeof schema>();
371
+ * const handlers = combineHandlers(
372
+ * typedHandlers('posts', 'create', (record) => { ... }),
373
+ * typedHandlers('comments', '$default', (record) => { ... }),
374
+ * typedHandlers('$default', (record) => { ... }),
375
+ * );
376
+ */
377
+ static helpers() {
378
+ function typedHandlers(...args) {
379
+ if (args.length === 2) {
380
+ return { $default: args[1] };
381
+ }
382
+ const [namespace, action, handler] = args;
383
+ return { [namespace]: { [action]: handler } };
384
+ }
385
+ function combineHandlers(...entries) {
386
+ const result = {};
387
+ for (const entry of entries) {
388
+ for (const key of Object.keys(entry)) {
389
+ if (key === '$default') {
390
+ result.$default = entry.$default;
391
+ }
392
+ else {
393
+ result[key] = { ...result[key], ...entry[key] };
394
+ }
395
+ }
396
+ }
397
+ return result;
398
+ }
399
+ return {
400
+ typedHandlers: typedHandlers,
401
+ combineHandlers: combineHandlers,
402
+ };
403
+ }
404
+ /**
405
+ * Instance form of {@link Webhooks.helpers} that infers `Schema` from this
406
+ * instance — no `<typeof schema>` type argument required.
407
+ *
408
+ * @example
409
+ * const { typedHandlers, combineHandlers } = db.webhooks.helpers();
410
+ */
411
+ helpers() {
412
+ return Webhooks.helpers();
413
+ }
414
+ constructor(config, jsonFetch) {
415
+ this.appId = config.appId;
416
+ this.schema = config.schema;
417
+ this.#token = config.adminToken || config.token;
418
+ this.apiURI = config.apiURI || 'https://api.interfacedb.com';
419
+ this.#jsonFetch = jsonFetch || defaultJsonFetch;
420
+ this.manager = new WebhooksManager({
421
+ appId: this.appId,
422
+ apiURI: this.apiURI,
423
+ token: this.#token,
424
+ withAuth: config.withAuth,
425
+ jsonFetch: this.#jsonFetch,
426
+ });
427
+ }
428
+ /** Fetches Instant's JWK set for verifying webhook signatures. */
429
+ async fetchJwks() {
430
+ const resp = await this.#jsonFetch(`${this.apiURI}/.well-known/webhooks/jwks.json`);
431
+ return resp;
432
+ }
433
+ /**
434
+ * Resolves a `kid` to an imported {@link CryptoKey}, hitting a
435
+ * process-wide cache on repeat calls. Falls back to {@link fetchJwks} if
436
+ * the key isn't already known.
437
+ */
438
+ async keyOfKid(kid) {
439
+ const cacheKey = `${this.apiURI}:${kid}`;
440
+ const cached = keyCache[cacheKey];
441
+ if (cached) {
442
+ return cached;
443
+ }
444
+ const jwk = knownKeys[this.apiURI]?.keys.find((k) => k.kid === kid) ||
445
+ (await this.fetchJwks())?.keys?.find((k) => k.kid === kid);
446
+ if (!jwk) {
447
+ throw new core_1.InstantError('Could not find matching signing key', { kid });
448
+ }
449
+ const res = await importKey(jwk);
450
+ keyCache[cacheKey] = res;
451
+ return res;
452
+ }
453
+ /**
454
+ * Verifies an `Instant-Signature` header against a body and returns the
455
+ * parsed {@link WebhookBody} (containing the `payloadUrl` and a JWT
456
+ * `token` for fetching the records).
457
+ *
458
+ * Throws if the signature doesn't validate, the signature is older than
459
+ * `opts.tolerance` (default 300 seconds), or the body doesn't decode to
460
+ * the expected shape.
461
+ *
462
+ * @param body Either the raw body string, or a function returning it.
463
+ * Use a function to defer reading the body until after the
464
+ * header has been parsed.
465
+ */
466
+ async validate(signatureHeader, body, opts) {
467
+ const receivedAt = opts?.receivedAt || new Date();
468
+ const { t, kid, v1 } = parseSignatureHeader(signatureHeader);
469
+ const tolerance = opts?.tolerance ?? defaultTolerance;
470
+ validateT(receivedAt, t, tolerance);
471
+ const { alg, key } = await this.keyOfKid(kid);
472
+ const bodyText = typeof body === 'function' ? await body() : body;
473
+ const message = new TextEncoder().encode(`${t}.${bodyText}`);
474
+ const verified = await verify(alg, key, hexToUint8Array(v1), message);
475
+ if (!verified) {
476
+ throw new core_1.InstantError('Instant Signature did not validate', {
477
+ header: signatureHeader,
478
+ });
479
+ }
480
+ const res = JSON.parse(bodyText);
481
+ if (typeof res !== 'object' ||
482
+ typeof res.payloadUrl !== 'string' ||
483
+ typeof res.token !== 'string') {
484
+ throw new core_1.InstantError('Invalid webhook body, expected an object with payloadUrl and token fields', { body: res });
485
+ }
486
+ return res;
487
+ }
488
+ /**
489
+ * Pulls the `Instant-Signature` header and body from a `Request` and
490
+ * delegates to {@link validate}. Throws if the header is missing.
491
+ */
492
+ async validateRequest(req, opts) {
493
+ const signatureHeader = req.headers.get('instant-signature');
494
+ if (!signatureHeader) {
495
+ throw new core_1.InstantError('Request is missing Instant-Signature header');
496
+ }
497
+ return this.validate(signatureHeader, () => req.text(), opts);
498
+ }
499
+ /**
500
+ * Fetches the records and `idempotencyKey` for a validated
501
+ * {@link WebhookBody}, authenticating with the JWT `token` it carries.
502
+ */
503
+ fetchPayloads({ payloadUrl, token, }) {
504
+ return this.#jsonFetch(payloadUrl, {
505
+ headers: { Authorization: `Bearer ${token}`, accept: 'application/json' },
506
+ });
507
+ }
508
+ /**
509
+ * Dispatches each record in `payload` to its matching handler in
510
+ * `handlers`. Resolution order per record: exact `namespace` + `action` →
511
+ * `namespace`'s `$default` → top-level `$default`. Records with no matching
512
+ * handler are skipped.
513
+ *
514
+ * Handlers run concurrently. If any handler rejects, the call rejects so
515
+ * the caller (e.g. {@link processRequest}) can return a non-2xx response
516
+ * and let Instant retry the event.
517
+ */
518
+ async processPayload(handlers, payload) {
519
+ const results = [];
520
+ for (const record of payload.data) {
521
+ const { namespace, action } = record;
522
+ const handler = handlers?.[namespace]?.[action] ||
523
+ handlers?.[namespace]?.$default ||
524
+ handlers?.$default;
525
+ if (handler) {
526
+ // We need the as any here because typescript
527
+ // has trouble correlating the handler to the
528
+ // record namespace and action
529
+ results.push(handler(record));
530
+ }
531
+ }
532
+ await Promise.all(results);
533
+ }
534
+ /**
535
+ * The one-liner for handling webhooks. Hand it your handlers and the
536
+ * incoming `Request` — it verifies the signature, fetches the records, and
537
+ * dispatches each one to your code.
538
+ *
539
+ * Async handlers are executed in parallel, the return promise will resolve once
540
+ * all handlers complete and will reject if any of the handlers fails.
541
+ *
542
+ * @example
543
+ * const { typedHandlers, combineHandlers } = db.webhooks.helpers();
544
+ *
545
+ * const handlers = combineHandlers(
546
+ * typedHandlers('posts', 'create', async (record) => {
547
+ * await sendNewPostEmail(record.after);
548
+ * }),
549
+ * typedHandlers('$default', (record) => {
550
+ * console.log('webhook event', record);
551
+ * }),
552
+ * );
553
+ *
554
+ * export async function POST(req: Request) {
555
+ * await db.webhooks.processRequest(handlers, req);
556
+ * return new Response('ok');
557
+ * }
558
+ */
559
+ async processRequest(handlers, req, opts) {
560
+ const body = await this.validateRequest(req, opts);
561
+ const payload = await this.fetchPayloads(body);
562
+ await this.processPayload(handlers, payload);
563
+ }
564
+ /**
565
+ * Adapter for frameworks that hand you a Node-style `http.IncomingMessage`
566
+ * (Next.js Pages Router, Express, Koa, etc.) instead of a Web `Request`.
567
+ * Wraps the request in a Web `Request` and delegates to
568
+ * {@link processRequest}. You still send the HTTP response yourself.
569
+ *
570
+ * The raw body is required for signature verification. The adapter picks
571
+ * it up from one of:
572
+ *
573
+ * - `req.body` if it's a `Buffer` or `Uint8Array` (set by middleware like
574
+ * `express.raw({ type: 'application/json' })`)
575
+ * - `req.body` if it's a string (set by middleware like `express.text()`)
576
+ * - otherwise the unconsumed request stream
577
+ *
578
+ * Don't use a JSON body parser on this route — `express.json()` and
579
+ * `bodyParser: true` in Next.js both parse the body into an object,
580
+ * destroying the raw bytes the signature was computed over.
581
+ *
582
+ * @example
583
+ * // Next.js Pages Router (`pages/api/webhooks.ts`)
584
+ * import type { NextApiRequest, NextApiResponse } from 'next';
585
+ *
586
+ * export const config = { api: { bodyParser: false } };
587
+ *
588
+ * const { typedHandlers, combineHandlers } = db.webhooks.helpers();
589
+ *
590
+ * const handlers = combineHandlers(
591
+ * typedHandlers('posts', 'create', async (record) => {
592
+ * await sendNewPostEmail(record.after);
593
+ * }),
594
+ * typedHandlers('$default', (record) => {
595
+ * console.log('unhandled record', record);
596
+ * }),
597
+ * );
598
+ *
599
+ * export default async function handler(
600
+ * req: NextApiRequest,
601
+ * res: NextApiResponse,
602
+ * ) {
603
+ * try {
604
+ * await db.webhooks.processNodeRequest(handlers, req);
605
+ * res.status(200).end();
606
+ * } catch (e) {
607
+ * res.status(400).json({ error: String(e) });
608
+ * }
609
+ * }
610
+ *
611
+ * @example
612
+ * // Express — skip the JSON body parser on the webhook route and use
613
+ * // `express.raw()` so `req.body` arrives as a Buffer.
614
+ * import express from 'express';
615
+ *
616
+ * const app = express();
617
+ *
618
+ * app.use((req, res, next) => {
619
+ * if (req.originalUrl === '/webhooks/instant') return next();
620
+ * express.json()(req, res, next);
621
+ * });
622
+ *
623
+ * app.post(
624
+ * '/webhooks/instant',
625
+ * express.raw({ type: 'application/json' }),
626
+ * async (req, res) => {
627
+ * try {
628
+ * await db.webhooks.processNodeRequest(handlers, req);
629
+ * res.status(200).end();
630
+ * } catch (e) {
631
+ * res.status(400).json({ error: String(e) });
632
+ * }
633
+ * },
634
+ * );
635
+ *
636
+ * @example
637
+ * // Koa — `ctx.req` is the raw IncomingMessage. If `koa-bodyparser` (or
638
+ * // similar) runs on this route it consumes the stream, so either skip it
639
+ * // here or pull the raw body yourself and shim it onto the request:
640
+ * import Koa from 'koa';
641
+ * import Router from '@koa/router';
642
+ * import rawBody from 'raw-body';
643
+ *
644
+ * const router = new Router();
645
+ *
646
+ * router.post('/webhooks/instant', async (ctx) => {
647
+ * try {
648
+ * await db.webhooks.processNodeRequest(handlers, ctx.req, {
649
+ * body: rawBody(ctx.req), // adapter awaits the Promise
650
+ * });
651
+ * ctx.status = 200;
652
+ * } catch (e) {
653
+ * ctx.status = 400;
654
+ * ctx.body = { error: String(e) };
655
+ * }
656
+ * });
657
+ *
658
+ * @example
659
+ * // NestJS (Express adapter). With `rawBody: true` on the factory, Nest
660
+ * // populates `req.rawBody` itself — just pass `req`.
661
+ * import { Controller, Post, Req, HttpCode } from '@nestjs/common';
662
+ * import type { Request } from 'express';
663
+ *
664
+ * @Controller('webhooks')
665
+ * export class WebhooksController {
666
+ * @Post('instant')
667
+ * @HttpCode(200)
668
+ * async handle(@Req() req: Request) {
669
+ * await db.webhooks.processNodeRequest(handlers, req);
670
+ * }
671
+ * }
672
+ *
673
+ * // (Pair with `NestFactory.create(AppModule, { rawBody: true })` in main.ts.)
674
+ */
675
+ async processNodeRequest(handlers, req, opts) {
676
+ let rawBody;
677
+ let bodyWasReserialized = false;
678
+ // Priority: an explicit `opts.body`, then `req.rawBody` (set by
679
+ // middleware like Firebase Functions or body-parser's `verify` hook),
680
+ // then `req.body`, then the unconsumed request stream.
681
+ let bodyValue = opts?.body ?? req.rawBody ?? req.body;
682
+ // Allow callers to pass a Promise (e.g. the result of `raw-body(ctx.req)`
683
+ // in Koa) without having to await first.
684
+ if (bodyValue != null &&
685
+ typeof bodyValue.then === 'function') {
686
+ bodyValue = await bodyValue;
687
+ }
688
+ if (typeof bodyValue === 'string') {
689
+ rawBody = bodyValue;
690
+ }
691
+ else if (bodyValue instanceof Uint8Array) {
692
+ rawBody = new TextDecoder('utf-8').decode(bodyValue);
693
+ }
694
+ else if (bodyValue != null && typeof bodyValue === 'object') {
695
+ // A JSON parser middleware (e.g. `express.json()`) has consumed the
696
+ // stream and handed us a parsed object. Re-serialize and try anyway —
697
+ // for Instant's webhook body shape this typically round-trips to the
698
+ // bytes the server signed. If it doesn't, we surface a targeted error
699
+ // below.
700
+ try {
701
+ rawBody = JSON.stringify(bodyValue);
702
+ bodyWasReserialized = true;
703
+ }
704
+ catch {
705
+ throw new core_1.InstantError('Webhook request body has already been parsed and could not be re-serialized. Configure this route to receive the raw request body instead of parsed JSON.');
706
+ }
707
+ }
708
+ else if (req[Symbol.asyncIterator]) {
709
+ const encoder = new TextEncoder();
710
+ const chunks = [];
711
+ for await (const chunk of req) {
712
+ chunks.push(typeof chunk === 'string' ? encoder.encode(chunk) : chunk);
713
+ }
714
+ let total = 0;
715
+ for (const c of chunks)
716
+ total += c.byteLength;
717
+ const buf = new Uint8Array(total);
718
+ let offset = 0;
719
+ for (const c of chunks) {
720
+ buf.set(c, offset);
721
+ offset += c.byteLength;
722
+ }
723
+ rawBody = new TextDecoder('utf-8').decode(buf);
724
+ }
725
+ else {
726
+ throw new core_1.InstantError('Could not read the webhook request body. Pass a Node IncomingMessage with an unconsumed stream, or set `req.body` to the raw bytes (Buffer/Uint8Array) or string.');
727
+ }
728
+ const host = typeof req.headers.host === 'string' ? req.headers.host : 'localhost';
729
+ const url = new URL(req.url ?? '/', `https://${host}`);
730
+ const headers = new Headers();
731
+ for (const [k, v] of Object.entries(req.headers)) {
732
+ if (typeof v === 'string')
733
+ headers.set(k, v);
734
+ else if (Array.isArray(v))
735
+ headers.set(k, v.join(', '));
736
+ }
737
+ const webReq = new Request(url, {
738
+ method: req.method ?? 'POST',
739
+ headers,
740
+ body: rawBody,
741
+ });
742
+ try {
743
+ await this.processRequest(handlers, webReq, opts);
744
+ }
745
+ catch (e) {
746
+ if (bodyWasReserialized &&
747
+ e instanceof core_1.InstantError &&
748
+ e.message === 'Instant Signature did not validate') {
749
+ throw new core_1.InstantError('Webhook signature did not validate. The request body was re-serialized from a parsed JSON object, which can produce different bytes than the server signed. Configure this route to receive the raw request body instead of parsed JSON.', { hint: e.hint });
750
+ }
751
+ throw e;
752
+ }
753
+ }
754
+ }
755
+ exports.Webhooks = Webhooks;
756
+ //# sourceMappingURL=index.js.map