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