@distyra/sdk 0.5.0 → 0.6.1
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/README.md +64 -2
- package/dist/generated.d.ts +4317 -759
- package/dist/index.d.ts +94 -2
- package/dist/index.js +154 -4
- package/dist/webhook.d.ts +56 -0
- package/dist/webhook.js +93 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -7,8 +7,18 @@
|
|
|
7
7
|
* - `paths` — generated types from the OpenAPI snapshot in
|
|
8
8
|
* apps/saas-api/openapi.json. Re-exported so callers can write their own
|
|
9
9
|
* openapi-fetch client if they need non-default behavior.
|
|
10
|
-
* - `createDistyraClient()` — opinionated factory that wires up bearer auth
|
|
11
|
-
*
|
|
10
|
+
* - `createDistyraClient()` — opinionated factory that wires up bearer auth,
|
|
11
|
+
* a default base URL, and a request timeout, returning a typed
|
|
12
|
+
* openapi-fetch client.
|
|
13
|
+
*
|
|
14
|
+
* Plus three ergonomic helpers on top of the typed client:
|
|
15
|
+
*
|
|
16
|
+
* - a default request timeout (AbortController) — parity with the Python SDK's
|
|
17
|
+
* 30s default; previously the JS client had none (a hung origin hung the
|
|
18
|
+
* caller forever).
|
|
19
|
+
* - `idempotencyKey()` — sets the spec-conformant `Idempotency-Key` header so
|
|
20
|
+
* retried POSTs dedup server-side.
|
|
21
|
+
* - `paginate()` — auto-iterates the cursor-paginated list endpoints.
|
|
12
22
|
*
|
|
13
23
|
* Consumers: the banking backend internally (shadow-mode and cutover paths,
|
|
14
24
|
* v2 plan §16.4 weeks 6–9) plus external API customers via the published
|
|
@@ -26,12 +36,19 @@ import { type Client, type ClientOptions } from 'openapi-fetch';
|
|
|
26
36
|
import type { paths } from './generated';
|
|
27
37
|
export type { paths } from './generated';
|
|
28
38
|
export type { components, operations } from './generated';
|
|
39
|
+
export { verifyWebhook, SIGNATURE_HEADER, DEFAULT_TOLERANCE_SECONDS } from './webhook';
|
|
40
|
+
export type { VerifyResult, VerifyWebhookOptions } from './webhook';
|
|
29
41
|
/**
|
|
30
42
|
* Production base URL. Mirrors the OpenAPI `servers[0]` entry; kept in sync by
|
|
31
43
|
* hand because the SDK shouldn't have to parse the spec at runtime. The API and
|
|
32
44
|
* CDN stay on `.com`; only the Distyra website and customer portal use `.eu`.
|
|
33
45
|
*/
|
|
34
46
|
export declare const PRODUCTION_BASE_URL = "https://api.distyra.com";
|
|
47
|
+
/**
|
|
48
|
+
* Default per-request timeout, in milliseconds. Matches the Python SDK's 30s so
|
|
49
|
+
* the two clients behave identically. Set `timeoutMs: 0` to disable.
|
|
50
|
+
*/
|
|
51
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
35
52
|
export interface DistyraClientOptions extends Omit<ClientOptions, 'baseUrl' | 'headers'> {
|
|
36
53
|
/** API key (`sk_…`). Required. */
|
|
37
54
|
apiKey: string;
|
|
@@ -39,6 +56,29 @@ export interface DistyraClientOptions extends Omit<ClientOptions, 'baseUrl' | 'h
|
|
|
39
56
|
baseUrl?: string;
|
|
40
57
|
/** Extra headers merged on every request (rarely needed; useful for tracing). */
|
|
41
58
|
headers?: Record<string, string>;
|
|
59
|
+
/**
|
|
60
|
+
* Per-request timeout in milliseconds (default {@link DEFAULT_TIMEOUT_MS}).
|
|
61
|
+
* The request is aborted with a `TimeoutError` when it elapses. Set to `0`
|
|
62
|
+
* to disable the timeout entirely. A caller-supplied `signal` on an
|
|
63
|
+
* individual request still wins — aborting it aborts the request early, and
|
|
64
|
+
* the timeout is layered on top.
|
|
65
|
+
*/
|
|
66
|
+
timeoutMs?: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A non-2xx API response (or a thrown transport error) surfaced by the helper
|
|
70
|
+
* layer (`paginate`). Mirrors the Python SDK's `DistyraError`: carries the
|
|
71
|
+
* parsed `{ error, detail }` envelope so callers don't re-parse the body.
|
|
72
|
+
*
|
|
73
|
+
* The base `createDistyraClient()` client itself still returns the openapi-fetch
|
|
74
|
+
* `{ data, error }` union on ordinary calls — this is only thrown by helpers
|
|
75
|
+
* that can't express a partial result (an iterator can't yield an error union).
|
|
76
|
+
*/
|
|
77
|
+
export declare class DistyraApiError extends Error {
|
|
78
|
+
readonly status: number;
|
|
79
|
+
readonly code: string;
|
|
80
|
+
readonly detail?: string;
|
|
81
|
+
constructor(status: number, body: unknown);
|
|
42
82
|
}
|
|
43
83
|
/**
|
|
44
84
|
* Create a typed openapi-fetch client pre-bound to the Distyra API.
|
|
@@ -52,5 +92,57 @@ export interface DistyraClientOptions extends Omit<ClientOptions, 'baseUrl' | 'h
|
|
|
52
92
|
*
|
|
53
93
|
* `data` is fully typed against the EnrichResponse schema. `error` carries
|
|
54
94
|
* the standard `{ error, detail? }` envelope on 4xx/5xx.
|
|
95
|
+
*
|
|
96
|
+
* Retry-safe POSTs: spread {@link idempotencyKey}() into the request options.
|
|
97
|
+
* Long list endpoints: use {@link paginate} to iterate every page.
|
|
55
98
|
*/
|
|
56
99
|
export declare function createDistyraClient(opts: DistyraClientOptions): Client<paths>;
|
|
100
|
+
/**
|
|
101
|
+
* A request-options fragment that sets the `Idempotency-Key` header. Spread it
|
|
102
|
+
* into any POST so a retried call dedups server-side instead of double-charging
|
|
103
|
+
* or double-creating:
|
|
104
|
+
*
|
|
105
|
+
* const key = crypto.randomUUID();
|
|
106
|
+
* await sdk.POST('/v1/enrich/batch', { body, ...idempotencyKey(key) });
|
|
107
|
+
* // ...on a network error, retry with the SAME fragment — the server replays
|
|
108
|
+
* // the original response instead of re-running the batch.
|
|
109
|
+
*
|
|
110
|
+
* Call with no argument to mint a fresh v4 UUID (useful for a one-shot request
|
|
111
|
+
* you won't retry). To make retries safe you must REUSE the same key across the
|
|
112
|
+
* original and every retry, so capture it once and pass it back in.
|
|
113
|
+
*
|
|
114
|
+
* The header is the spec-conformant `Idempotency-Key` (no `X-` prefix). The
|
|
115
|
+
* legacy `X-Idempotency-Key` still works server-side but is deprecated.
|
|
116
|
+
*/
|
|
117
|
+
export declare function idempotencyKey(key?: string): {
|
|
118
|
+
headers: {
|
|
119
|
+
'Idempotency-Key': string;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The cursor-paginated list endpoints. Each returns `{ <items>: [...],
|
|
124
|
+
* next_cursor: string | null }` and accepts a `cursor` query param; the cursor
|
|
125
|
+
* is an opaque `<iso-timestamp>|<uuid>` string. {@link paginate} auto-advances
|
|
126
|
+
* it until `next_cursor` is null.
|
|
127
|
+
*/
|
|
128
|
+
export type CursorListPath = '/v1/underwrite/analyses' | '/v1/underwrite/ingestions' | '/v1/underwrite/collection-links';
|
|
129
|
+
export interface PaginateOptions {
|
|
130
|
+
/** Extra query params (e.g. `limit`) applied to every page request. `cursor` is managed for you and any value passed here is ignored. */
|
|
131
|
+
query?: Record<string, unknown>;
|
|
132
|
+
/** Optional AbortSignal to stop iterating between pages. */
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Async-iterate every item across all pages of a cursor-paginated list
|
|
137
|
+
* endpoint, transparently following `next_cursor`:
|
|
138
|
+
*
|
|
139
|
+
* for await (const link of paginate(sdk, '/v1/underwrite/collection-links')) {
|
|
140
|
+
* console.log(link.id, link.status);
|
|
141
|
+
* }
|
|
142
|
+
*
|
|
143
|
+
* Yields the individual items (not pages) — it auto-detects the single array
|
|
144
|
+
* field in each response (`analyses` / `ingestions` / `collection_links`).
|
|
145
|
+
* Throws {@link DistyraApiError} on any non-2xx page so a mid-iteration failure
|
|
146
|
+
* surfaces instead of silently truncating.
|
|
147
|
+
*/
|
|
148
|
+
export declare function paginate<Item = Record<string, unknown>>(client: Client<paths>, path: CursorListPath, options?: PaginateOptions): AsyncGenerator<Item, void, unknown>;
|
package/dist/index.js
CHANGED
|
@@ -8,8 +8,18 @@
|
|
|
8
8
|
* - `paths` — generated types from the OpenAPI snapshot in
|
|
9
9
|
* apps/saas-api/openapi.json. Re-exported so callers can write their own
|
|
10
10
|
* openapi-fetch client if they need non-default behavior.
|
|
11
|
-
* - `createDistyraClient()` — opinionated factory that wires up bearer auth
|
|
12
|
-
*
|
|
11
|
+
* - `createDistyraClient()` — opinionated factory that wires up bearer auth,
|
|
12
|
+
* a default base URL, and a request timeout, returning a typed
|
|
13
|
+
* openapi-fetch client.
|
|
14
|
+
*
|
|
15
|
+
* Plus three ergonomic helpers on top of the typed client:
|
|
16
|
+
*
|
|
17
|
+
* - a default request timeout (AbortController) — parity with the Python SDK's
|
|
18
|
+
* 30s default; previously the JS client had none (a hung origin hung the
|
|
19
|
+
* caller forever).
|
|
20
|
+
* - `idempotencyKey()` — sets the spec-conformant `Idempotency-Key` header so
|
|
21
|
+
* retried POSTs dedup server-side.
|
|
22
|
+
* - `paginate()` — auto-iterates the cursor-paginated list endpoints.
|
|
13
23
|
*
|
|
14
24
|
* Consumers: the banking backend internally (shadow-mode and cutover paths,
|
|
15
25
|
* v2 plan §16.4 weeks 6–9) plus external API customers via the published
|
|
@@ -27,15 +37,49 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
27
37
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
28
38
|
};
|
|
29
39
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
-
exports.PRODUCTION_BASE_URL = void 0;
|
|
40
|
+
exports.DistyraApiError = exports.DEFAULT_TIMEOUT_MS = exports.PRODUCTION_BASE_URL = exports.DEFAULT_TOLERANCE_SECONDS = exports.SIGNATURE_HEADER = exports.verifyWebhook = void 0;
|
|
31
41
|
exports.createDistyraClient = createDistyraClient;
|
|
42
|
+
exports.idempotencyKey = idempotencyKey;
|
|
43
|
+
exports.paginate = paginate;
|
|
32
44
|
const openapi_fetch_1 = __importDefault(require("openapi-fetch"));
|
|
45
|
+
// Webhook signature verification (server-side; node:crypto). Isolated in
|
|
46
|
+
// ./webhook so importing the client alone doesn't require a Node runtime.
|
|
47
|
+
var webhook_1 = require("./webhook");
|
|
48
|
+
Object.defineProperty(exports, "verifyWebhook", { enumerable: true, get: function () { return webhook_1.verifyWebhook; } });
|
|
49
|
+
Object.defineProperty(exports, "SIGNATURE_HEADER", { enumerable: true, get: function () { return webhook_1.SIGNATURE_HEADER; } });
|
|
50
|
+
Object.defineProperty(exports, "DEFAULT_TOLERANCE_SECONDS", { enumerable: true, get: function () { return webhook_1.DEFAULT_TOLERANCE_SECONDS; } });
|
|
33
51
|
/**
|
|
34
52
|
* Production base URL. Mirrors the OpenAPI `servers[0]` entry; kept in sync by
|
|
35
53
|
* hand because the SDK shouldn't have to parse the spec at runtime. The API and
|
|
36
54
|
* CDN stay on `.com`; only the Distyra website and customer portal use `.eu`.
|
|
37
55
|
*/
|
|
38
56
|
exports.PRODUCTION_BASE_URL = 'https://api.distyra.com';
|
|
57
|
+
/**
|
|
58
|
+
* Default per-request timeout, in milliseconds. Matches the Python SDK's 30s so
|
|
59
|
+
* the two clients behave identically. Set `timeoutMs: 0` to disable.
|
|
60
|
+
*/
|
|
61
|
+
exports.DEFAULT_TIMEOUT_MS = 30_000;
|
|
62
|
+
/**
|
|
63
|
+
* A non-2xx API response (or a thrown transport error) surfaced by the helper
|
|
64
|
+
* layer (`paginate`). Mirrors the Python SDK's `DistyraError`: carries the
|
|
65
|
+
* parsed `{ error, detail }` envelope so callers don't re-parse the body.
|
|
66
|
+
*
|
|
67
|
+
* The base `createDistyraClient()` client itself still returns the openapi-fetch
|
|
68
|
+
* `{ data, error }` union on ordinary calls — this is only thrown by helpers
|
|
69
|
+
* that can't express a partial result (an iterator can't yield an error union).
|
|
70
|
+
*/
|
|
71
|
+
class DistyraApiError extends Error {
|
|
72
|
+
constructor(status, body) {
|
|
73
|
+
const env = (body ?? {});
|
|
74
|
+
const code = env.error ?? 'unknown_error';
|
|
75
|
+
super(`HTTP ${status}: ${code}${env.detail ? ` — ${env.detail}` : ''}`);
|
|
76
|
+
this.name = 'DistyraApiError';
|
|
77
|
+
this.status = status;
|
|
78
|
+
this.code = code;
|
|
79
|
+
this.detail = env.detail;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
exports.DistyraApiError = DistyraApiError;
|
|
39
83
|
/**
|
|
40
84
|
* Create a typed openapi-fetch client pre-bound to the Distyra API.
|
|
41
85
|
*
|
|
@@ -48,14 +92,22 @@ exports.PRODUCTION_BASE_URL = 'https://api.distyra.com';
|
|
|
48
92
|
*
|
|
49
93
|
* `data` is fully typed against the EnrichResponse schema. `error` carries
|
|
50
94
|
* the standard `{ error, detail? }` envelope on 4xx/5xx.
|
|
95
|
+
*
|
|
96
|
+
* Retry-safe POSTs: spread {@link idempotencyKey}() into the request options.
|
|
97
|
+
* Long list endpoints: use {@link paginate} to iterate every page.
|
|
51
98
|
*/
|
|
52
99
|
function createDistyraClient(opts) {
|
|
53
|
-
const { apiKey, baseUrl = exports.PRODUCTION_BASE_URL, headers, ...rest } = opts;
|
|
100
|
+
const { apiKey, baseUrl = exports.PRODUCTION_BASE_URL, headers, timeoutMs = exports.DEFAULT_TIMEOUT_MS, fetch: fetchImpl, ...rest } = opts;
|
|
54
101
|
if (!apiKey) {
|
|
55
102
|
throw new Error('createDistyraClient: apiKey is required');
|
|
56
103
|
}
|
|
104
|
+
// openapi-fetch always calls `fetch` with a single Request object, so the
|
|
105
|
+
// custom-fetch slot is narrower than the global `fetch` signature.
|
|
106
|
+
const baseFetch = fetchImpl ?? globalThis.fetch;
|
|
107
|
+
const clientFetch = timeoutMs > 0 ? (input) => fetchWithTimeout(baseFetch, input, timeoutMs) : baseFetch;
|
|
57
108
|
return (0, openapi_fetch_1.default)({
|
|
58
109
|
baseUrl,
|
|
110
|
+
fetch: clientFetch,
|
|
59
111
|
headers: {
|
|
60
112
|
Authorization: `Bearer ${apiKey}`,
|
|
61
113
|
'Content-Type': 'application/json',
|
|
@@ -64,3 +116,101 @@ function createDistyraClient(opts) {
|
|
|
64
116
|
...rest,
|
|
65
117
|
});
|
|
66
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* A request-options fragment that sets the `Idempotency-Key` header. Spread it
|
|
121
|
+
* into any POST so a retried call dedups server-side instead of double-charging
|
|
122
|
+
* or double-creating:
|
|
123
|
+
*
|
|
124
|
+
* const key = crypto.randomUUID();
|
|
125
|
+
* await sdk.POST('/v1/enrich/batch', { body, ...idempotencyKey(key) });
|
|
126
|
+
* // ...on a network error, retry with the SAME fragment — the server replays
|
|
127
|
+
* // the original response instead of re-running the batch.
|
|
128
|
+
*
|
|
129
|
+
* Call with no argument to mint a fresh v4 UUID (useful for a one-shot request
|
|
130
|
+
* you won't retry). To make retries safe you must REUSE the same key across the
|
|
131
|
+
* original and every retry, so capture it once and pass it back in.
|
|
132
|
+
*
|
|
133
|
+
* The header is the spec-conformant `Idempotency-Key` (no `X-` prefix). The
|
|
134
|
+
* legacy `X-Idempotency-Key` still works server-side but is deprecated.
|
|
135
|
+
*/
|
|
136
|
+
function idempotencyKey(key) {
|
|
137
|
+
return { headers: { 'Idempotency-Key': key ?? randomUuid() } };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Async-iterate every item across all pages of a cursor-paginated list
|
|
141
|
+
* endpoint, transparently following `next_cursor`:
|
|
142
|
+
*
|
|
143
|
+
* for await (const link of paginate(sdk, '/v1/underwrite/collection-links')) {
|
|
144
|
+
* console.log(link.id, link.status);
|
|
145
|
+
* }
|
|
146
|
+
*
|
|
147
|
+
* Yields the individual items (not pages) — it auto-detects the single array
|
|
148
|
+
* field in each response (`analyses` / `ingestions` / `collection_links`).
|
|
149
|
+
* Throws {@link DistyraApiError} on any non-2xx page so a mid-iteration failure
|
|
150
|
+
* surfaces instead of silently truncating.
|
|
151
|
+
*/
|
|
152
|
+
async function* paginate(client, path, options = {}) {
|
|
153
|
+
const baseQuery = { ...(options.query ?? {}) };
|
|
154
|
+
delete baseQuery.cursor; // cursor is managed here; ignore any caller value
|
|
155
|
+
let cursor;
|
|
156
|
+
for (;;) {
|
|
157
|
+
const query = cursor ? { ...baseQuery, cursor } : baseQuery;
|
|
158
|
+
// openapi-fetch's GET is per-path typed; the generic helper spans several
|
|
159
|
+
// paths, so we go through the untyped call and re-type the page below.
|
|
160
|
+
const { data, error, response } = await client.GET(path, { params: { query }, signal: options.signal });
|
|
161
|
+
if (error !== undefined || data === undefined) {
|
|
162
|
+
throw new DistyraApiError(response?.status ?? 0, error);
|
|
163
|
+
}
|
|
164
|
+
const page = data;
|
|
165
|
+
const items = Object.values(page).find((v) => Array.isArray(v)) ?? [];
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
yield item;
|
|
168
|
+
}
|
|
169
|
+
const next = page.next_cursor;
|
|
170
|
+
if (typeof next !== 'string' || next.length === 0) {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
cursor = next;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Wrap a fetch call in an AbortController-driven timeout while still honoring a
|
|
178
|
+
* caller-supplied `signal` on the incoming Request (aborting either one aborts
|
|
179
|
+
* the request).
|
|
180
|
+
*/
|
|
181
|
+
function fetchWithTimeout(baseFetch, input, timeoutMs) {
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
const timer = setTimeout(() => {
|
|
184
|
+
controller.abort(new DOMException(`Request timed out after ${timeoutMs}ms`, 'TimeoutError'));
|
|
185
|
+
}, timeoutMs);
|
|
186
|
+
const callerSignal = input.signal;
|
|
187
|
+
if (callerSignal) {
|
|
188
|
+
if (callerSignal.aborted) {
|
|
189
|
+
controller.abort(callerSignal.reason);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
callerSignal.addEventListener('abort', () => controller.abort(callerSignal.reason), {
|
|
193
|
+
once: true,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return baseFetch(new Request(input, { signal: controller.signal })).finally(() => clearTimeout(timer));
|
|
198
|
+
}
|
|
199
|
+
/** RFC-4122 v4 UUID via WebCrypto, with graceful fallbacks for old runtimes. */
|
|
200
|
+
function randomUuid() {
|
|
201
|
+
const c = globalThis.crypto;
|
|
202
|
+
if (c?.randomUUID) {
|
|
203
|
+
return c.randomUUID();
|
|
204
|
+
}
|
|
205
|
+
if (c?.getRandomValues) {
|
|
206
|
+
const b = c.getRandomValues(new Uint8Array(16));
|
|
207
|
+
b[6] = (b[6] & 0x0f) | 0x40;
|
|
208
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
209
|
+
const hex = Array.from(b, (x) => x.toString(16).padStart(2, '0'));
|
|
210
|
+
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex
|
|
211
|
+
.slice(6, 8)
|
|
212
|
+
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`;
|
|
213
|
+
}
|
|
214
|
+
// Last resort — non-crypto, but idempotency keys only need to be unique.
|
|
215
|
+
return 'idem-' + Date.now().toString(16) + '-' + Math.random().toString(16).slice(2);
|
|
216
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook signature verification for Distyra webhook deliveries.
|
|
3
|
+
*
|
|
4
|
+
* Distyra signs every delivery with a Stripe-shape header:
|
|
5
|
+
*
|
|
6
|
+
* X-Distyra-Signature: t=<unix>,v1=<hmac_sha256_hex>
|
|
7
|
+
*
|
|
8
|
+
* where the HMAC-SHA256 input is the literal string "<t>.<raw_body>", keyed by
|
|
9
|
+
* your endpoint's signing secret (the value returned when you create or rotate
|
|
10
|
+
* the endpoint). The `t` prefix defends against replay: the verifier rejects
|
|
11
|
+
* timestamps outside a tolerance window (5 minutes by default).
|
|
12
|
+
*
|
|
13
|
+
* Always verify against the RAW request body, before JSON parsing — any
|
|
14
|
+
* re-serialization changes the bytes and invalidates the HMAC.
|
|
15
|
+
*
|
|
16
|
+
* This module uses `node:crypto` and is intended for server-side webhook
|
|
17
|
+
* receivers (Node 18+). It is kept in its own module so importing the API
|
|
18
|
+
* client (`@distyra/sdk`) in a non-Node runtime does not pull in `node:crypto`.
|
|
19
|
+
*/
|
|
20
|
+
/** The header Distyra sends the signature in. */
|
|
21
|
+
export declare const SIGNATURE_HEADER = "X-Distyra-Signature";
|
|
22
|
+
/** Default replay-protection window, in seconds. */
|
|
23
|
+
export declare const DEFAULT_TOLERANCE_SECONDS: number;
|
|
24
|
+
export type VerifyResult = {
|
|
25
|
+
ok: true;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: 'malformed' | 'stale' | 'no_match';
|
|
30
|
+
};
|
|
31
|
+
export interface VerifyWebhookOptions {
|
|
32
|
+
/** Reject a signature whose timestamp is more than this many seconds from now (default 300). */
|
|
33
|
+
toleranceSeconds?: number;
|
|
34
|
+
/** Override "now" (unix seconds). For testing. */
|
|
35
|
+
now?: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Verify a Distyra webhook signature.
|
|
39
|
+
*
|
|
40
|
+
* @param secret Your endpoint's signing secret.
|
|
41
|
+
* @param payload The RAW request body string, exactly as received. Do
|
|
42
|
+
* NOT `JSON.parse` then re-`stringify` — verify the raw bytes.
|
|
43
|
+
* @param signatureHeader The value of the `X-Distyra-Signature` header.
|
|
44
|
+
* @returns `{ ok: true, timestamp }` when valid; otherwise `{ ok: false, reason }`
|
|
45
|
+
* where reason is `'malformed'` (unparseable/absent header), `'stale'`
|
|
46
|
+
* (outside the tolerance window), or `'no_match'` (signature mismatch).
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* import { verifyWebhook } from '@distyra/sdk';
|
|
50
|
+
*
|
|
51
|
+
* const raw = await readRawBody(req); // the exact bytes, before JSON.parse
|
|
52
|
+
* const result = verifyWebhook(secret, raw, req.headers['x-distyra-signature']);
|
|
53
|
+
* if (!result.ok) return res.status(400).end();
|
|
54
|
+
* const event = JSON.parse(raw);
|
|
55
|
+
*/
|
|
56
|
+
export declare function verifyWebhook(secret: string, payload: string, signatureHeader: string | null | undefined, options?: VerifyWebhookOptions): VerifyResult;
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Webhook signature verification for Distyra webhook deliveries.
|
|
4
|
+
*
|
|
5
|
+
* Distyra signs every delivery with a Stripe-shape header:
|
|
6
|
+
*
|
|
7
|
+
* X-Distyra-Signature: t=<unix>,v1=<hmac_sha256_hex>
|
|
8
|
+
*
|
|
9
|
+
* where the HMAC-SHA256 input is the literal string "<t>.<raw_body>", keyed by
|
|
10
|
+
* your endpoint's signing secret (the value returned when you create or rotate
|
|
11
|
+
* the endpoint). The `t` prefix defends against replay: the verifier rejects
|
|
12
|
+
* timestamps outside a tolerance window (5 minutes by default).
|
|
13
|
+
*
|
|
14
|
+
* Always verify against the RAW request body, before JSON parsing — any
|
|
15
|
+
* re-serialization changes the bytes and invalidates the HMAC.
|
|
16
|
+
*
|
|
17
|
+
* This module uses `node:crypto` and is intended for server-side webhook
|
|
18
|
+
* receivers (Node 18+). It is kept in its own module so importing the API
|
|
19
|
+
* client (`@distyra/sdk`) in a non-Node runtime does not pull in `node:crypto`.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.DEFAULT_TOLERANCE_SECONDS = exports.SIGNATURE_HEADER = void 0;
|
|
23
|
+
exports.verifyWebhook = verifyWebhook;
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
/** The header Distyra sends the signature in. */
|
|
26
|
+
exports.SIGNATURE_HEADER = 'X-Distyra-Signature';
|
|
27
|
+
/** Default replay-protection window, in seconds. */
|
|
28
|
+
exports.DEFAULT_TOLERANCE_SECONDS = 5 * 60;
|
|
29
|
+
/**
|
|
30
|
+
* Verify a Distyra webhook signature.
|
|
31
|
+
*
|
|
32
|
+
* @param secret Your endpoint's signing secret.
|
|
33
|
+
* @param payload The RAW request body string, exactly as received. Do
|
|
34
|
+
* NOT `JSON.parse` then re-`stringify` — verify the raw bytes.
|
|
35
|
+
* @param signatureHeader The value of the `X-Distyra-Signature` header.
|
|
36
|
+
* @returns `{ ok: true, timestamp }` when valid; otherwise `{ ok: false, reason }`
|
|
37
|
+
* where reason is `'malformed'` (unparseable/absent header), `'stale'`
|
|
38
|
+
* (outside the tolerance window), or `'no_match'` (signature mismatch).
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* import { verifyWebhook } from '@distyra/sdk';
|
|
42
|
+
*
|
|
43
|
+
* const raw = await readRawBody(req); // the exact bytes, before JSON.parse
|
|
44
|
+
* const result = verifyWebhook(secret, raw, req.headers['x-distyra-signature']);
|
|
45
|
+
* if (!result.ok) return res.status(400).end();
|
|
46
|
+
* const event = JSON.parse(raw);
|
|
47
|
+
*/
|
|
48
|
+
function verifyWebhook(secret, payload, signatureHeader, options = {}) {
|
|
49
|
+
if (!signatureHeader)
|
|
50
|
+
return { ok: false, reason: 'malformed' };
|
|
51
|
+
const tolerance = options.toleranceSeconds ?? exports.DEFAULT_TOLERANCE_SECONDS;
|
|
52
|
+
const now = options.now ?? Math.floor(Date.now() / 1000);
|
|
53
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
54
|
+
if (!parsed)
|
|
55
|
+
return { ok: false, reason: 'malformed' };
|
|
56
|
+
if (Math.abs(now - parsed.t) > tolerance) {
|
|
57
|
+
return { ok: false, reason: 'stale' };
|
|
58
|
+
}
|
|
59
|
+
const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(`${parsed.t}.${payload}`).digest();
|
|
60
|
+
for (const v of parsed.signatures) {
|
|
61
|
+
const candidate = Buffer.from(v, 'hex');
|
|
62
|
+
if (candidate.length !== expected.length)
|
|
63
|
+
continue;
|
|
64
|
+
if ((0, node_crypto_1.timingSafeEqual)(candidate, expected)) {
|
|
65
|
+
return { ok: true, timestamp: parsed.t };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { ok: false, reason: 'no_match' };
|
|
69
|
+
}
|
|
70
|
+
function parseSignatureHeader(header) {
|
|
71
|
+
let t = null;
|
|
72
|
+
const signatures = [];
|
|
73
|
+
for (const part of header.split(',')) {
|
|
74
|
+
const eq = part.indexOf('=');
|
|
75
|
+
if (eq < 0)
|
|
76
|
+
return null;
|
|
77
|
+
const key = part.slice(0, eq).trim();
|
|
78
|
+
const value = part.slice(eq + 1).trim();
|
|
79
|
+
if (key === 't') {
|
|
80
|
+
const n = Number.parseInt(value, 10);
|
|
81
|
+
if (!Number.isFinite(n))
|
|
82
|
+
return null;
|
|
83
|
+
t = n;
|
|
84
|
+
}
|
|
85
|
+
else if (key === 'v1') {
|
|
86
|
+
signatures.push(value);
|
|
87
|
+
}
|
|
88
|
+
// Unknown keys (future v2, etc.) are ignored — forward-compat.
|
|
89
|
+
}
|
|
90
|
+
if (t === null || signatures.length === 0)
|
|
91
|
+
return null;
|
|
92
|
+
return { t, signatures };
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@distyra/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "TypeScript SDK for the Distyra Transaction Enrichment & SME Underwriting API. Generated from the committed OpenAPI spec; thin, typed wrapper around openapi-fetch.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Brightfield Software",
|