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