@sekyro/node 0.0.0-stage → 0.1.0-rc.2

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.
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Wire-level enums and shapes of the Sekyro partner API (`/api/v2`). These
3
+ * mirror the backend's frozen partner contract; they are plain string unions so
4
+ * a newer API value never throws at runtime — it simply widens the type in the
5
+ * next SDK release.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ /** Session lifecycle status. @public */
10
+ type SessionStatus = "created" | "in_progress" | "review_pending" | "completed" | "expired" | "failed";
11
+ /**
12
+ * Terminal verdict, orthogonal to {@link SessionStatus}. `null` until the session
13
+ * reaches a terminal status; `review` when a human reviewer must adjudicate.
14
+ * @public
15
+ */
16
+ type SessionOutcome = "approved" | "review" | "rejected";
17
+ /** The verification checks a flow may contain. @public */
18
+ type CheckType = "liveness" | "face_match" | "doc_ocr" | "population_registry" | "phone_otp";
19
+ /** Per-check execution state within a session. @public */
20
+ type CheckState = "pending" | "input_required" | "processing" | "passed" | "failed" | "review" | "skipped";
21
+ /** Hosted-flow languages. @public */
22
+ type Locale = "ar" | "fr" | "en";
23
+ /** The environment a webhook was emitted from. @public */
24
+ type SekyroEnvironment = "sandbox" | "live";
25
+ /**
26
+ * One check of a session, as the partner API projects it: enum/id fields only,
27
+ * never civil data.
28
+ * @public
29
+ */
30
+ interface SessionStep {
31
+ checkType: CheckType;
32
+ state: CheckState;
33
+ /** Machine-readable failure/review reason, when the check has one. */
34
+ reason?: string;
35
+ /** Calibrated model score in [0, 1] for scored checks (liveness, face match). */
36
+ calibratedScore?: number;
37
+ }
38
+ /**
39
+ * Out-of-band delivery of the session's hosted link, sent by Sekyro after the
40
+ * session is created. Best effort: a failed send never fails the create.
41
+ * @public
42
+ */
43
+ interface DeliverOptions {
44
+ /** Delivery channel. */
45
+ channel: "sms" | "whatsapp";
46
+ /** E.164 phone number of the recipient, e.g. `+22236123456`. */
47
+ to: string;
48
+ }
49
+ /**
50
+ * Input of {@link SessionsApi.create}. There is no recipe field: the API key
51
+ * belongs to an app, and that app's eKYC profile decides the steps.
52
+ * @public
53
+ */
54
+ interface CreateSessionInput {
55
+ /** Pins the hosted-flow language. Omit to let the person's device decide. */
56
+ locale?: Locale;
57
+ /**
58
+ * Session lifetime in minutes. Must fall within the app profile's link
59
+ * range, otherwise the API answers `400 ttl_out_of_policy`. Omit to use the
60
+ * profile's default.
61
+ */
62
+ ttlMinutes?: number;
63
+ /** Also send the hosted link to the person by SMS or WhatsApp. */
64
+ deliver?: DeliverOptions;
65
+ /**
66
+ * Step inputs consumed server-side. The known anchors are typed (all
67
+ * strings — an `nni` is a 10-digit string, never a number); further keys
68
+ * pass through to the API as-is. `nni` binds the session to that identity:
69
+ * the hosted flow may omit it but cannot substitute another.
70
+ */
71
+ input?: {
72
+ /** 10-digit Mauritanian NNI, as a string. */
73
+ nni?: string;
74
+ /** E.164 phone number, e.g. `+22236123456`. */
75
+ phone?: string;
76
+ fullName?: string;
77
+ /** ISO 8601 date (`YYYY-MM-DD`). */
78
+ birthDate?: string;
79
+ } & Record<string, unknown>;
80
+ }
81
+ /** Per-request options. @public */
82
+ interface RequestOptions {
83
+ /**
84
+ * Sent as `X-Idempotency-Key`. Replaying the same key within 24 h returns the
85
+ * original response instead of creating a second session.
86
+ */
87
+ idempotencyKey?: string;
88
+ }
89
+ /**
90
+ * Result of {@link SessionsApi.create}. `clientToken` is returned ONCE and never
91
+ * again — hand it to the browser (`@sekyro/web`) and do not persist it.
92
+ * @public
93
+ */
94
+ interface CreatedSession {
95
+ id: string;
96
+ status: SessionStatus;
97
+ /** One-time credential for the client SDK / hosted flow. */
98
+ clientToken: string;
99
+ /** Hosted verification link for this session. */
100
+ hostedUrl: string;
101
+ steps: SessionStep[];
102
+ /** ISO 8601 — when the session (and its link) stop accepting input. */
103
+ expiresAt: string;
104
+ }
105
+ /**
106
+ * The whitelisted session view returned by {@link SessionsApi.get}. Never
107
+ * contains the client token.
108
+ * @public
109
+ */
110
+ interface Session {
111
+ id: string;
112
+ status: SessionStatus;
113
+ outcome: SessionOutcome | null;
114
+ steps: SessionStep[];
115
+ /** ISO 8601. */
116
+ expiresAt: string;
117
+ /** ISO 8601. */
118
+ createdAt: string;
119
+ }
120
+ /**
121
+ * `data` of the terminal / review-pending session lifecycle events
122
+ * (`session.completed` | `session.failed` | `session.review_pending` |
123
+ * `session.expired` | `session.rejected`) — ids and enums only, no civil data,
124
+ * scores or links. `outcome` and `steps` travel on `completed` / `failed`;
125
+ * `review_pending` carries `steps` without an outcome; `expired` carries the
126
+ * status alone.
127
+ * @public
128
+ */
129
+ interface SessionLifecycleEventData {
130
+ sessionId: string;
131
+ status: SessionStatus;
132
+ outcome?: SessionOutcome | null;
133
+ steps?: Array<Pick<SessionStep, "checkType" | "state">>;
134
+ /** Machine-readable reason, when the transition has one. */
135
+ reason?: string;
136
+ }
137
+ /**
138
+ * `data` of `session.step.completed` — the check that just passed.
139
+ * @public
140
+ */
141
+ interface SessionStepCompletedEventData {
142
+ sessionId: string;
143
+ checkType: CheckType;
144
+ state: CheckState;
145
+ }
146
+ /**
147
+ * `data` of `session.review_decided` — a human (or the review-timeout sweep)
148
+ * decided a pending review. `outcome` speaks the REVIEW vocabulary
149
+ * (`pass` | `fail`), not the session outcome; `reason` is the reviewer's code
150
+ * (e.g. `review_timeout` for an expired review).
151
+ * @public
152
+ */
153
+ interface SessionReviewDecidedEventData {
154
+ sessionId: string;
155
+ outcome: "pass" | "fail";
156
+ reason: string;
157
+ }
158
+ /**
159
+ * Event type → the shape of its `data`. Session events are typed; the
160
+ * remaining events carry an open record until their payloads are frozen.
161
+ * @public
162
+ */
163
+ interface WebhookEventDataMap {
164
+ "session.created": Record<string, unknown>;
165
+ "session.started": Record<string, unknown>;
166
+ "session.step.completed": SessionStepCompletedEventData;
167
+ "session.review_pending": SessionLifecycleEventData;
168
+ "session.review_decided": SessionReviewDecidedEventData;
169
+ "session.completed": SessionLifecycleEventData;
170
+ "session.rejected": SessionLifecycleEventData;
171
+ "session.expired": SessionLifecycleEventData;
172
+ "session.failed": SessionLifecycleEventData;
173
+ "subject.enrolled": Record<string, unknown>;
174
+ "subject.deleted": Record<string, unknown>;
175
+ "aml.match_flagged": Record<string, unknown>;
176
+ "payment.received": Record<string, unknown>;
177
+ "invoice.generated": Record<string, unknown>;
178
+ "invoice.paid": Record<string, unknown>;
179
+ "invoice.overdue": Record<string, unknown>;
180
+ "webhook.test": Record<string, unknown>;
181
+ }
182
+ /** Every event a partner endpoint may receive. @public */
183
+ type WebhookEventType = keyof WebhookEventDataMap;
184
+ /**
185
+ * The signed webhook payload for ONE event type. There is no global API
186
+ * version; each event carries its own per-event `version`. Retried deliveries
187
+ * keep the same `id` — de-duplicate on it.
188
+ * @public
189
+ */
190
+ interface WebhookEnvelopeOf<E extends WebhookEventType> {
191
+ /** UUIDv7, stable across delivery retries. */
192
+ id: string;
193
+ event: E;
194
+ /** Per-event payload version. */
195
+ version: number;
196
+ /** ISO 8601. */
197
+ createdAt: string;
198
+ orgId: string;
199
+ env: SekyroEnvironment;
200
+ data: WebhookEventDataMap[E];
201
+ }
202
+ /**
203
+ * The signed webhook payload, discriminated on `event`: narrowing on
204
+ * `envelope.event` narrows `envelope.data` to that event's shape.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * const envelope = verifyWebhook(rawBody, header, secret);
209
+ * switch (envelope.event) {
210
+ * case "session.completed":
211
+ * envelope.data.outcome; // SessionOutcome | null | undefined
212
+ * break;
213
+ * case "session.review_decided":
214
+ * envelope.data.outcome; // "pass" | "fail"
215
+ * break;
216
+ * }
217
+ * ```
218
+ * @public
219
+ */
220
+ type WebhookEnvelope<E extends WebhookEventType = WebhookEventType> = E extends WebhookEventType ? WebhookEnvelopeOf<E> : never;
221
+ /**
222
+ * `data` of any `session.*` event.
223
+ * @deprecated Use {@link SessionLifecycleEventData}, {@link SessionStepCompletedEventData}
224
+ * or {@link SessionReviewDecidedEventData}, or narrow a {@link WebhookEnvelope} on `event`.
225
+ * @public
226
+ */
227
+ type SessionEventData = SessionLifecycleEventData | SessionStepCompletedEventData | SessionReviewDecidedEventData;
228
+
229
+ /** Default partner API origin + version prefix. @public */
230
+ declare const DEFAULT_BASE_URL = "https://api.sekyro.ai/api/v2";
231
+ /** Default per-request timeout. @public */
232
+ declare const DEFAULT_TIMEOUT_MS = 30000;
233
+ /** `fetch`-compatible function; the global `fetch` by default. @public */
234
+ type FetchLike = typeof globalThis.fetch;
235
+ /** Constructor options of {@link Sekyro}. @public */
236
+ interface SekyroOptions {
237
+ /** Partner API key (server-side only). Sent as `Authorization: Bearer`. */
238
+ apiKey: string;
239
+ /** API base URL incl. the `/api/v2` prefix. Default {@link DEFAULT_BASE_URL}. */
240
+ baseUrl?: string;
241
+ /** Per-request timeout in ms. Default {@link DEFAULT_TIMEOUT_MS}. */
242
+ timeoutMs?: number;
243
+ /** Custom `fetch` (tests, proxies, custom agents). Default `globalThis.fetch`. */
244
+ fetch?: FetchLike;
245
+ }
246
+ /**
247
+ * `sekyro.sessions` — create and read verification sessions.
248
+ *
249
+ * Exported as a TYPE only (`import type { SessionsApi }`): the class value is
250
+ * not part of the package surface, so it can only be obtained through
251
+ * {@link Sekyro.sessions} — never constructed by hand with an unchecked
252
+ * transport.
253
+ * @public
254
+ */
255
+ declare class SessionsApi {
256
+ private readonly call;
257
+ /**
258
+ * Constructed by {@link Sekyro}; the transport is kept out of the public
259
+ * surface on purpose (only its bound `call` crosses this boundary).
260
+ * @internal
261
+ */
262
+ constructor(call: <T>(method: "GET" | "POST", path: string, body?: unknown, idempotencyKey?: string) => Promise<T>);
263
+ /**
264
+ * `POST /sessions`. Returns the one-time `clientToken` — hand it to the
265
+ * browser, never persist it. Pass an `idempotencyKey` so a retried call
266
+ * cannot create a second session.
267
+ */
268
+ create(input?: CreateSessionInput, opts?: RequestOptions): Promise<CreatedSession>;
269
+ /** `GET /sessions/:id` — the whitelisted view (no client token). */
270
+ get(id: string): Promise<Session>;
271
+ }
272
+ /**
273
+ * Sekyro partner API client. Server-side only: it carries your API key.
274
+ *
275
+ * Throws {@link SekyroConfigError} synchronously when `apiKey` is missing or
276
+ * `baseUrl` is not `https:` (plain `http:` is accepted for `localhost`,
277
+ * `127.0.0.1` and `[::1]` only).
278
+ *
279
+ * @example
280
+ * ```ts
281
+ * const sekyro = new Sekyro({ apiKey: process.env.SEKYRO_API_KEY! });
282
+ * const { id, clientToken } = await sekyro.sessions.create({ input: { nni: "1357924632" } });
283
+ * ```
284
+ * @public
285
+ */
286
+ declare class Sekyro {
287
+ readonly sessions: SessionsApi;
288
+ constructor(opts: SekyroOptions);
289
+ }
290
+
291
+ /** Why {@link verifyWebhook} rejected a delivery. @public */
292
+ type SekyroWebhookErrorCode = "invalid_signature" | "stale" | "malformed";
293
+ /**
294
+ * Thrown by {@link verifyWebhook}. Respond with a 4xx and do not process the
295
+ * body; Sekyro retries deliveries that do not receive a 2xx.
296
+ * @public
297
+ */
298
+ declare class SekyroWebhookError extends Error {
299
+ readonly name = "SekyroWebhookError";
300
+ readonly code: SekyroWebhookErrorCode;
301
+ constructor(code: SekyroWebhookErrorCode, message?: string);
302
+ }
303
+ /** Why the {@link Sekyro} constructor rejected its options. @public */
304
+ type SekyroConfigErrorCode = "missing_api_key" | "invalid_base_url";
305
+ /**
306
+ * Thrown synchronously by `new Sekyro(...)` for an unusable configuration —
307
+ * before any request is made. Extends `TypeError` so existing
308
+ * `instanceof TypeError` guards keep working.
309
+ * @public
310
+ */
311
+ declare class SekyroConfigError extends TypeError {
312
+ readonly name = "SekyroConfigError";
313
+ readonly code: SekyroConfigErrorCode;
314
+ constructor(code: SekyroConfigErrorCode, message?: string);
315
+ }
316
+ /** Constructor input of {@link SekyroApiError}. @public */
317
+ interface SekyroApiErrorInit {
318
+ /** HTTP status; `0` when no response was received (network / timeout). */
319
+ status: number;
320
+ /** API error code, or `network` | `timeout` | `http_error` | `malformed_response`. */
321
+ code: string;
322
+ message: string;
323
+ /** The API's `request_id`, when the response carried one. */
324
+ requestId?: string;
325
+ /** Underlying error (network failures, abort reasons, JSON parse errors). */
326
+ cause?: unknown;
327
+ }
328
+ /** Error codes the SDK itself produces (as opposed to codes from the API). @public */
329
+ type SekyroClientErrorCode = "network" | "timeout" | "http_error" | "malformed_response";
330
+ /**
331
+ * Any failed call through {@link Sekyro}: a non-2xx partner envelope
332
+ * `{ status: 'error', error: { code, message, request_id } }`, a non-JSON
333
+ * response, a network failure or a timeout.
334
+ *
335
+ * `retryable` is `true` for `network`, `timeout`, HTTP 429, HTTP 5xx and the
336
+ * `idempotency_in_progress` code — retry with the SAME idempotency key.
337
+ * @public
338
+ */
339
+ declare class SekyroApiError extends Error {
340
+ readonly name = "SekyroApiError";
341
+ readonly status: number;
342
+ readonly code: string;
343
+ readonly requestId: string | undefined;
344
+ readonly retryable: boolean;
345
+ constructor(init: SekyroApiErrorInit);
346
+ }
347
+
348
+ /**
349
+ * The HTTP header carrying the webhook signature:
350
+ * `t=<unix seconds>,v1=<hex>[,v1=<hex>]`.
351
+ * @public
352
+ */
353
+ declare const SEKYRO_SIGNATURE_HEADER = "x-sekyro-signature";
354
+ /**
355
+ * The pre-rebrand name of the signature header. Sekyro sends BOTH headers with
356
+ * the same value during the transition; prefer {@link SEKYRO_SIGNATURE_HEADER}
357
+ * and use {@link signatureFromHeaders} to accept either.
358
+ * @public
359
+ */
360
+ declare const LEGACY_SIGNATURE_HEADER = "x-ibtikar-signature";
361
+ /**
362
+ * Pick the signature header out of an incoming request's headers: the
363
+ * `x-sekyro-signature` value when present, else the legacy
364
+ * `x-ibtikar-signature`, else `undefined`. Accepts a Fetch `Headers` object or
365
+ * a Node-style plain record (lower- or mixed-case keys; a multi-valued header
366
+ * yields its first value).
367
+ *
368
+ * @example
369
+ * ```ts
370
+ * verifyWebhook(rawBody, signatureFromHeaders(req.headers), secret);
371
+ * ```
372
+ * @public
373
+ */
374
+ declare function signatureFromHeaders(headers: Headers | Record<string, string | string[] | undefined>): string | undefined;
375
+ /** Options of {@link verifyWebhook}. @public */
376
+ interface VerifyWebhookOptions {
377
+ /** Max |now − t| in seconds before a delivery is rejected as `stale`. Default 300. */
378
+ toleranceSec?: number;
379
+ /** Clock returning epoch milliseconds. Default `Date.now`. */
380
+ now?: () => number;
381
+ }
382
+ /**
383
+ * Verify a Sekyro webhook delivery and return its parsed envelope, discriminated
384
+ * on `event` (narrow with a `switch` to type `data`).
385
+ *
386
+ * - `rawBody` MUST be the request body exactly as received (before any JSON
387
+ * parsing / re-serialisation); the signature covers those bytes.
388
+ * - Accepts the delivery when ANY `v1` in the header matches, which is what
389
+ * makes secret rotation seamless (Sekyro signs with current + previous).
390
+ * - Uses `node:crypto` only; the digest compare is constant-time.
391
+ *
392
+ * Checks run in this order: header/secret present → header well-formed →
393
+ * timestamp within tolerance → signature → body is a JSON object.
394
+ *
395
+ * @param rawBody - The raw UTF-8 request body.
396
+ * @param header - The `x-sekyro-signature` header value (or the legacy
397
+ * `x-ibtikar-signature` — same format; see {@link signatureFromHeaders}).
398
+ * @param secret - The endpoint secret you hold (current or previous).
399
+ * @throws {@link SekyroWebhookError} with `code` `invalid_signature` | `stale` | `malformed`.
400
+ * @public
401
+ */
402
+ declare function verifyWebhook(rawBody: string, header: string | null | undefined, secret: string, opts?: VerifyWebhookOptions): WebhookEnvelope;
403
+
404
+ export { type CheckState, type CheckType, type CreateSessionInput, type CreatedSession, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, type DeliverOptions, type FetchLike, LEGACY_SIGNATURE_HEADER, type Locale, type RequestOptions, SEKYRO_SIGNATURE_HEADER, Sekyro, SekyroApiError, type SekyroApiErrorInit, type SekyroClientErrorCode, SekyroConfigError, type SekyroConfigErrorCode, type SekyroEnvironment, type SekyroOptions, SekyroWebhookError, type SekyroWebhookErrorCode, type Session, type SessionEventData, type SessionLifecycleEventData, type SessionOutcome, type SessionReviewDecidedEventData, type SessionStatus, type SessionStep, type SessionStepCompletedEventData, SessionsApi, type VerifyWebhookOptions, type WebhookEnvelope, type WebhookEnvelopeOf, type WebhookEventDataMap, type WebhookEventType, signatureFromHeaders, verifyWebhook };
package/dist/index.js ADDED
@@ -0,0 +1,272 @@
1
+ // src/errors.ts
2
+ var WEBHOOK_MESSAGES = {
3
+ invalid_signature: "No v1 signature in the header matches this body and secret",
4
+ stale: "Delivery timestamp is outside the tolerance window",
5
+ malformed: "Signature header or body is not in the expected format"
6
+ };
7
+ var SekyroWebhookError = class extends Error {
8
+ name = "SekyroWebhookError";
9
+ code;
10
+ constructor(code, message = WEBHOOK_MESSAGES[code]) {
11
+ super(message);
12
+ this.code = code;
13
+ }
14
+ };
15
+ var CONFIG_MESSAGES = {
16
+ missing_api_key: "Sekyro: `apiKey` is required",
17
+ invalid_base_url: "Sekyro: `baseUrl` must be an https: URL (http: is allowed only for localhost, 127.0.0.1 or [::1])"
18
+ };
19
+ var SekyroConfigError = class extends TypeError {
20
+ name = "SekyroConfigError";
21
+ code;
22
+ constructor(code, message = CONFIG_MESSAGES[code]) {
23
+ super(message);
24
+ this.code = code;
25
+ }
26
+ };
27
+ var SekyroApiError = class extends Error {
28
+ name = "SekyroApiError";
29
+ status;
30
+ code;
31
+ requestId;
32
+ retryable;
33
+ constructor(init) {
34
+ super(init.message, init.cause === void 0 ? void 0 : { cause: init.cause });
35
+ this.status = init.status;
36
+ this.code = init.code;
37
+ this.requestId = init.requestId;
38
+ this.retryable = isRetryable(init.status, init.code);
39
+ }
40
+ };
41
+ function isRetryable(status, code) {
42
+ if (code === "network" || code === "timeout") return true;
43
+ if (code === "idempotency_in_progress") return true;
44
+ if (status === 429) return true;
45
+ return status >= 500 && status <= 599;
46
+ }
47
+
48
+ // src/version.ts
49
+ var SDK_VERSION = true ? "0.1.0-rc.2" : "0.0.0-dev";
50
+
51
+ // src/client.ts
52
+ var USER_AGENT = `@sekyro/node/${SDK_VERSION}`;
53
+ var DEFAULT_BASE_URL = "https://api.sekyro.ai/api/v2";
54
+ var DEFAULT_TIMEOUT_MS = 3e4;
55
+ function isSuccessEnvelope(value) {
56
+ return typeof value === "object" && value !== null && value.success === true && "data" in value;
57
+ }
58
+ function isErrorEnvelope(value) {
59
+ if (typeof value !== "object" || value === null) return false;
60
+ const v = value;
61
+ if (v.status !== "error" || typeof v.error !== "object" || v.error === null) return false;
62
+ const e = v.error;
63
+ return typeof e.code === "string" && typeof e.message === "string";
64
+ }
65
+ function isAbortLike(err) {
66
+ if (typeof err !== "object" || err === null) return false;
67
+ const name = err.name;
68
+ return name === "TimeoutError" || name === "AbortError";
69
+ }
70
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
71
+ function assertSafeBaseUrl(baseUrl) {
72
+ let url;
73
+ try {
74
+ url = new URL(baseUrl);
75
+ } catch {
76
+ throw new SekyroConfigError("invalid_base_url");
77
+ }
78
+ if (url.protocol === "https:") return;
79
+ if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) return;
80
+ throw new SekyroConfigError("invalid_base_url");
81
+ }
82
+ var Transport = class {
83
+ apiKey;
84
+ baseUrl;
85
+ timeoutMs;
86
+ fetchImpl;
87
+ constructor(opts) {
88
+ const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
89
+ assertSafeBaseUrl(baseUrl);
90
+ this.apiKey = opts.apiKey;
91
+ this.baseUrl = baseUrl;
92
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
93
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
94
+ }
95
+ async call(method, path, body, idempotencyKey) {
96
+ const headers = {
97
+ authorization: `Bearer ${this.apiKey}`,
98
+ accept: "application/json",
99
+ "user-agent": USER_AGENT
100
+ };
101
+ if (body !== void 0) headers["content-type"] = "application/json";
102
+ if (idempotencyKey !== void 0) headers["x-idempotency-key"] = idempotencyKey;
103
+ const init = {
104
+ method,
105
+ headers,
106
+ signal: AbortSignal.timeout(this.timeoutMs)
107
+ };
108
+ if (body !== void 0) init.body = JSON.stringify(body);
109
+ let res;
110
+ try {
111
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
112
+ } catch (err) {
113
+ if (isAbortLike(err)) {
114
+ throw new SekyroApiError({
115
+ status: 0,
116
+ code: "timeout",
117
+ message: `Request timed out after ${this.timeoutMs}ms`,
118
+ cause: err
119
+ });
120
+ }
121
+ throw new SekyroApiError({
122
+ status: 0,
123
+ code: "network",
124
+ message: `Network error: ${err instanceof Error ? err.message : String(err)}`,
125
+ cause: err
126
+ });
127
+ }
128
+ const text = await res.text();
129
+ let parsed;
130
+ let parseError;
131
+ try {
132
+ parsed = text.length > 0 ? JSON.parse(text) : void 0;
133
+ } catch (err) {
134
+ parseError = err;
135
+ }
136
+ if (!res.ok) {
137
+ if (isErrorEnvelope(parsed)) {
138
+ const init2 = {
139
+ status: res.status,
140
+ code: parsed.error.code,
141
+ message: parsed.error.message
142
+ };
143
+ if (parsed.error.request_id !== void 0) init2.requestId = parsed.error.request_id;
144
+ throw new SekyroApiError(init2);
145
+ }
146
+ throw new SekyroApiError({
147
+ status: res.status,
148
+ code: "http_error",
149
+ message: `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`
150
+ });
151
+ }
152
+ if (!isSuccessEnvelope(parsed)) {
153
+ throw new SekyroApiError({
154
+ status: res.status,
155
+ code: "malformed_response",
156
+ message: "Response is not a `{ success: true, data }` envelope",
157
+ cause: parseError
158
+ });
159
+ }
160
+ return parsed.data;
161
+ }
162
+ };
163
+ var SessionsApi = class {
164
+ /**
165
+ * Constructed by {@link Sekyro}; the transport is kept out of the public
166
+ * surface on purpose (only its bound `call` crosses this boundary).
167
+ * @internal
168
+ */
169
+ constructor(call) {
170
+ this.call = call;
171
+ }
172
+ call;
173
+ /**
174
+ * `POST /sessions`. Returns the one-time `clientToken` — hand it to the
175
+ * browser, never persist it. Pass an `idempotencyKey` so a retried call
176
+ * cannot create a second session.
177
+ */
178
+ async create(input = {}, opts = {}) {
179
+ const wire = await this.call("POST", "/sessions", input, opts.idempotencyKey);
180
+ return {
181
+ id: wire.id,
182
+ status: wire.status,
183
+ clientToken: wire.client_token,
184
+ hostedUrl: wire.hosted_url,
185
+ steps: wire.steps,
186
+ expiresAt: wire.expiresAt
187
+ };
188
+ }
189
+ /** `GET /sessions/:id` — the whitelisted view (no client token). */
190
+ async get(id) {
191
+ if (typeof id !== "string" || id.length === 0) {
192
+ throw new TypeError("sessions.get: `id` must be a non-empty string");
193
+ }
194
+ return this.call("GET", `/sessions/${encodeURIComponent(id)}`);
195
+ }
196
+ };
197
+ var Sekyro = class {
198
+ sessions;
199
+ constructor(opts) {
200
+ if (typeof opts?.apiKey !== "string" || opts.apiKey.length === 0) {
201
+ throw new SekyroConfigError("missing_api_key");
202
+ }
203
+ const transport = new Transport(opts);
204
+ this.sessions = new SessionsApi(transport.call.bind(transport));
205
+ }
206
+ };
207
+
208
+ // src/webhooks.ts
209
+ import { createHmac, timingSafeEqual } from "crypto";
210
+ var SEKYRO_SIGNATURE_HEADER = "x-sekyro-signature";
211
+ var LEGACY_SIGNATURE_HEADER = "x-ibtikar-signature";
212
+ function signatureFromHeaders(headers) {
213
+ return readHeader(headers, SEKYRO_SIGNATURE_HEADER) ?? readHeader(headers, LEGACY_SIGNATURE_HEADER);
214
+ }
215
+ function readHeader(headers, name) {
216
+ if (typeof headers.get === "function") {
217
+ const v2 = headers.get(name);
218
+ return v2 === null || v2 === "" ? void 0 : v2;
219
+ }
220
+ const record = headers;
221
+ const key = Object.keys(record).find((k) => k.toLowerCase() === name);
222
+ if (key === void 0) return void 0;
223
+ const raw = record[key];
224
+ const v = Array.isArray(raw) ? raw[0] : raw;
225
+ return v === void 0 || v === "" ? void 0 : v;
226
+ }
227
+ var HEX64 = /^[0-9a-f]{64}$/i;
228
+ function verifyWebhook(rawBody, header, secret, opts = {}) {
229
+ if (!header || !secret) throw new SekyroWebhookError("invalid_signature");
230
+ const parts = header.split(",").map((part) => {
231
+ const trimmed = part.trim();
232
+ const eq = trimmed.indexOf("=");
233
+ return eq === -1 ? [trimmed, ""] : [trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1).trim()];
234
+ });
235
+ const tRaw = parts.find(([k]) => k === "t")?.[1];
236
+ const t = tRaw !== void 0 && /^\d+$/.test(tRaw) ? Number(tRaw) : Number.NaN;
237
+ const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
238
+ if (!Number.isSafeInteger(t) || sigs.length === 0) throw new SekyroWebhookError("malformed");
239
+ const nowSec = Math.floor((opts.now?.() ?? Date.now()) / 1e3);
240
+ const tolerance = opts.toleranceSec ?? 300;
241
+ if (Math.abs(nowSec - t) > tolerance) throw new SekyroWebhookError("stale");
242
+ const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
243
+ const ok = sigs.some((hex) => {
244
+ if (!HEX64.test(hex)) return false;
245
+ const got = Buffer.from(hex, "hex");
246
+ return got.length === expected.length && timingSafeEqual(got, expected);
247
+ });
248
+ if (!ok) throw new SekyroWebhookError("invalid_signature");
249
+ let parsed;
250
+ try {
251
+ parsed = JSON.parse(rawBody);
252
+ } catch {
253
+ throw new SekyroWebhookError("malformed", "Signed body is not valid JSON");
254
+ }
255
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
256
+ throw new SekyroWebhookError("malformed", "Signed body is not a JSON object");
257
+ }
258
+ return parsed;
259
+ }
260
+ export {
261
+ DEFAULT_BASE_URL,
262
+ DEFAULT_TIMEOUT_MS,
263
+ LEGACY_SIGNATURE_HEADER,
264
+ SEKYRO_SIGNATURE_HEADER,
265
+ Sekyro,
266
+ SekyroApiError,
267
+ SekyroConfigError,
268
+ SekyroWebhookError,
269
+ signatureFromHeaders,
270
+ verifyWebhook
271
+ };
272
+ //# sourceMappingURL=index.js.map