@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.
- package/LICENSE.md +202 -0
- package/NOTICE +4 -0
- package/README.md +387 -0
- package/dist/commonjs/__types__/typeUtils.d.ts +12 -0
- package/dist/commonjs/__types__/typeUtils.d.ts.map +1 -0
- package/dist/commonjs/__types__/typeUtils.js +5 -0
- package/dist/commonjs/__types__/typeUtils.js.map +1 -0
- package/dist/commonjs/__types__/typesTests.d.ts +2 -0
- package/dist/commonjs/__types__/typesTests.d.ts.map +1 -0
- package/dist/commonjs/__types__/typesTests.js +79 -0
- package/dist/commonjs/__types__/typesTests.js.map +1 -0
- package/dist/commonjs/index.d.ts +588 -0
- package/dist/commonjs/index.d.ts.map +1 -0
- package/dist/commonjs/index.js +756 -0
- package/dist/commonjs/index.js.map +1 -0
- package/dist/commonjs/package.json +3 -0
- package/dist/esm/__types__/typeUtils.d.ts +12 -0
- package/dist/esm/__types__/typeUtils.d.ts.map +1 -0
- package/dist/esm/__types__/typeUtils.js +4 -0
- package/dist/esm/__types__/typeUtils.js.map +1 -0
- package/dist/esm/__types__/typesTests.d.ts +2 -0
- package/dist/esm/__types__/typesTests.d.ts.map +1 -0
- package/dist/esm/__types__/typesTests.js +77 -0
- package/dist/esm/__types__/typesTests.js.map +1 -0
- package/dist/esm/index.d.ts +588 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +751 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +3 -0
- package/package.json +70 -0
|
@@ -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
|