webhookadmin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/index.cjs +586 -0
- package/dist/index.d.cts +430 -0
- package/dist/index.d.ts +430 -0
- package/dist/index.js +569 -0
- package/package.json +63 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var WebhookAdminError = class extends Error {
|
|
5
|
+
name = "WebhookAdminError";
|
|
6
|
+
/** HTTP status. Undefined when no response was received. */
|
|
7
|
+
status;
|
|
8
|
+
/** API error code, such as `invalid` or `not_found`. */
|
|
9
|
+
code;
|
|
10
|
+
/** Per-field messages for validation errors. */
|
|
11
|
+
fields;
|
|
12
|
+
/** Request ID (`x-request-id`, or Cloudflare's `cf-ray`) to quote when contacting support. */
|
|
13
|
+
requestId;
|
|
14
|
+
constructor(message, options = {}) {
|
|
15
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
16
|
+
this.status = options.status;
|
|
17
|
+
this.code = options.code;
|
|
18
|
+
this.fields = options.fields;
|
|
19
|
+
this.requestId = options.requestId;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
var AuthenticationError = class extends WebhookAdminError {
|
|
23
|
+
name = "AuthenticationError";
|
|
24
|
+
};
|
|
25
|
+
var PermissionError = class extends WebhookAdminError {
|
|
26
|
+
name = "PermissionError";
|
|
27
|
+
};
|
|
28
|
+
var NotFoundError = class extends WebhookAdminError {
|
|
29
|
+
name = "NotFoundError";
|
|
30
|
+
};
|
|
31
|
+
var ValidationError = class extends WebhookAdminError {
|
|
32
|
+
name = "ValidationError";
|
|
33
|
+
};
|
|
34
|
+
var PlanLimitError = class extends WebhookAdminError {
|
|
35
|
+
name = "PlanLimitError";
|
|
36
|
+
};
|
|
37
|
+
var ConflictError = class extends WebhookAdminError {
|
|
38
|
+
name = "ConflictError";
|
|
39
|
+
};
|
|
40
|
+
var RateLimitError = class extends WebhookAdminError {
|
|
41
|
+
name = "RateLimitError";
|
|
42
|
+
/** Seconds to wait before retrying, from `retry-after`. */
|
|
43
|
+
retryAfter;
|
|
44
|
+
constructor(message, options = {}) {
|
|
45
|
+
super(message, options);
|
|
46
|
+
this.retryAfter = options.retryAfter;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var ApiError = class extends WebhookAdminError {
|
|
50
|
+
name = "ApiError";
|
|
51
|
+
};
|
|
52
|
+
var ConnectionError = class extends WebhookAdminError {
|
|
53
|
+
name = "ConnectionError";
|
|
54
|
+
};
|
|
55
|
+
var TimeoutError = class extends ConnectionError {
|
|
56
|
+
name = "TimeoutError";
|
|
57
|
+
};
|
|
58
|
+
var WebhookVerificationError = class extends WebhookAdminError {
|
|
59
|
+
name = "WebhookVerificationError";
|
|
60
|
+
};
|
|
61
|
+
function parseRetryAfter(value, now = Date.now()) {
|
|
62
|
+
if (value === null || value.trim() === "") return void 0;
|
|
63
|
+
const n = Number(value);
|
|
64
|
+
if (Number.isFinite(n)) return Math.max(0, n);
|
|
65
|
+
const at = Date.parse(value);
|
|
66
|
+
return Number.isNaN(at) ? void 0 : Math.max(0, (at - now) / 1e3);
|
|
67
|
+
}
|
|
68
|
+
function requestIdOf(headers) {
|
|
69
|
+
return headers.get("x-request-id") ?? headers.get("cf-ray") ?? void 0;
|
|
70
|
+
}
|
|
71
|
+
function errorFromResponse(status, text, headers) {
|
|
72
|
+
let body = {};
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(text);
|
|
75
|
+
if (parsed && typeof parsed === "object") body = parsed;
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
const options = {
|
|
79
|
+
status,
|
|
80
|
+
code: typeof body.error === "string" ? body.error : void 0,
|
|
81
|
+
fields: body.fields && typeof body.fields === "object" ? body.fields : void 0,
|
|
82
|
+
requestId: requestIdOf(headers)
|
|
83
|
+
};
|
|
84
|
+
const message = typeof body.message === "string" ? body.message : `HTTP ${status}`;
|
|
85
|
+
switch (status) {
|
|
86
|
+
case 401:
|
|
87
|
+
return new AuthenticationError(message, options);
|
|
88
|
+
case 403:
|
|
89
|
+
return new PermissionError(message, options);
|
|
90
|
+
case 404:
|
|
91
|
+
return new NotFoundError(message, options);
|
|
92
|
+
case 400:
|
|
93
|
+
case 413:
|
|
94
|
+
case 422:
|
|
95
|
+
return new ValidationError(message, options);
|
|
96
|
+
case 402:
|
|
97
|
+
return new PlanLimitError(message, options);
|
|
98
|
+
case 409:
|
|
99
|
+
return new ConflictError(message, options);
|
|
100
|
+
case 429:
|
|
101
|
+
return new RateLimitError(message, { ...options, retryAfter: parseRetryAfter(headers.get("retry-after")) });
|
|
102
|
+
default:
|
|
103
|
+
return new ApiError(message, options);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/version.ts
|
|
108
|
+
var VERSION = "0.1.0";
|
|
109
|
+
var USER_AGENT = `webhookadmin-node/${VERSION}`;
|
|
110
|
+
|
|
111
|
+
// src/http.ts
|
|
112
|
+
var MAX_RATE_LIMIT_WAIT_MS = 6e4;
|
|
113
|
+
var BACKOFF_BASE_MS = 500;
|
|
114
|
+
var BACKOFF_MAX_MS = 8e3;
|
|
115
|
+
function backoff(attempt, random = Math.random) {
|
|
116
|
+
const base = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** attempt);
|
|
117
|
+
return Math.round(base * (0.75 + random() * 0.5));
|
|
118
|
+
}
|
|
119
|
+
function rateLimitWait(headers, now = Date.now()) {
|
|
120
|
+
const after = parseRetryAfter(headers.get("retry-after"), now);
|
|
121
|
+
if (after !== void 0) return Math.ceil(after * 1e3);
|
|
122
|
+
const reset = Number(headers.get("x-ratelimit-reset"));
|
|
123
|
+
if (headers.get("x-ratelimit-reset") && Number.isFinite(reset)) return Math.max(0, reset * 1e3 - now);
|
|
124
|
+
return void 0;
|
|
125
|
+
}
|
|
126
|
+
function sleep(ms, signal) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const onAbort = () => {
|
|
129
|
+
clearTimeout(t);
|
|
130
|
+
reject(new ConnectionError("Request was aborted", { cause: signal?.reason }));
|
|
131
|
+
};
|
|
132
|
+
const t = setTimeout(() => {
|
|
133
|
+
signal?.removeEventListener("abort", onAbort);
|
|
134
|
+
resolve();
|
|
135
|
+
}, ms);
|
|
136
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
var Http = class {
|
|
140
|
+
constructor(config) {
|
|
141
|
+
this.config = config;
|
|
142
|
+
}
|
|
143
|
+
config;
|
|
144
|
+
url(path2, query = {}) {
|
|
145
|
+
const qs = new URLSearchParams();
|
|
146
|
+
for (const [k, v] of Object.entries(query)) if (v !== void 0 && v !== "") qs.set(k, String(v));
|
|
147
|
+
const s = qs.toString();
|
|
148
|
+
return `${this.config.baseUrl}${path2}${s ? `?${s}` : ""}`;
|
|
149
|
+
}
|
|
150
|
+
async request(req, options = {}) {
|
|
151
|
+
const maxRetries = options.maxRetries ?? this.config.maxRetries;
|
|
152
|
+
for (let attempt = 0; ; attempt++) {
|
|
153
|
+
const r = await this.once(req, options);
|
|
154
|
+
if (r.ok) return r.value;
|
|
155
|
+
if (!r.retryable || attempt >= maxRetries || options.signal?.aborted) throw r.error;
|
|
156
|
+
await sleep(r.wait ?? backoff(attempt), options.signal);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async once(req, options) {
|
|
160
|
+
const headers = {
|
|
161
|
+
authorization: `Bearer ${this.config.apiKey}`,
|
|
162
|
+
accept: "application/json",
|
|
163
|
+
"user-agent": USER_AGENT,
|
|
164
|
+
...req.headers
|
|
165
|
+
};
|
|
166
|
+
let body;
|
|
167
|
+
if (req.body !== void 0) {
|
|
168
|
+
body = JSON.stringify(req.body);
|
|
169
|
+
headers["content-type"] = "application/json";
|
|
170
|
+
}
|
|
171
|
+
if (options.signal?.aborted) {
|
|
172
|
+
return { ok: false, error: new ConnectionError("Request was aborted", { cause: options.signal.reason }), retryable: false };
|
|
173
|
+
}
|
|
174
|
+
const timeout = options.timeout ?? this.config.timeout;
|
|
175
|
+
const ctrl = new AbortController();
|
|
176
|
+
let timedOut = false;
|
|
177
|
+
const timer = setTimeout(() => {
|
|
178
|
+
timedOut = true;
|
|
179
|
+
ctrl.abort();
|
|
180
|
+
}, timeout);
|
|
181
|
+
const onAbort = () => ctrl.abort();
|
|
182
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
183
|
+
let res;
|
|
184
|
+
let text;
|
|
185
|
+
try {
|
|
186
|
+
res = await this.config.fetch(this.url(req.path, req.query), { method: req.method, headers, body, signal: ctrl.signal });
|
|
187
|
+
text = await res.text();
|
|
188
|
+
} catch (e) {
|
|
189
|
+
if (timedOut)
|
|
190
|
+
return { ok: false, error: new TimeoutError(`Request timed out after ${timeout} ms`, { cause: e }), retryable: req.idempotent };
|
|
191
|
+
if (options.signal?.aborted) return { ok: false, error: new ConnectionError("Request was aborted", { cause: e }), retryable: false };
|
|
192
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
193
|
+
return { ok: false, error: new ConnectionError(`Connection error: ${msg}`, { cause: e }), retryable: req.idempotent };
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
197
|
+
}
|
|
198
|
+
if (res.ok) {
|
|
199
|
+
if (text === "") return { ok: true, value: void 0 };
|
|
200
|
+
try {
|
|
201
|
+
return { ok: true, value: JSON.parse(text) };
|
|
202
|
+
} catch (e) {
|
|
203
|
+
const error2 = new ApiError(`Invalid JSON in response (HTTP ${res.status})`, {
|
|
204
|
+
status: res.status,
|
|
205
|
+
requestId: requestIdOf(res.headers),
|
|
206
|
+
cause: e
|
|
207
|
+
});
|
|
208
|
+
return { ok: false, error: error2, retryable: false };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const error = errorFromResponse(res.status, text, res.headers);
|
|
212
|
+
if (res.status === 429) {
|
|
213
|
+
const wait = rateLimitWait(res.headers);
|
|
214
|
+
if (wait !== void 0 && wait > MAX_RATE_LIMIT_WAIT_MS) return { ok: false, error, retryable: false };
|
|
215
|
+
return { ok: false, error, retryable: true, wait };
|
|
216
|
+
}
|
|
217
|
+
return { ok: false, error, retryable: res.status >= 500 && req.idempotent };
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// src/pagination.ts
|
|
222
|
+
var PagePromise = class {
|
|
223
|
+
/** @internal */
|
|
224
|
+
constructor(fetchPage, cursor) {
|
|
225
|
+
this.fetchPage = fetchPage;
|
|
226
|
+
this.cursor = cursor;
|
|
227
|
+
}
|
|
228
|
+
fetchPage;
|
|
229
|
+
cursor;
|
|
230
|
+
first;
|
|
231
|
+
firstPage() {
|
|
232
|
+
this.first ??= this.fetchPage(this.cursor);
|
|
233
|
+
return this.first;
|
|
234
|
+
}
|
|
235
|
+
// biome-ignore lint/suspicious/noThenProperty: PagePromise is intentionally awaitable
|
|
236
|
+
then(onfulfilled, onrejected) {
|
|
237
|
+
return this.firstPage().then(onfulfilled, onrejected);
|
|
238
|
+
}
|
|
239
|
+
// biome-ignore lint/suspicious/noExplicitAny: same signature as Promise
|
|
240
|
+
catch(onrejected) {
|
|
241
|
+
return this.firstPage().catch(onrejected);
|
|
242
|
+
}
|
|
243
|
+
finally(onfinally) {
|
|
244
|
+
return this.firstPage().finally(onfinally);
|
|
245
|
+
}
|
|
246
|
+
async *[Symbol.asyncIterator]() {
|
|
247
|
+
let page = await this.firstPage();
|
|
248
|
+
for (; ; ) {
|
|
249
|
+
yield* page.items;
|
|
250
|
+
if (!page.next_cursor) return;
|
|
251
|
+
page = await this.fetchPage(page.next_cursor);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// src/resources/consumers.ts
|
|
257
|
+
var Consumers = class {
|
|
258
|
+
/** @internal */
|
|
259
|
+
constructor(http) {
|
|
260
|
+
this.http = http;
|
|
261
|
+
}
|
|
262
|
+
http;
|
|
263
|
+
/** Creates a consumer. A duplicate `external_id` throws `ConflictError`. Requires `consumers:write`. */
|
|
264
|
+
create(params, options) {
|
|
265
|
+
return this.http.request({ method: "POST", path: "/v1/consumers", body: params, idempotent: false }, options);
|
|
266
|
+
}
|
|
267
|
+
/** Lists consumers. Requires `logs:read`. */
|
|
268
|
+
list(params = {}, options) {
|
|
269
|
+
const { cursor, ...query } = params;
|
|
270
|
+
return new PagePromise(
|
|
271
|
+
(c) => this.http.request(
|
|
272
|
+
{ method: "GET", path: "/v1/consumers", query: { ...query, cursor: c }, idempotent: true },
|
|
273
|
+
options
|
|
274
|
+
),
|
|
275
|
+
cursor
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// src/resources/deliveries.ts
|
|
281
|
+
var Deliveries = class {
|
|
282
|
+
/** @internal */
|
|
283
|
+
constructor(http) {
|
|
284
|
+
this.http = http;
|
|
285
|
+
}
|
|
286
|
+
http;
|
|
287
|
+
/**
|
|
288
|
+
* Sends a finished delivery again. A delivery still being sent throws `ConflictError`.
|
|
289
|
+
* Requires `messages:retry`.
|
|
290
|
+
*/
|
|
291
|
+
async retry(deliveryId, options) {
|
|
292
|
+
await this.http.request({ method: "POST", path: `/v1/deliveries/${encodeURIComponent(deliveryId)}/retry`, idempotent: false }, options);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// src/resources/endpoints.ts
|
|
297
|
+
var path = (endpointId, rest = "") => `/v1/endpoints/${encodeURIComponent(endpointId)}${rest}`;
|
|
298
|
+
var Endpoints = class {
|
|
299
|
+
/** @internal */
|
|
300
|
+
constructor(http) {
|
|
301
|
+
this.http = http;
|
|
302
|
+
}
|
|
303
|
+
http;
|
|
304
|
+
/** Creates an endpoint. The signing secret is returned only here. Requires `endpoints:write`. */
|
|
305
|
+
create(params, options) {
|
|
306
|
+
return this.http.request({ method: "POST", path: "/v1/endpoints", body: params, idempotent: false }, options);
|
|
307
|
+
}
|
|
308
|
+
/** Lists endpoints, optionally for one consumer. All endpoints come in one page. Requires `logs:read`. */
|
|
309
|
+
list(params = {}, options) {
|
|
310
|
+
return new PagePromise(async (cursor) => {
|
|
311
|
+
const r = await this.http.request(
|
|
312
|
+
{ method: "GET", path: "/v1/endpoints", query: { ...params, cursor }, idempotent: true },
|
|
313
|
+
options
|
|
314
|
+
);
|
|
315
|
+
return { items: r.items, next_cursor: r.next_cursor ?? null };
|
|
316
|
+
}, void 0);
|
|
317
|
+
}
|
|
318
|
+
/** Gets an endpoint with its latest attempts. Requires `logs:read`. */
|
|
319
|
+
get(endpointId, options) {
|
|
320
|
+
return this.http.request({ method: "GET", path: path(endpointId), idempotent: true }, options);
|
|
321
|
+
}
|
|
322
|
+
/** Updates an endpoint. Set `status: 'active'` to resume a paused or disabled endpoint. Requires `endpoints:write`. */
|
|
323
|
+
update(endpointId, params, options) {
|
|
324
|
+
return this.http.request({ method: "PATCH", path: path(endpointId), body: params, idempotent: true }, options);
|
|
325
|
+
}
|
|
326
|
+
/** Deletes an endpoint. Requires `endpoints:write`. */
|
|
327
|
+
async delete(endpointId, options) {
|
|
328
|
+
await this.http.request({ method: "DELETE", path: path(endpointId), idempotent: true }, options);
|
|
329
|
+
}
|
|
330
|
+
/** Issues a new signing secret. The previous one keeps signing for 24 hours. Requires `endpoints:write`. */
|
|
331
|
+
rotateSecret(endpointId, options) {
|
|
332
|
+
return this.http.request({ method: "POST", path: path(endpointId, "/rotate-secret"), idempotent: false }, options);
|
|
333
|
+
}
|
|
334
|
+
/** Sends a test message to this endpoint only. Not counted in usage. Requires `messages:send`. */
|
|
335
|
+
sendTest(endpointId, params = {}, options) {
|
|
336
|
+
return this.http.request({ method: "POST", path: path(endpointId, "/test"), body: params, idempotent: false }, options);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// src/runtime.ts
|
|
341
|
+
var NODE_CRYPTO = "node:crypto";
|
|
342
|
+
var nodeCrypto;
|
|
343
|
+
function webCrypto() {
|
|
344
|
+
const g = globalThis.crypto;
|
|
345
|
+
if (g?.subtle) return Promise.resolve(g);
|
|
346
|
+
nodeCrypto ??= import(
|
|
347
|
+
/* webpackIgnore: true */
|
|
348
|
+
/* @vite-ignore */
|
|
349
|
+
NODE_CRYPTO
|
|
350
|
+
).then((m) => m.webcrypto);
|
|
351
|
+
return nodeCrypto;
|
|
352
|
+
}
|
|
353
|
+
function readEnv(name) {
|
|
354
|
+
const g = globalThis;
|
|
355
|
+
try {
|
|
356
|
+
const v = g.process?.env?.[name] ?? g.Deno?.env?.get(name);
|
|
357
|
+
return v?.trim() || void 0;
|
|
358
|
+
} catch {
|
|
359
|
+
return void 0;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function uuid() {
|
|
363
|
+
const b = (await webCrypto()).getRandomValues(new Uint8Array(16));
|
|
364
|
+
b[6] = b[6] & 15 | 64;
|
|
365
|
+
b[8] = b[8] & 63 | 128;
|
|
366
|
+
const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
367
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// src/resources/messages.ts
|
|
371
|
+
var id = (v) => encodeURIComponent(v);
|
|
372
|
+
var Messages = class {
|
|
373
|
+
/** @internal */
|
|
374
|
+
constructor(http) {
|
|
375
|
+
this.http = http;
|
|
376
|
+
}
|
|
377
|
+
http;
|
|
378
|
+
/**
|
|
379
|
+
* Sends a message to every active endpoint of the consumer that accepts the event type.
|
|
380
|
+
* Requires the `messages:send` scope.
|
|
381
|
+
*/
|
|
382
|
+
async send(params, options = {}) {
|
|
383
|
+
const { idempotencyKey, ...rest } = options;
|
|
384
|
+
const key = idempotencyKey ?? `webhookadmin-node-${await uuid()}`;
|
|
385
|
+
return this.http.request(
|
|
386
|
+
{ method: "POST", path: "/v1/messages", body: params, headers: { "idempotency-key": key }, idempotent: true },
|
|
387
|
+
rest
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
/** Lists messages, newest first. Requires `logs:read`. */
|
|
391
|
+
list(params = {}, options) {
|
|
392
|
+
const { cursor, ...query } = params;
|
|
393
|
+
return new PagePromise(
|
|
394
|
+
(c) => this.http.request(
|
|
395
|
+
{ method: "GET", path: "/v1/messages", query: { ...query, cursor: c }, idempotent: true },
|
|
396
|
+
options
|
|
397
|
+
),
|
|
398
|
+
cursor
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
/** Gets a message with its deliveries and attempts. Requires `logs:read`. */
|
|
402
|
+
get(messageId, options) {
|
|
403
|
+
return this.http.request({ method: "GET", path: `/v1/messages/${id(messageId)}`, idempotent: true }, options);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// src/resources/portal.ts
|
|
408
|
+
var Portal = class {
|
|
409
|
+
/** @internal */
|
|
410
|
+
constructor(http) {
|
|
411
|
+
this.http = http;
|
|
412
|
+
}
|
|
413
|
+
http;
|
|
414
|
+
/**
|
|
415
|
+
* Creates a 15-minute link to the consumer portal, where your customer manages their own endpoints.
|
|
416
|
+
* Starter plan or above. Requires `endpoints:write`.
|
|
417
|
+
*/
|
|
418
|
+
createLink(consumerId, params = {}, options) {
|
|
419
|
+
return this.http.request(
|
|
420
|
+
{ method: "POST", path: `/v1/consumers/${encodeURIComponent(consumerId)}/portal`, body: params, idempotent: true },
|
|
421
|
+
options
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// src/client.ts
|
|
427
|
+
var DEFAULT_BASE_URL = "https://api.webhookadmin.com";
|
|
428
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
429
|
+
var DEFAULT_MAX_RETRIES = 2;
|
|
430
|
+
var API_KEY_ENV = "WEBHOOK_ADMIN_API_KEY";
|
|
431
|
+
var WebhookAdmin = class {
|
|
432
|
+
messages;
|
|
433
|
+
consumers;
|
|
434
|
+
endpoints;
|
|
435
|
+
deliveries;
|
|
436
|
+
portal;
|
|
437
|
+
baseUrl;
|
|
438
|
+
/**
|
|
439
|
+
* @param apiKey `sk_live_…` or `sk_test_…`. Defaults to the `WEBHOOK_ADMIN_API_KEY` environment variable.
|
|
440
|
+
*/
|
|
441
|
+
constructor(apiKey, options = {}) {
|
|
442
|
+
const key = apiKey ?? readEnv(API_KEY_ENV);
|
|
443
|
+
if (!key) {
|
|
444
|
+
throw new WebhookAdminError(`Missing API key. Pass it to new WebhookAdmin('sk_\u2026') or set ${API_KEY_ENV}.`);
|
|
445
|
+
}
|
|
446
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
447
|
+
if (!f) throw new WebhookAdminError("fetch is not available. Use Node.js 18 or later, or pass options.fetch.");
|
|
448
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
449
|
+
const http = new Http({
|
|
450
|
+
apiKey: key,
|
|
451
|
+
baseUrl: this.baseUrl,
|
|
452
|
+
timeout: options.timeout ?? DEFAULT_TIMEOUT_MS,
|
|
453
|
+
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
454
|
+
// 素の fetch を this なしで呼ぶと Workers などで Illegal invocation になる
|
|
455
|
+
fetch: options.fetch ?? ((input, init) => f(input, init))
|
|
456
|
+
});
|
|
457
|
+
this.messages = new Messages(http);
|
|
458
|
+
this.consumers = new Consumers(http);
|
|
459
|
+
this.endpoints = new Endpoints(http);
|
|
460
|
+
this.deliveries = new Deliveries(http);
|
|
461
|
+
this.portal = new Portal(http);
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// src/webhook.ts
|
|
466
|
+
var PREFIX = "whsec_";
|
|
467
|
+
var enc = new TextEncoder();
|
|
468
|
+
var dec = new TextDecoder();
|
|
469
|
+
function base64Decode(s) {
|
|
470
|
+
const bin = atob(s);
|
|
471
|
+
const out = new Uint8Array(new ArrayBuffer(bin.length));
|
|
472
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
function base64Encode(bytes) {
|
|
476
|
+
let s = "";
|
|
477
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
478
|
+
return btoa(s);
|
|
479
|
+
}
|
|
480
|
+
function header(headers, name) {
|
|
481
|
+
if (typeof headers.get === "function") return headers.get(name) ?? void 0;
|
|
482
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
483
|
+
if (k.toLowerCase() === name) return Array.isArray(v) ? v[0] : v;
|
|
484
|
+
}
|
|
485
|
+
return void 0;
|
|
486
|
+
}
|
|
487
|
+
function bytesOf(payload) {
|
|
488
|
+
if (typeof payload === "string") return enc.encode(payload);
|
|
489
|
+
if (payload instanceof Uint8Array) return payload;
|
|
490
|
+
if (payload instanceof ArrayBuffer) return new Uint8Array(payload);
|
|
491
|
+
throw new WebhookVerificationError(
|
|
492
|
+
"Payload must be the raw request body (string, Buffer, Uint8Array or ArrayBuffer), not a parsed object"
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
function concat(a, b) {
|
|
496
|
+
const out = new Uint8Array(new ArrayBuffer(a.length + b.length));
|
|
497
|
+
out.set(a);
|
|
498
|
+
out.set(b, a.length);
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
var Webhook = class {
|
|
502
|
+
key;
|
|
503
|
+
tolerance;
|
|
504
|
+
cryptoKey;
|
|
505
|
+
/** @param secret The endpoint's signing secret (`whsec_…`). */
|
|
506
|
+
constructor(secret, options = {}) {
|
|
507
|
+
if (typeof secret !== "string" || secret === "") throw new WebhookAdminError("Webhook secret is required");
|
|
508
|
+
try {
|
|
509
|
+
this.key = base64Decode(secret.startsWith(PREFIX) ? secret.slice(PREFIX.length) : secret);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
throw new WebhookAdminError("Webhook secret is not valid base64 (expected whsec_\u2026)", { cause: e });
|
|
512
|
+
}
|
|
513
|
+
this.tolerance = options.tolerance ?? 300;
|
|
514
|
+
}
|
|
515
|
+
importKey() {
|
|
516
|
+
this.cryptoKey ??= webCrypto().then(
|
|
517
|
+
(c) => c.subtle.importKey("raw", this.key, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"])
|
|
518
|
+
);
|
|
519
|
+
return this.cryptoKey;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Verifies the signature and timestamp, then returns the parsed body.
|
|
523
|
+
* Throws `WebhookVerificationError` when verification fails.
|
|
524
|
+
* @param payload The raw request body, exactly as received.
|
|
525
|
+
*/
|
|
526
|
+
async verify(payload, headers) {
|
|
527
|
+
const id2 = header(headers, "webhook-id");
|
|
528
|
+
const timestamp = header(headers, "webhook-timestamp");
|
|
529
|
+
const signature = header(headers, "webhook-signature");
|
|
530
|
+
if (!id2 || !timestamp || !signature) throw new WebhookVerificationError("Missing required headers");
|
|
531
|
+
const ts = Number(timestamp);
|
|
532
|
+
if (!/^\d+$/.test(timestamp) || !Number.isSafeInteger(ts)) throw new WebhookVerificationError("Invalid webhook-timestamp header");
|
|
533
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
534
|
+
if (now - ts > this.tolerance) throw new WebhookVerificationError("Message timestamp too old");
|
|
535
|
+
if (ts - now > this.tolerance) throw new WebhookVerificationError("Message timestamp too new");
|
|
536
|
+
const body = bytesOf(payload);
|
|
537
|
+
const signed = concat(enc.encode(`${id2}.${timestamp}.`), body);
|
|
538
|
+
const key = await this.importKey();
|
|
539
|
+
const subtle = (await webCrypto()).subtle;
|
|
540
|
+
for (const part of signature.split(" ")) {
|
|
541
|
+
const [version, sig] = part.split(",");
|
|
542
|
+
if (version !== "v1" || !sig) continue;
|
|
543
|
+
let expected;
|
|
544
|
+
try {
|
|
545
|
+
expected = base64Decode(sig);
|
|
546
|
+
} catch {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (await subtle.verify("HMAC", key, expected, signed)) {
|
|
550
|
+
try {
|
|
551
|
+
return JSON.parse(dec.decode(body));
|
|
552
|
+
} catch (e) {
|
|
553
|
+
throw new WebhookVerificationError("Payload is not valid JSON", { cause: e });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
throw new WebhookVerificationError("No matching signature found");
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Computes the `webhook-signature` value (`v1,<base64>`). Useful for tests.
|
|
561
|
+
* @param timestamp A `Date` or Unix seconds.
|
|
562
|
+
*/
|
|
563
|
+
async sign(msgId, timestamp, payload) {
|
|
564
|
+
const ts = timestamp instanceof Date ? Math.floor(timestamp.getTime() / 1e3) : timestamp;
|
|
565
|
+
const signed = concat(enc.encode(`${msgId}.${ts}.`), bytesOf(payload));
|
|
566
|
+
const sig = await (await webCrypto()).subtle.sign("HMAC", await this.importKey(), signed);
|
|
567
|
+
return `v1,${base64Encode(new Uint8Array(sig))}`;
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
exports.ApiError = ApiError;
|
|
572
|
+
exports.AuthenticationError = AuthenticationError;
|
|
573
|
+
exports.ConflictError = ConflictError;
|
|
574
|
+
exports.ConnectionError = ConnectionError;
|
|
575
|
+
exports.NotFoundError = NotFoundError;
|
|
576
|
+
exports.PagePromise = PagePromise;
|
|
577
|
+
exports.PermissionError = PermissionError;
|
|
578
|
+
exports.PlanLimitError = PlanLimitError;
|
|
579
|
+
exports.RateLimitError = RateLimitError;
|
|
580
|
+
exports.TimeoutError = TimeoutError;
|
|
581
|
+
exports.VERSION = VERSION;
|
|
582
|
+
exports.ValidationError = ValidationError;
|
|
583
|
+
exports.Webhook = Webhook;
|
|
584
|
+
exports.WebhookAdmin = WebhookAdmin;
|
|
585
|
+
exports.WebhookAdminError = WebhookAdminError;
|
|
586
|
+
exports.WebhookVerificationError = WebhookVerificationError;
|