@billkit-eu/sdk 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/CHANGELOG.md +71 -0
- package/LICENSE +201 -0
- package/README.md +299 -0
- package/dist/index.cjs +996 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +911 -0
- package/dist/index.d.ts +911 -0
- package/dist/index.js +977 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
- package/src/client.ts +88 -0
- package/src/errors.ts +168 -0
- package/src/index.ts +103 -0
- package/src/logging.ts +72 -0
- package/src/pagination.ts +77 -0
- package/src/resources.ts +993 -0
- package/src/retry.ts +61 -0
- package/src/transport.ts +300 -0
- package/src/version.ts +1 -0
- package/src/webhooks.ts +171 -0
package/src/retry.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry policy for transient failures.
|
|
3
|
+
*
|
|
4
|
+
* Retries 5xx + network errors with jittered exponential backoff.
|
|
5
|
+
* 4xx (including 409 Idempotency-Key conflicts) are caller-fault and
|
|
6
|
+
* never retried. The SDK auto-generates an `Idempotency-Key` for every
|
|
7
|
+
* mutating call so retrying a 5xx never double-charges.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface RetryPolicy {
|
|
11
|
+
readonly maxAttempts: number;
|
|
12
|
+
readonly initialBackoffMs: number;
|
|
13
|
+
readonly backoffMultiplier: number;
|
|
14
|
+
readonly maxBackoffMs: number;
|
|
15
|
+
readonly maxRetryAfterMs?: number;
|
|
16
|
+
readonly jitter: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_RETRY_POLICY: RetryPolicy = {
|
|
20
|
+
maxAttempts: 4,
|
|
21
|
+
initialBackoffMs: 500,
|
|
22
|
+
backoffMultiplier: 2.0,
|
|
23
|
+
maxBackoffMs: 8000,
|
|
24
|
+
maxRetryAfterMs: 30_000,
|
|
25
|
+
jitter: 0.25,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Backoff before attempt `attempt` (1-indexed: attempt 2 is the first
|
|
30
|
+
* retry). Caller never asks for attempt=1.
|
|
31
|
+
*/
|
|
32
|
+
export function backoffForMs(attempt: number, policy: RetryPolicy): number {
|
|
33
|
+
const base = policy.initialBackoffMs * policy.backoffMultiplier ** (attempt - 2);
|
|
34
|
+
const capped = Math.min(base, policy.maxBackoffMs);
|
|
35
|
+
const jitterRange = capped * policy.jitter;
|
|
36
|
+
const jittered = capped + (Math.random() * 2 - 1) * jitterRange;
|
|
37
|
+
return Math.max(0, jittered);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function shouldRetry(
|
|
41
|
+
status: number | null,
|
|
42
|
+
attempt: number,
|
|
43
|
+
policy: RetryPolicy,
|
|
44
|
+
retryAfterMs?: number,
|
|
45
|
+
): boolean {
|
|
46
|
+
if (attempt >= policy.maxAttempts) return false;
|
|
47
|
+
if (status === null) return true; // network error
|
|
48
|
+
if (status === 429) {
|
|
49
|
+
// 429 is retried only when the server supplies a short, parseable
|
|
50
|
+
// Retry-After value; otherwise we surface the exception so the
|
|
51
|
+
// caller can decide. ``maxRetryAfterMs`` may be left ``undefined``
|
|
52
|
+
// to allow any Retry-After value within budget.
|
|
53
|
+
if (retryAfterMs === undefined || retryAfterMs < 0) return false;
|
|
54
|
+
return policy.maxRetryAfterMs === undefined || retryAfterMs <= policy.maxRetryAfterMs;
|
|
55
|
+
}
|
|
56
|
+
return status >= 500;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function sleep(ms: number): Promise<void> {
|
|
60
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch-backed transport with retry + error mapping.
|
|
3
|
+
*
|
|
4
|
+
* Uses the runtime's native `fetch` (Node 20+, Bun, Deno, Cloudflare
|
|
5
|
+
* Workers, browsers). The transport is the only place that touches HTTP;
|
|
6
|
+
* everything else in the SDK speaks to a `Transport` interface so a
|
|
7
|
+
* caller can inject a mock or replay layer for testing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { APIConnectionError, errorFromResponse, type BillKitError } from "./errors.js";
|
|
11
|
+
import { NOOP_LOGGER, type BillKitLogger } from "./logging.js";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_RETRY_POLICY,
|
|
14
|
+
type RetryPolicy,
|
|
15
|
+
backoffForMs,
|
|
16
|
+
shouldRetry,
|
|
17
|
+
sleep,
|
|
18
|
+
} from "./retry.js";
|
|
19
|
+
import { VERSION } from "./version.js";
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_BASE_URL = "https://api.billkit.eu";
|
|
22
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
export type QueryValue = string | number | boolean | null | undefined;
|
|
25
|
+
|
|
26
|
+
export interface RequestOptions {
|
|
27
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
28
|
+
path: string;
|
|
29
|
+
// Loosened from ``Record<string, QueryValue>`` so resource methods
|
|
30
|
+
// can pass a structurally-typed ``ListParams``-style object without
|
|
31
|
+
// a cast, since TS demands an index signature otherwise.
|
|
32
|
+
query?: { readonly [key: string]: QueryValue };
|
|
33
|
+
body?: Record<string, unknown> | undefined;
|
|
34
|
+
idempotencyKey?: string | undefined;
|
|
35
|
+
extraHeaders?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TransportConfig {
|
|
39
|
+
apiKey: string;
|
|
40
|
+
baseUrl?: string;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
retryPolicy?: RetryPolicy;
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
/**
|
|
45
|
+
* Where to send the SDK's request/retry lifecycle. Omitted (the
|
|
46
|
+
* default) means a no-op: the SDK stays silent and never picks a
|
|
47
|
+
* destination for you. `console` works as-is; see
|
|
48
|
+
* {@link BillKitLogger}. Secrets, bodies and query strings are never
|
|
49
|
+
* passed to it.
|
|
50
|
+
*/
|
|
51
|
+
logger?: BillKitLogger;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function userAgent(): string {
|
|
55
|
+
return `billkit-node/${VERSION}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function autoIdempotencyKey(method: string, supplied?: string): string | undefined {
|
|
59
|
+
if (method === "GET") return undefined;
|
|
60
|
+
if (supplied !== undefined) return supplied;
|
|
61
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
62
|
+
if (uuid === undefined) {
|
|
63
|
+
// Deliberately fail loudly rather than fall back to
|
|
64
|
+
// `Date.now()-Math.random()`. This key is what makes a retried
|
|
65
|
+
// mutating call safe: two processes that generate the *same* key send
|
|
66
|
+
// different requests the server treats as replays of each other, so it
|
|
67
|
+
// returns the first call's response for the second, silently wrong on
|
|
68
|
+
// a charge. `Math.random()` is not collision-resistant and is seeded
|
|
69
|
+
// per-process, so a fleet starting together is exactly the case where
|
|
70
|
+
// it collides. Every runtime this SDK supports (Node 20+, Bun, Deno,
|
|
71
|
+
// Workers, modern browsers) has `crypto.randomUUID`.
|
|
72
|
+
throw new Error(
|
|
73
|
+
"BillKit: crypto.randomUUID() is unavailable, so a safe Idempotency-Key " +
|
|
74
|
+
"cannot be generated. Use Node 20+, Bun, Deno, or Cloudflare Workers, " +
|
|
75
|
+
"or pass your own `idempotencyKey` on this call.",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return `sdk-${uuid}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The URL with the **query string stripped**, for logging only.
|
|
83
|
+
*
|
|
84
|
+
* Never log the value {@link buildUrl} returns: list filters routinely
|
|
85
|
+
* carry `?email=ada@example.com`, and copying customer PII into the
|
|
86
|
+
* caller's log sink is exactly what this SDK must not do. Keeping the
|
|
87
|
+
* two builders separate makes that a visible choice rather than an
|
|
88
|
+
* accident waiting for someone to "simplify" it.
|
|
89
|
+
*/
|
|
90
|
+
function logSafeUrl(baseUrl: string, path: string): string {
|
|
91
|
+
const normalised = path.startsWith("/") ? path : `/${path}`;
|
|
92
|
+
return baseUrl.replace(/\/$/, "") + normalised;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildUrl(baseUrl: string, path: string, query: RequestOptions["query"]): string {
|
|
96
|
+
const normalised = path.startsWith("/") ? path : `/${path}`;
|
|
97
|
+
const url = new URL(baseUrl.replace(/\/$/, "") + normalised);
|
|
98
|
+
if (query) {
|
|
99
|
+
for (const [k, v] of Object.entries(query)) {
|
|
100
|
+
if (v !== null && v !== undefined) {
|
|
101
|
+
url.searchParams.set(k, String(v));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return url.toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildHeaders(
|
|
109
|
+
apiKey: string,
|
|
110
|
+
hasBody: boolean,
|
|
111
|
+
idempotencyKey: string | undefined,
|
|
112
|
+
extra: Record<string, string> | undefined,
|
|
113
|
+
): Headers {
|
|
114
|
+
const headers = new Headers({
|
|
115
|
+
Authorization: `Bearer ${apiKey}`,
|
|
116
|
+
"User-Agent": userAgent(),
|
|
117
|
+
Accept: "application/json",
|
|
118
|
+
});
|
|
119
|
+
if (hasBody) headers.set("Content-Type", "application/json");
|
|
120
|
+
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
|
|
121
|
+
if (extra) {
|
|
122
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
123
|
+
headers.set(k, v);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return headers;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function parseJson(response: Response): Promise<unknown> {
|
|
130
|
+
const text = await response.text();
|
|
131
|
+
if (!text) return null;
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(text);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseRetryAfterMs(header: string | null): number | undefined {
|
|
140
|
+
if (!header) return undefined;
|
|
141
|
+
const n = Number.parseFloat(header);
|
|
142
|
+
if (Number.isFinite(n) && n >= 0) return n * 1000;
|
|
143
|
+
|
|
144
|
+
const retryAt = Date.parse(header);
|
|
145
|
+
if (Number.isNaN(retryAt)) return undefined;
|
|
146
|
+
return Math.max(0, retryAt - Date.now());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function retryDelayMs(
|
|
150
|
+
status: number | null,
|
|
151
|
+
attempt: number,
|
|
152
|
+
policy: RetryPolicy,
|
|
153
|
+
retryAfterMs?: number,
|
|
154
|
+
): number {
|
|
155
|
+
if (status === 429 && retryAfterMs !== undefined) return retryAfterMs;
|
|
156
|
+
return backoffForMs(attempt + 1, policy);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Map a thrown fetch/read error to an {@link APIConnectionError}.
|
|
161
|
+
*
|
|
162
|
+
* ``AbortSignal.timeout`` aborts with a ``TimeoutError`` (some runtimes
|
|
163
|
+
* surface ``AbortError``); we translate that into an explicit, greppable
|
|
164
|
+
* timeout message instead of the runtime's terse default.
|
|
165
|
+
*/
|
|
166
|
+
function connectionError(err: unknown, timeoutMs: number): APIConnectionError {
|
|
167
|
+
const e = err as { name?: string; message?: string } | undefined;
|
|
168
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
169
|
+
return new APIConnectionError(`BillKit request timed out after ${timeoutMs}ms.`);
|
|
170
|
+
}
|
|
171
|
+
return new APIConnectionError(e?.message ?? "Network request failed.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export class Transport {
|
|
175
|
+
private readonly apiKey: string;
|
|
176
|
+
private readonly baseUrl: string;
|
|
177
|
+
private readonly timeoutMs: number;
|
|
178
|
+
private readonly retryPolicy: RetryPolicy;
|
|
179
|
+
private readonly fetchFn: typeof fetch;
|
|
180
|
+
private readonly logger: BillKitLogger;
|
|
181
|
+
|
|
182
|
+
constructor(config: TransportConfig) {
|
|
183
|
+
if (!config.apiKey) {
|
|
184
|
+
throw new Error("BillKit: an API key is required (config.apiKey or BILLKIT_API_KEY env).");
|
|
185
|
+
}
|
|
186
|
+
this.apiKey = config.apiKey;
|
|
187
|
+
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
188
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
189
|
+
this.retryPolicy = config.retryPolicy ?? DEFAULT_RETRY_POLICY;
|
|
190
|
+
this.logger = config.logger ?? NOOP_LOGGER;
|
|
191
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
192
|
+
if (!fetchFn) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"BillKit: no global fetch implementation found. Use Node 20+, Bun, Deno, " +
|
|
195
|
+
"Cloudflare Workers, or pass { fetch } in the client options.",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
this.fetchFn = fetchFn.bind(globalThis);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async request<T = unknown>(options: RequestOptions): Promise<T> {
|
|
202
|
+
const idempotencyKey = autoIdempotencyKey(options.method, options.idempotencyKey);
|
|
203
|
+
const url = buildUrl(this.baseUrl, options.path, options.query);
|
|
204
|
+
const headers = buildHeaders(
|
|
205
|
+
this.apiKey,
|
|
206
|
+
options.body !== undefined,
|
|
207
|
+
idempotencyKey,
|
|
208
|
+
options.extraHeaders,
|
|
209
|
+
);
|
|
210
|
+
const body = options.body !== undefined ? JSON.stringify(options.body) : undefined;
|
|
211
|
+
// Query-free; see `logSafeUrl`. Never swap this for `url`.
|
|
212
|
+
const loggedUrl = logSafeUrl(this.baseUrl, options.path);
|
|
213
|
+
|
|
214
|
+
let lastError: BillKitError | null = null;
|
|
215
|
+
for (let attempt = 1; attempt <= this.retryPolicy.maxAttempts; attempt++) {
|
|
216
|
+
this.logger.debug("BillKit request", {
|
|
217
|
+
method: options.method,
|
|
218
|
+
url: loggedUrl,
|
|
219
|
+
attempt,
|
|
220
|
+
maxAttempts: this.retryPolicy.maxAttempts,
|
|
221
|
+
});
|
|
222
|
+
const startedAt = Date.now();
|
|
223
|
+
let response: Response;
|
|
224
|
+
let parsedBody: unknown;
|
|
225
|
+
try {
|
|
226
|
+
// ``body`` is only spread when present so a GET request goes
|
|
227
|
+
// out without a body field. Some hosts (Cloudflare Workers'
|
|
228
|
+
// outgoing fetch) refuse ``body: null`` on GET; omitting it
|
|
229
|
+
// is the portable shape.
|
|
230
|
+
//
|
|
231
|
+
// ``AbortSignal.timeout`` stays armed through the *body read*
|
|
232
|
+
// below, not just until the headers arrive, so a server that
|
|
233
|
+
// streams headers and then stalls the body is still bounded by
|
|
234
|
+
// ``timeoutMs`` instead of hanging forever. A fresh signal is
|
|
235
|
+
// created per attempt because a timed-out signal can't be reused.
|
|
236
|
+
const init: RequestInit = {
|
|
237
|
+
method: options.method,
|
|
238
|
+
headers,
|
|
239
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
240
|
+
};
|
|
241
|
+
if (body !== undefined) init.body = body;
|
|
242
|
+
response = await this.fetchFn(url, init);
|
|
243
|
+
parsedBody = await parseJson(response);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
lastError = connectionError(err, this.timeoutMs);
|
|
246
|
+
if (!shouldRetry(null, attempt, this.retryPolicy)) throw lastError;
|
|
247
|
+
const delayMs = retryDelayMs(null, attempt, this.retryPolicy);
|
|
248
|
+
this.logger.warn("BillKit retrying", {
|
|
249
|
+
method: options.method,
|
|
250
|
+
url: loggedUrl,
|
|
251
|
+
reason: (err as { name?: string } | undefined)?.name ?? "network error",
|
|
252
|
+
attempt,
|
|
253
|
+
delayMs,
|
|
254
|
+
});
|
|
255
|
+
await sleep(delayMs);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const requestId =
|
|
260
|
+
response.headers.get("x-request-id") ?? response.headers.get("request-id") ?? undefined;
|
|
261
|
+
this.logger.debug("BillKit response", {
|
|
262
|
+
method: options.method,
|
|
263
|
+
url: loggedUrl,
|
|
264
|
+
status: response.status,
|
|
265
|
+
durationMs: Date.now() - startedAt,
|
|
266
|
+
requestId: requestId ?? null,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (response.ok) {
|
|
270
|
+
return (parsedBody ?? undefined) as T;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
|
|
274
|
+
const error = errorFromResponse({
|
|
275
|
+
status: response.status,
|
|
276
|
+
body: parsedBody,
|
|
277
|
+
requestId,
|
|
278
|
+
retryAfter: retryAfterMs === undefined ? undefined : retryAfterMs / 1000,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (!shouldRetry(response.status, attempt, this.retryPolicy, retryAfterMs)) {
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
lastError = error;
|
|
285
|
+
const delayMs = retryDelayMs(response.status, attempt, this.retryPolicy, retryAfterMs);
|
|
286
|
+
this.logger.warn("BillKit retrying", {
|
|
287
|
+
method: options.method,
|
|
288
|
+
url: loggedUrl,
|
|
289
|
+
reason: `HTTP ${response.status}`,
|
|
290
|
+
attempt,
|
|
291
|
+
delayMs,
|
|
292
|
+
});
|
|
293
|
+
await sleep(delayMs);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Loop exhausted; surface the last seen error.
|
|
297
|
+
if (lastError) throw lastError;
|
|
298
|
+
throw new APIConnectionError("Retry budget exhausted with no recorded error.");
|
|
299
|
+
}
|
|
300
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.1.0";
|
package/src/webhooks.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify `BillKit-Signature: t=<unix>,v1=<hex>` headers.
|
|
3
|
+
*
|
|
4
|
+
* Works in Node 20+, Bun, Deno, Cloudflare Workers and the browser:
|
|
5
|
+
* we use the Web Crypto API (`globalThis.crypto.subtle`) which is
|
|
6
|
+
* available in all modern runtimes. The verifier:
|
|
7
|
+
*
|
|
8
|
+
* 1. Parses the header (rejects malformed shapes). A header may carry
|
|
9
|
+
* more than one `v1=` value, because the server emits both the old and new
|
|
10
|
+
* signature during a signing-secret rotation, and verification passes
|
|
11
|
+
* if any of them matches.
|
|
12
|
+
* 2. Confirms the timestamp is within `toleranceSeconds` of now
|
|
13
|
+
* (replay protection).
|
|
14
|
+
* 3. Computes the expected HMAC and compares against each candidate in
|
|
15
|
+
* constant time.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
19
|
+
|
|
20
|
+
export class WebhookVerificationError extends Error {
|
|
21
|
+
override name = "WebhookVerificationError";
|
|
22
|
+
constructor(message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VerifyWebhookOptions {
|
|
29
|
+
payload: string | Uint8Array;
|
|
30
|
+
signatureHeader: string | null | undefined;
|
|
31
|
+
secret: string;
|
|
32
|
+
toleranceSeconds?: number;
|
|
33
|
+
nowMs?: number; // injectable for tests
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const textEncoder = new TextEncoder();
|
|
37
|
+
const V1_HEX_RE = /^[0-9a-fA-F]{64}$/;
|
|
38
|
+
|
|
39
|
+
function toBytes(payload: string | Uint8Array): Uint8Array {
|
|
40
|
+
return typeof payload === "string" ? textEncoder.encode(payload) : payload;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
44
|
+
if (a.length !== b.length) return false;
|
|
45
|
+
let diff = 0;
|
|
46
|
+
for (let i = 0; i < a.length; i++) {
|
|
47
|
+
diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
48
|
+
}
|
|
49
|
+
return diff === 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hexToBytes(hex: string): Uint8Array | null {
|
|
53
|
+
if (!V1_HEX_RE.test(hex)) return null;
|
|
54
|
+
const out = new Uint8Array(hex.length / 2);
|
|
55
|
+
for (let i = 0; i < out.length; i++) {
|
|
56
|
+
const byte = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
57
|
+
if (Number.isNaN(byte)) return null;
|
|
58
|
+
out[i] = byte;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseSignatureHeader(header: string): { ts: number; v1List: string[] } {
|
|
64
|
+
let tsRaw: string | undefined;
|
|
65
|
+
const v1List: string[] = [];
|
|
66
|
+
for (const chunk of header.split(",")) {
|
|
67
|
+
const idx = chunk.indexOf("=");
|
|
68
|
+
if (idx < 0) continue;
|
|
69
|
+
const key = chunk.slice(0, idx).trim();
|
|
70
|
+
const value = chunk.slice(idx + 1).trim();
|
|
71
|
+
// A rotation can carry more than one ``v1=`` (old + new secret);
|
|
72
|
+
// collect them all and let the verifier accept any match.
|
|
73
|
+
if (key === "t") tsRaw = value;
|
|
74
|
+
else if (key === "v1") v1List.push(value);
|
|
75
|
+
}
|
|
76
|
+
if (!tsRaw || v1List.length === 0) {
|
|
77
|
+
throw new WebhookVerificationError(`Malformed BillKit-Signature header: ${header}`);
|
|
78
|
+
}
|
|
79
|
+
const ts = Number.parseInt(tsRaw, 10);
|
|
80
|
+
if (Number.isNaN(ts) || ts <= 0) {
|
|
81
|
+
throw new WebhookVerificationError(`Malformed timestamp in BillKit-Signature: ${tsRaw}`);
|
|
82
|
+
}
|
|
83
|
+
return { ts, v1List };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toArrayBuffer(view: Uint8Array): ArrayBuffer {
|
|
87
|
+
// ``Uint8Array.buffer`` is ``ArrayBufferLike`` (could be
|
|
88
|
+
// ``SharedArrayBuffer``); SubtleCrypto wants a concrete
|
|
89
|
+
// ``ArrayBuffer``. We copy into a fresh ArrayBuffer to bridge.
|
|
90
|
+
const out = new ArrayBuffer(view.byteLength);
|
|
91
|
+
new Uint8Array(out).set(view);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function computeHmac(secret: string, signed: Uint8Array): Promise<Uint8Array> {
|
|
96
|
+
const subtle = globalThis.crypto?.subtle;
|
|
97
|
+
if (!subtle) {
|
|
98
|
+
throw new WebhookVerificationError(
|
|
99
|
+
"No SubtleCrypto available. The BillKit SDK requires Node 20+, Bun, Deno, " +
|
|
100
|
+
"Cloudflare Workers, or any runtime that exposes globalThis.crypto.subtle.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const key = await subtle.importKey(
|
|
104
|
+
"raw",
|
|
105
|
+
toArrayBuffer(textEncoder.encode(secret)),
|
|
106
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
107
|
+
false,
|
|
108
|
+
["sign"],
|
|
109
|
+
);
|
|
110
|
+
const signature = await subtle.sign("HMAC", key, toArrayBuffer(signed));
|
|
111
|
+
return new Uint8Array(signature);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function verifyWebhookSignature<T = unknown>(
|
|
115
|
+
options: VerifyWebhookOptions,
|
|
116
|
+
): Promise<T> {
|
|
117
|
+
const {
|
|
118
|
+
payload,
|
|
119
|
+
signatureHeader,
|
|
120
|
+
secret,
|
|
121
|
+
toleranceSeconds = DEFAULT_WEBHOOK_TOLERANCE_SECONDS,
|
|
122
|
+
nowMs = Date.now(),
|
|
123
|
+
} = options;
|
|
124
|
+
|
|
125
|
+
if (signatureHeader === null || signatureHeader === undefined) {
|
|
126
|
+
throw new WebhookVerificationError("Missing BillKit-Signature header.");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { ts, v1List } = parseSignatureHeader(signatureHeader);
|
|
130
|
+
if (Math.abs(nowMs / 1000 - ts) > toleranceSeconds) {
|
|
131
|
+
throw new WebhookVerificationError(
|
|
132
|
+
`Signature timestamp outside ±${toleranceSeconds}s tolerance.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const payloadBytes = toBytes(payload);
|
|
137
|
+
const signed = new Uint8Array(payloadBytes.length + textEncoder.encode(`${ts}.`).length);
|
|
138
|
+
const prefix = textEncoder.encode(`${ts}.`);
|
|
139
|
+
signed.set(prefix, 0);
|
|
140
|
+
signed.set(payloadBytes, prefix.length);
|
|
141
|
+
|
|
142
|
+
const expected = await computeHmac(secret, signed);
|
|
143
|
+
// Compare against every candidate; don't break on the first match so
|
|
144
|
+
// the loop's timing doesn't reveal which signature matched.
|
|
145
|
+
let sawValidHex = false;
|
|
146
|
+
let matched = false;
|
|
147
|
+
for (const v1 of v1List) {
|
|
148
|
+
const received = hexToBytes(v1);
|
|
149
|
+
if (!received) continue;
|
|
150
|
+
sawValidHex = true;
|
|
151
|
+
if (constantTimeEqual(expected, received)) matched = true;
|
|
152
|
+
}
|
|
153
|
+
if (!sawValidHex) {
|
|
154
|
+
throw new WebhookVerificationError(
|
|
155
|
+
`Malformed v1 hex in BillKit-Signature: ${v1List.join(",")}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (!matched) {
|
|
159
|
+
throw new WebhookVerificationError("Signature mismatch.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
163
|
+
const text = decoder.decode(payloadBytes);
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(text) as T;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
throw new WebhookVerificationError(
|
|
168
|
+
`Webhook body is not valid JSON: ${(err as Error).message}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|