@touchque/node 1.3.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.mjs ADDED
@@ -0,0 +1,756 @@
1
+ // src/core/HttpClient.ts
2
+ import axios from "axios";
3
+ import * as crypto from "crypto";
4
+
5
+ // src/errors.ts
6
+ var TouchQueError = class extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "TouchQueError";
10
+ }
11
+ };
12
+ var TouchQueAPIError = class extends TouchQueError {
13
+ status;
14
+ code;
15
+ data;
16
+ constructor(status, data) {
17
+ const message = data.message || data.error || `TouchQue API Error: HTTP ${status}`;
18
+ super(message);
19
+ this.name = "TouchQueAPIError";
20
+ this.status = status;
21
+ this.code = data.code;
22
+ this.data = data;
23
+ }
24
+ };
25
+ var TouchQueTimeoutError = class extends TouchQueError {
26
+ requestId;
27
+ constructor(requestId, timeoutMs) {
28
+ super(
29
+ `TouchQue: Request '${requestId}' was not approved within ${timeoutMs / 1e3}s. The user did not respond in time.`
30
+ );
31
+ this.name = "TouchQueTimeoutError";
32
+ this.requestId = requestId;
33
+ }
34
+ };
35
+ var TouchQueRejectedError = class extends TouchQueError {
36
+ requestId;
37
+ constructor(requestId) {
38
+ super(
39
+ `TouchQue: Request '${requestId}' was rejected by the user.`
40
+ );
41
+ this.name = "TouchQueRejectedError";
42
+ this.requestId = requestId;
43
+ }
44
+ };
45
+ var TouchQueWebhookSignatureError = class extends TouchQueError {
46
+ constructor() {
47
+ super(
48
+ "TouchQue: Webhook signature verification failed. This request did not come from TouchQue or the payload was tampered."
49
+ );
50
+ this.name = "TouchQueWebhookSignatureError";
51
+ }
52
+ };
53
+ var TouchQueConfigError = class extends TouchQueError {
54
+ constructor(message) {
55
+ super(`TouchQue Config Error: ${message}`);
56
+ this.name = "TouchQueConfigError";
57
+ }
58
+ };
59
+
60
+ // src/core/HttpClient.ts
61
+ var TEN_MB = 10 * 1024 * 1024;
62
+ function assertSafeBaseUrl(baseUrl) {
63
+ let url;
64
+ try {
65
+ url = new URL(baseUrl);
66
+ } catch {
67
+ throw new TouchQueConfigError(`baseUrl is not a valid URL: ${baseUrl}`);
68
+ }
69
+ if (url.protocol === "https:") return;
70
+ if (url.protocol === "http:") {
71
+ const host = url.hostname.toLowerCase();
72
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]") return;
73
+ throw new TouchQueConfigError(
74
+ `Refusing to use a plaintext http:// baseUrl for "${url.hostname}". The API key and request signature would be sent in the clear \u2014 use https://.`
75
+ );
76
+ }
77
+ throw new TouchQueConfigError(`baseUrl must be http(s): ${baseUrl}`);
78
+ }
79
+ function buildQuery(params) {
80
+ if (!params) return "";
81
+ const keys = Object.keys(params).filter((k) => params[k] !== void 0 && params[k] !== null);
82
+ if (keys.length === 0) return "";
83
+ keys.sort();
84
+ const parts = keys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(params[k]))}`);
85
+ return `?${parts.join("&")}`;
86
+ }
87
+ var HttpClient = class {
88
+ client;
89
+ apiKey;
90
+ apiSecret;
91
+ constructor(config) {
92
+ if (!config.apiKey || typeof config.apiKey !== "string") {
93
+ throw new TouchQueConfigError("apiKey is required and must be a string");
94
+ }
95
+ if (!config.apiSecret || typeof config.apiSecret !== "string") {
96
+ throw new TouchQueConfigError("apiSecret is required and must be a string");
97
+ }
98
+ if (!config.apiKey.startsWith("tq_")) {
99
+ throw new TouchQueConfigError(
100
+ 'apiKey must start with "tq_". Did you accidentally swap apiKey and apiSecret?'
101
+ );
102
+ }
103
+ this.apiKey = config.apiKey;
104
+ this.apiSecret = config.apiSecret;
105
+ const baseURL = config.baseUrl || "https://api-authenticator.touchque.com";
106
+ assertSafeBaseUrl(baseURL);
107
+ this.client = axios.create({
108
+ baseURL,
109
+ timeout: config.timeout || 1e4,
110
+ headers: { "Content-Type": "application/json" },
111
+ // An API endpoint should never redirect; a redirect to another host
112
+ // could leak the signed auth headers.
113
+ maxRedirects: 0,
114
+ // Cap the response/request size so a misbehaving or spoofed endpoint
115
+ // can't exhaust memory.
116
+ maxContentLength: TEN_MB,
117
+ maxBodyLength: TEN_MB
118
+ });
119
+ }
120
+ /**
121
+ * Canonical signature.
122
+ * Format: HMAC(apiSecret, "METHOD:pathWithQuery:timestamp:nonce:bodyHash")
123
+ *
124
+ * `pathWithQuery` includes the sorted query string so GET/DELETE query
125
+ * params are covered by the signature (older SDKs signed the path only;
126
+ * the backend accepts either form).
127
+ */
128
+ sign(method, pathWithQuery, body, timestamp, nonce) {
129
+ const bodyHash = crypto.createHash("sha256").update(body || "").digest("hex");
130
+ const message = `${method.toUpperCase()}:${pathWithQuery}:${timestamp}:${nonce}:${bodyHash}`;
131
+ return crypto.createHmac("sha256", this.apiSecret).update(message).digest("hex");
132
+ }
133
+ generateNonce() {
134
+ return crypto.randomBytes(16).toString("hex");
135
+ }
136
+ authHeaders(method, pathWithQuery, body) {
137
+ const timestamp = Date.now().toString();
138
+ const nonce = this.generateNonce();
139
+ return {
140
+ "x-api-key": this.apiKey,
141
+ "x-signature": this.sign(method, pathWithQuery, body, timestamp, nonce),
142
+ "x-timestamp": timestamp,
143
+ "x-nonce": nonce
144
+ };
145
+ }
146
+ async post(path, body = {}) {
147
+ const bodyStr = JSON.stringify(body);
148
+ try {
149
+ const response = await this.client.post(path, bodyStr, {
150
+ headers: this.authHeaders("POST", path, bodyStr)
151
+ });
152
+ return response.data;
153
+ } catch (error) {
154
+ throw this.handleError(error);
155
+ }
156
+ }
157
+ async get(path, params) {
158
+ const query = buildQuery(params);
159
+ try {
160
+ const response = await this.client.get(path + query, {
161
+ headers: this.authHeaders("GET", path + query, "")
162
+ });
163
+ return response.data;
164
+ } catch (error) {
165
+ throw this.handleError(error);
166
+ }
167
+ }
168
+ async delete(path) {
169
+ try {
170
+ const response = await this.client.delete(path, {
171
+ headers: this.authHeaders("DELETE", path, "")
172
+ });
173
+ return response.data;
174
+ } catch (error) {
175
+ throw this.handleError(error);
176
+ }
177
+ }
178
+ handleError(error) {
179
+ if (axios.isAxiosError(error)) {
180
+ const axiosErr = error;
181
+ const status = axiosErr.response?.status || 500;
182
+ const data = axiosErr.response?.data || {};
183
+ return new TouchQueAPIError(status, {
184
+ error: data.error,
185
+ message: data.message,
186
+ code: data.code
187
+ });
188
+ }
189
+ return new TouchQueAPIError(500, {
190
+ message: error instanceof Error ? error.message : "Unknown error"
191
+ });
192
+ }
193
+ };
194
+
195
+ // src/resources/Auth.ts
196
+ var Auth = class {
197
+ constructor(http) {
198
+ this.http = http;
199
+ }
200
+ http;
201
+ /**
202
+ * Generate a new setup secret for a user.
203
+ * Show this secret to the user ONCE (e.g. as a QR code) so they can
204
+ * link their TouchQue app.
205
+ *
206
+ * The secret expires after 60 seconds if not used (scanned by the mobile app).
207
+ * After expiry, calling this method again will automatically rotate the secret.
208
+ *
209
+ * @example
210
+ * const { secret, expiresAt } = await tq.auth.generateSecret({
211
+ * externalUsername: 'user@company.com'
212
+ * });
213
+ * // Show `secret` to user as QR code
214
+ * // Secret expires at `expiresAt` if not scanned
215
+ */
216
+ async generateSecret(options) {
217
+ return this.http.post("/auth/generate-secret", {
218
+ externalUsername: options.externalUsername
219
+ });
220
+ }
221
+ /**
222
+ * Reset (regenerate) a user's secret.
223
+ * This invalidates the old secret and generates a new one.
224
+ * Show the new secret to the user.
225
+ *
226
+ * @example
227
+ * const { secret } = await tq.auth.resetSecret({
228
+ * externalUsername: 'user@company.com'
229
+ * });
230
+ * // Show new secret to user
231
+ */
232
+ async resetSecret(options) {
233
+ return this.http.post("/auth/secret/reset", {
234
+ externalUsername: options.externalUsername
235
+ });
236
+ }
237
+ /**
238
+ * Validate a secret code.
239
+ * Useful if you want to verify the user entered the correct setup secret.
240
+ * Note: The backend allows this without an API key, but the SDK sends it for consistency.
241
+ */
242
+ async validateSecret(options) {
243
+ return this.http.post("/auth/secret/validate", {
244
+ secret: options.secret
245
+ });
246
+ }
247
+ /**
248
+ * Look up a user's current link status — no push notification is sent.
249
+ * Useful for detecting "the mobile app just linked this secret" (`used`
250
+ * flips true, `deviceId` gets set) without sending a LOGIN challenge to
251
+ * their device, e.g. while polling during enrollment.
252
+ *
253
+ * @example
254
+ * const { used, deviceId } = await tq.auth.getUser({
255
+ * externalUsername: 'user@company.com'
256
+ * });
257
+ * if (used && deviceId) {
258
+ * // Device has linked — no approval push needed to confirm this.
259
+ * }
260
+ */
261
+ async getUser(options) {
262
+ return this.http.get(`/users/${encodeURIComponent(options.externalUsername)}`);
263
+ }
264
+ };
265
+
266
+ // src/resources/Login.ts
267
+ var Login = class {
268
+ constructor(http) {
269
+ this.http = http;
270
+ }
271
+ http;
272
+ /**
273
+ * Request 2FA confirmation from a user's TouchQue app.
274
+ *
275
+ * The user will receive a push notification on their device.
276
+ * They can then approve or reject the request.
277
+ *
278
+ * Built-in `type` values:
279
+ * - `'LOGIN'` — User is logging in
280
+ * - `'DISABLE_2FA'` — User is disabling 2FA
281
+ * - Custom action type IDs or slugs configured in your TouchQue Dashboard
282
+ *
283
+ * @example
284
+ * // Basic login verification
285
+ * const response = await tq.login.request({
286
+ * externalUsername: 'user@company.com',
287
+ * type: 'LOGIN'
288
+ * });
289
+ *
290
+ * @example
291
+ * // Protect a custom action using its Dashboard-configured ID or slug
292
+ * const response = await tq.login.request({
293
+ * externalUsername: 'user@company.com',
294
+ * type: 'action_type_slug_or_id',
295
+ * referenceId: 'txn_abc123' // Your internal transaction ID
296
+ * });
297
+ */
298
+ async request(options) {
299
+ return this.http.post("/login/request", {
300
+ externalUsername: options.externalUsername,
301
+ type: options.type,
302
+ ...options.referenceId && { referenceId: options.referenceId },
303
+ ...options.clientIp && { clientIp: options.clientIp },
304
+ ...options.userAgent && { userAgent: options.userAgent },
305
+ ...options.requireBiometric && { requireBiometric: options.requireBiometric },
306
+ ...options.requireNumberMatch && { requireNumberMatch: options.requireNumberMatch }
307
+ });
308
+ }
309
+ /**
310
+ * Check the current status of a login request.
311
+ *
312
+ * Returns: `'PENDING'` | `'CONFIRMED'` | `'REJECTED'` | `'EXPIRED'`
313
+ *
314
+ * @example
315
+ * const { status } = await tq.login.status('request_id_here');
316
+ * if (status === 'CONFIRMED') {
317
+ * // User approved — proceed with action
318
+ * }
319
+ */
320
+ async status(requestId) {
321
+ return this.http.get(`/login/status/${encodeURIComponent(requestId)}`);
322
+ }
323
+ /**
324
+ * Wait for the user to approve or reject a login request.
325
+ *
326
+ * This is a convenience method that polls the status endpoint
327
+ * until the request is approved, rejected, or times out.
328
+ *
329
+ * **This is the recommended way to integrate TouchQue into your routes.**
330
+ *
331
+ * @throws {TouchQueRejectedError} If the user rejects the request
332
+ * @throws {TouchQueTimeoutError} If the request times out (user didn't respond)
333
+ *
334
+ * @example
335
+ * // Simple: Send request and wait for approval in one step
336
+ * try {
337
+ * const loginReq = await tq.login.request({
338
+ * externalUsername: 'user@company.com',
339
+ * type: 'LOGIN'
340
+ * });
341
+ *
342
+ * const result = await tq.login.waitForApproval({
343
+ * requestId: loginReq.requestId,
344
+ * timeout: 30000 // Wait up to 30 seconds
345
+ * });
346
+ *
347
+ * if (result.approved) {
348
+ * // ✅ User approved — proceed
349
+ * proceedWithAction();
350
+ * }
351
+ * } catch (err) {
352
+ * if (err instanceof TouchQueRejectedError) {
353
+ * // ❌ User rejected — cancel the action
354
+ * } else if (err instanceof TouchQueTimeoutError) {
355
+ * // ⏰ User didn't respond in time
356
+ * }
357
+ * }
358
+ */
359
+ async waitForApproval(options) {
360
+ const timeout = options.timeout || 3e4;
361
+ const pollInterval = options.pollInterval || 400;
362
+ const startTime = Date.now();
363
+ while (Date.now() - startTime < timeout) {
364
+ const { status } = await this.status(options.requestId);
365
+ if (status === "CONFIRMED") {
366
+ return { approved: true, status };
367
+ }
368
+ if (status === "REJECTED") {
369
+ throw new TouchQueRejectedError(options.requestId);
370
+ }
371
+ if (status === "EXPIRED") {
372
+ throw new TouchQueTimeoutError(options.requestId, timeout);
373
+ }
374
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
375
+ }
376
+ throw new TouchQueTimeoutError(options.requestId, timeout);
377
+ }
378
+ /**
379
+ * Convenience: Send a 2FA request AND wait for approval in one call.
380
+ *
381
+ * Perfect for protecting any action with a single function call.
382
+ *
383
+ * @throws {TouchQueRejectedError} User rejected
384
+ * @throws {TouchQueTimeoutError} User didn't respond in time
385
+ *
386
+ * @example
387
+ * // Protect an action with 2FA — ONE LINE!
388
+ * const result = await tq.login.verify({
389
+ * externalUsername: 'user@company.com',
390
+ * type: 'action_type_slug_or_id',
391
+ * referenceId: 'action_789'
392
+ * });
393
+ * // If we reach here, user approved ✅
394
+ * executeAction();
395
+ */
396
+ async verify(options) {
397
+ const { timeout, pollInterval, ...requestOptions } = options;
398
+ const loginReq = await this.request(requestOptions);
399
+ const result = await this.waitForApproval({
400
+ requestId: loginReq.requestId,
401
+ timeout,
402
+ pollInterval
403
+ });
404
+ return {
405
+ ...result,
406
+ requestId: loginReq.requestId,
407
+ challengeCode: loginReq.challengeCode
408
+ };
409
+ }
410
+ /**
411
+ * Approve a pending 2FA request using a Recovery Code.
412
+ * Useful when a user loses their phone and needs to bypass 2FA
413
+ * using one of the backup codes generated during setup.
414
+ */
415
+ async approveWithRecoveryCode(options) {
416
+ return this.http.post("/login/recovery", {
417
+ requestId: options.requestId,
418
+ code: options.code
419
+ });
420
+ }
421
+ };
422
+
423
+ // src/resources/Webhook.ts
424
+ import * as crypto2 from "crypto";
425
+ function canonicalize(payload) {
426
+ const { signature: _drop, ...rest } = payload;
427
+ const sorted = {};
428
+ for (const key of Object.keys(rest).sort()) {
429
+ sorted[key] = rest[key];
430
+ }
431
+ return JSON.stringify(sorted);
432
+ }
433
+ var Webhook = class {
434
+ apiSecret;
435
+ constructor(apiSecret) {
436
+ this.apiSecret = apiSecret;
437
+ }
438
+ /**
439
+ * Verify that an incoming webhook request is genuinely from TouchQue and
440
+ * return its parsed payload. Throws `TouchQueWebhookSignatureError` if the
441
+ * signature is invalid, the body is malformed, or the webhook is stale.
442
+ *
443
+ * **Always verify before processing, and de-duplicate on `payload.jti`
444
+ * (or `payload.requestId` + `payload.event`) so a replayed webhook can't
445
+ * re-trigger your logic.**
446
+ *
447
+ * @throws {TouchQueWebhookSignatureError}
448
+ *
449
+ * @example
450
+ * // Express.js — mount with a raw body parser on this route
451
+ * app.post('/webhooks/touchque', express.raw({ type: 'application/json' }), (req, res) => {
452
+ * try {
453
+ * const event = tq.webhook.verify({
454
+ * rawBody: req.body.toString('utf8'),
455
+ * signature: req.headers['x-signature'] as string,
456
+ * });
457
+ * // ... handle event.event, guarded by an idempotency check on event.jti
458
+ * res.sendStatus(200);
459
+ * } catch {
460
+ * res.sendStatus(403); // not from TouchQue, tampered, or replayed
461
+ * }
462
+ * });
463
+ */
464
+ verify(options) {
465
+ const { rawBody } = options;
466
+ if (!rawBody || typeof rawBody !== "string") {
467
+ throw new TouchQueWebhookSignatureError();
468
+ }
469
+ let payload;
470
+ try {
471
+ const parsed = JSON.parse(rawBody);
472
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
473
+ throw new Error("not an object");
474
+ }
475
+ payload = parsed;
476
+ } catch {
477
+ throw new TouchQueWebhookSignatureError();
478
+ }
479
+ const provided = typeof options.signature === "string" && options.signature ? options.signature : typeof payload.signature === "string" ? payload.signature : "";
480
+ if (!provided) {
481
+ throw new TouchQueWebhookSignatureError();
482
+ }
483
+ const expected = crypto2.createHmac("sha256", this.apiSecret).update(canonicalize(payload)).digest("hex");
484
+ const providedBuf = Buffer.from(provided, "utf8");
485
+ const expectedBuf = Buffer.from(expected, "utf8");
486
+ if (providedBuf.length !== expectedBuf.length || !crypto2.timingSafeEqual(providedBuf, expectedBuf)) {
487
+ throw new TouchQueWebhookSignatureError();
488
+ }
489
+ const tolerance = options.toleranceSeconds ?? 300;
490
+ if (tolerance > 0 && typeof payload.timestamp === "string") {
491
+ const ts = Date.parse(payload.timestamp);
492
+ if (!Number.isNaN(ts) && Math.abs(Date.now() - ts) > tolerance * 1e3) {
493
+ throw new TouchQueWebhookSignatureError();
494
+ }
495
+ }
496
+ return payload;
497
+ }
498
+ /**
499
+ * Check a webhook signature without throwing. Returns `true` / `false`.
500
+ *
501
+ * @example
502
+ * if (tq.webhook.isValid({ rawBody, signature })) {
503
+ * // process
504
+ * }
505
+ */
506
+ isValid(options) {
507
+ try {
508
+ this.verify(options);
509
+ return true;
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
514
+ };
515
+
516
+ // src/resources/WebAuthn.ts
517
+ var WebAuthn = class {
518
+ constructor(http) {
519
+ this.http = http;
520
+ }
521
+ http;
522
+ /**
523
+ * Step 1 of registering a passkey: get options for `navigator.credentials.create()`.
524
+ *
525
+ * @example
526
+ * const options = await tq.webauthn.registerOptions({ externalUsername: 'user@company.com' });
527
+ * // send `options` to the browser, call navigator.credentials.create({ publicKey: options })
528
+ */
529
+ async registerOptions(options) {
530
+ return this.http.post("/webauthn/register/options", {
531
+ externalUsername: options.externalUsername,
532
+ ...options.discoverable ? { discoverable: true } : {}
533
+ });
534
+ }
535
+ /**
536
+ * Step 2: verify the browser's registration response and store the credential.
537
+ *
538
+ * @example
539
+ * const { verified, credentialId } = await tq.webauthn.registerVerify({
540
+ * externalUsername: 'user@company.com',
541
+ * response: browserRegistrationResponseJSON,
542
+ * label: 'MacBook Touch ID',
543
+ * });
544
+ */
545
+ async registerVerify(options) {
546
+ return this.http.post("/webauthn/register/verify", {
547
+ externalUsername: options.externalUsername,
548
+ response: options.response,
549
+ ...options.label && { label: options.label }
550
+ });
551
+ }
552
+ /**
553
+ * Step 1 of approving a pending login with a passkey: get options for
554
+ * `navigator.credentials.get()`, scoped to a specific `requestId` (from
555
+ * `tq.login.request()`).
556
+ *
557
+ * @example
558
+ * const loginReq = await tq.login.request({ externalUsername: 'user@company.com', type: 'LOGIN' });
559
+ * const options = await tq.webauthn.authenticateOptions({ requestId: loginReq.requestId });
560
+ */
561
+ async authenticateOptions(options) {
562
+ return this.http.post("/webauthn/login/options", {
563
+ requestId: options.requestId
564
+ });
565
+ }
566
+ /**
567
+ * Step 2: verify the browser's assertion — approves the LoginRequest on success.
568
+ *
569
+ * @example
570
+ * const result = await tq.webauthn.authenticateVerify({
571
+ * requestId: loginReq.requestId,
572
+ * response: browserAuthenticationResponseJSON,
573
+ * });
574
+ */
575
+ async authenticateVerify(options) {
576
+ return this.http.post("/webauthn/login/verify", {
577
+ requestId: options.requestId,
578
+ response: options.response
579
+ });
580
+ }
581
+ /**
582
+ * Step 1 of a PASSWORDLESS-PRIMARY login: authenticate a user *from zero*
583
+ * (no password, no prior `tq.login.request()`, no mobile device) with a
584
+ * passkey registered via `registerOptions({ discoverable: true })`.
585
+ *
586
+ * Requires `TenantPolicy.passwordlessLoginEnabled` for the integration.
587
+ * Returns `404 no_passkey_registered` if the user has no passkey — the
588
+ * caller should then fall back to password login.
589
+ *
590
+ * @example
591
+ * const { attemptId, options } = await tq.webauthn.primaryOptions({ externalUsername: 'user@company.com' });
592
+ * // browser: const assertion = await navigator.credentials.get({ publicKey: options });
593
+ */
594
+ async primaryOptions(options) {
595
+ return this.http.post("/webauthn/authenticate/primary/options", {
596
+ externalUsername: options.externalUsername
597
+ });
598
+ }
599
+ /**
600
+ * Step 2 of a passwordless-primary login: verify the browser assertion.
601
+ *
602
+ * On `success:true` the returned `requestId` is a CONFIRMED LoginRequest —
603
+ * the login is done. On `success:false` with `requiresStepUp:true`, risk
604
+ * or policy demands a second factor: start the normal `tq.login.request()`
605
+ * / number-match flow instead of trusting this assertion alone.
606
+ */
607
+ async primaryVerify(options) {
608
+ return this.http.post("/webauthn/authenticate/primary/verify", {
609
+ attemptId: options.attemptId,
610
+ response: options.response
611
+ });
612
+ }
613
+ /**
614
+ * List a user's registered WebAuthn credentials (labels/metadata only, no key material).
615
+ */
616
+ async listCredentials(options) {
617
+ return this.http.get("/webauthn/credentials", {
618
+ externalUsername: options.externalUsername
619
+ });
620
+ }
621
+ /**
622
+ * Remove a registered credential (e.g. the user lost that device).
623
+ */
624
+ async deleteCredential(credentialRecordId) {
625
+ return this.http.delete(`/webauthn/credentials/${encodeURIComponent(credentialRecordId)}`);
626
+ }
627
+ };
628
+
629
+ // src/middleware/express.ts
630
+ function requireTouchQue(tqClient, actionType, options = {}) {
631
+ return async (req, res, next) => {
632
+ const getUserId = options.getUserId || ((r) => r.user?.email || r.user?.id || r.user?.username);
633
+ const userId = getUserId(req);
634
+ if (!userId) {
635
+ res.status(401).json({
636
+ error: "UNAUTHORIZED_FOR_2FA",
637
+ message: "Could not find user ID in request. Ensure the user is authenticated before this middleware."
638
+ });
639
+ return;
640
+ }
641
+ const referenceId = options.getReferenceId ? options.getReferenceId(req) : void 0;
642
+ const timeout = options.timeout || 3e4;
643
+ try {
644
+ const result = await tqClient.login.verify({
645
+ externalUsername: userId,
646
+ type: actionType,
647
+ referenceId,
648
+ timeout
649
+ });
650
+ req.touchque = {
651
+ requestId: result.requestId,
652
+ challengeCode: result.challengeCode,
653
+ approved: result.approved
654
+ };
655
+ next();
656
+ } catch (error) {
657
+ if (error instanceof TouchQueRejectedError) {
658
+ res.status(403).json({
659
+ error: "2FA_REJECTED",
660
+ message: "User rejected the 2FA request on their device."
661
+ });
662
+ return;
663
+ }
664
+ if (error instanceof TouchQueTimeoutError) {
665
+ res.status(408).json({
666
+ error: "2FA_TIMEOUT",
667
+ message: "User did not respond to the 2FA request in time."
668
+ });
669
+ return;
670
+ }
671
+ console.error("[TouchQue Middleware Error]", error);
672
+ res.status(503).json({
673
+ error: "2FA_UNAVAILABLE",
674
+ message: "The 2FA verification service is temporarily unavailable."
675
+ });
676
+ return;
677
+ }
678
+ };
679
+ }
680
+
681
+ // src/index.ts
682
+ var TouchQue = class {
683
+ client;
684
+ auth;
685
+ login;
686
+ /** Webhook signature verification */
687
+ webhook;
688
+ /** WebAuthn/FIDO2 (passkey) registration and login approval */
689
+ webauthn;
690
+ /**
691
+ * Create a new TouchQue SDK client.
692
+ *
693
+ * @param config - Your API credentials from the TouchQue Dashboard
694
+ *
695
+ * @example
696
+ * import { TouchQue } from '@touchque/node';
697
+ *
698
+ * const tq = new TouchQue({
699
+ * apiKey: 'tq_auth_abc123',
700
+ * apiSecret: 'your_api_secret'
701
+ * });
702
+ *
703
+ * // Now use tq.auth, tq.login, tq.webhook
704
+ */
705
+ constructor(config) {
706
+ const http = new HttpClient(config);
707
+ this.client = http;
708
+ this.auth = new Auth(http);
709
+ this.login = new Login(http);
710
+ this.webhook = new Webhook(config.apiSecret);
711
+ this.webauthn = new WebAuthn(http);
712
+ }
713
+ /**
714
+ * Wrap and protect any async function with TouchQue 2FA.
715
+ *
716
+ * @param fn The function to protect
717
+ * @param options Configuration for extracting the user identity and 2FA type
718
+ * @returns A new function that requires 2FA approval before executing the original function
719
+ *
720
+ * @example
721
+ * const secureWithdraw = tq.protect(processWithdraw, {
722
+ * type: 'WITHDRAW',
723
+ * getUserIdentifier: (username, amount) => username
724
+ * });
725
+ *
726
+ * await secureWithdraw("user@company.com", 500); // Prompts 2FA automatically
727
+ */
728
+ protect(fn, options) {
729
+ return async (...args) => {
730
+ const externalUsername = options.getUserIdentifier(...args);
731
+ const referenceId = options.getReferenceId ? options.getReferenceId(...args) : void 0;
732
+ await this.login.verify({
733
+ externalUsername,
734
+ type: options.type,
735
+ referenceId,
736
+ timeout: options.timeout,
737
+ pollInterval: options.pollInterval
738
+ });
739
+ return fn(...args);
740
+ };
741
+ }
742
+ };
743
+ export {
744
+ Auth,
745
+ Login,
746
+ TouchQue,
747
+ TouchQueAPIError,
748
+ TouchQueConfigError,
749
+ TouchQueError,
750
+ TouchQueRejectedError,
751
+ TouchQueTimeoutError,
752
+ TouchQueWebhookSignatureError,
753
+ WebAuthn,
754
+ Webhook,
755
+ requireTouchQue
756
+ };