@distyra/sdk 0.6.0 → 0.7.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/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
- * and a default base URL, returning a typed openapi-fetch client.
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
- * and a default base URL, returning a typed openapi-fetch client.
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;
@@ -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.6.0",
3
+ "version": "0.7.0",
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",