@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/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
+ }
@@ -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";
@@ -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
+ }