touchque-mcp-server 1.0.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.
- package/README.md +128 -0
- package/bundled/sdk-documentation.md +179 -0
- package/bundled/types.ts +582 -0
- package/index.js +495 -0
- package/package.json +41 -0
package/bundled/types.ts
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
// TouchQue Node.js SDK — bundled type reference for AI assistants.
|
|
2
|
+
// Concatenation of touchque-node/src/types.ts + touchque-node/src/steps.ts
|
|
3
|
+
// from the SDK source. Regenerate by copying those files here whenever
|
|
4
|
+
// the SDK's public API changes (see touchque-mcp-server/README.md).
|
|
5
|
+
|
|
6
|
+
// src/types.ts
|
|
7
|
+
// All TypeScript types and interfaces for the TouchQue Node.js SDK
|
|
8
|
+
|
|
9
|
+
export interface TouchQueConfig {
|
|
10
|
+
/** Your TouchQue API key (starts with `tq_`) */
|
|
11
|
+
apiKey: string;
|
|
12
|
+
/** Your TouchQue API secret */
|
|
13
|
+
apiSecret: string;
|
|
14
|
+
/**
|
|
15
|
+
* Optional base URL override (default: https://api.touchque.com, or the
|
|
16
|
+
* TQ_API_URL environment variable). Must be `https://` unless it
|
|
17
|
+
* points at localhost — the SDK refuses plaintext `http://` to any other
|
|
18
|
+
* host so the API key / signature are never sent in the clear.
|
|
19
|
+
*/
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
/** Request timeout in ms (default: 10000) */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Auth Resource ───────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface GenerateSecretOptions {
|
|
28
|
+
/** The user's unique identifier in YOUR system (email, userId, etc.) */
|
|
29
|
+
externalUsername: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GenerateSecretResponse {
|
|
33
|
+
/** New setup secret — show to user once (e.g. as QR code) */
|
|
34
|
+
secret: string;
|
|
35
|
+
externalUsername: string;
|
|
36
|
+
/** ISO string of when this secret expires if not used */
|
|
37
|
+
expiresAt: string;
|
|
38
|
+
ttlMs: number;
|
|
39
|
+
/**
|
|
40
|
+
* A ready-to-render `data:image/png;base64,...` QR code encoding `secret`
|
|
41
|
+
* for the TouchQue mobile app to scan. Show this directly in an `<img>`
|
|
42
|
+
* src — no client-side QR generation needed.
|
|
43
|
+
*/
|
|
44
|
+
qrCodeDataUrl: string;
|
|
45
|
+
/**
|
|
46
|
+
* 10 one-time recovery codes to be shown to the user.
|
|
47
|
+
* If the user loses their device, they can use one of these codes to bypass 2FA.
|
|
48
|
+
*/
|
|
49
|
+
recoveryCodes: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UnlinkSecretOptions {
|
|
53
|
+
externalUsername: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UnlinkSecretResponse {
|
|
57
|
+
success: boolean;
|
|
58
|
+
externalUsername: string;
|
|
59
|
+
message: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ValidateSecretOptions {
|
|
63
|
+
secret: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ValidateSecretResponse {
|
|
67
|
+
valid: boolean;
|
|
68
|
+
externalUsername?: string;
|
|
69
|
+
integrationId?: string;
|
|
70
|
+
integration?: {
|
|
71
|
+
companyName: string;
|
|
72
|
+
icon: string;
|
|
73
|
+
color: string;
|
|
74
|
+
};
|
|
75
|
+
message?: string;
|
|
76
|
+
reason?: string;
|
|
77
|
+
usedAt?: string;
|
|
78
|
+
expiredAt?: string;
|
|
79
|
+
}
|
|
80
|
+
export interface ResetSecretOptions {
|
|
81
|
+
externalUsername: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ResetSecretResponse {
|
|
85
|
+
/** New setup secret — show to user once */
|
|
86
|
+
secret: string;
|
|
87
|
+
externalUsername: string;
|
|
88
|
+
expiresAt?: string;
|
|
89
|
+
/** See GenerateSecretResponse.qrCodeDataUrl. */
|
|
90
|
+
qrCodeDataUrl?: string;
|
|
91
|
+
recoveryCodes?: string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface GetUserResponse {
|
|
95
|
+
externalUsername: string;
|
|
96
|
+
/** Set once the mobile app has linked (scanned) the secret. */
|
|
97
|
+
deviceId: string | null;
|
|
98
|
+
/** true once the secret has been linked to a device via /auth/secret/link. */
|
|
99
|
+
used: boolean;
|
|
100
|
+
frozen: boolean;
|
|
101
|
+
createdAt: string;
|
|
102
|
+
expireAt: string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Offline Sign ────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export interface OfflineChallengeOptions {
|
|
108
|
+
/** User identifier in YOUR system */
|
|
109
|
+
externalUsername: string;
|
|
110
|
+
/** The action being approved (an Action Type slug/id from your Dashboard) */
|
|
111
|
+
type: string;
|
|
112
|
+
/**
|
|
113
|
+
* What the user is approving, shown on the phone (see LoginRequestOptions.details).
|
|
114
|
+
* REQUIRED for critical action types.
|
|
115
|
+
*/
|
|
116
|
+
details?: LoginRequestDetails;
|
|
117
|
+
/** End user's IP / User-Agent: shown to the user as "where the request came from". */
|
|
118
|
+
clientIp?: string;
|
|
119
|
+
userAgent?: string;
|
|
120
|
+
/** Seconds the challenge stays valid (30–300, default 120). */
|
|
121
|
+
ttlSeconds?: number;
|
|
122
|
+
/** Set false to skip the ready-made QR image (`qrDataUrl` is then null). */
|
|
123
|
+
includeQrImage?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface OfflineChallengeResponse {
|
|
127
|
+
challengeId: string;
|
|
128
|
+
/** QR text (`TQ2.…`, encrypted for the user's phone; nothing in it is readable) — draw it as a QR code if you don't use `qrDataUrl`. */
|
|
129
|
+
qr: string;
|
|
130
|
+
/** `data:image/png;base64,…` ready for `<img src>`; null when `includeQrImage: false`. */
|
|
131
|
+
qrDataUrl: string | null;
|
|
132
|
+
expiresAt: string;
|
|
133
|
+
expiresInSeconds: number;
|
|
134
|
+
/** True when the workspace allows the time-based code fallback (no camera). */
|
|
135
|
+
totpAvailable: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface OfflineVerifyOptions {
|
|
139
|
+
challengeId: string;
|
|
140
|
+
/** The 7 characters shown on the phone; dashes/spaces/case are ignored. */
|
|
141
|
+
code: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface OfflineTotpVerifyOptions {
|
|
145
|
+
externalUsername: string;
|
|
146
|
+
code: string;
|
|
147
|
+
/** The action the code is for; critical actions are refused. */
|
|
148
|
+
type?: string;
|
|
149
|
+
clientIp?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface OfflineVerifyResult {
|
|
153
|
+
approved: boolean;
|
|
154
|
+
/** When not approved: invalid_code | locked | expired | used | unknown_challenge | too_many_failures | frozen | … */
|
|
155
|
+
reason?: string;
|
|
156
|
+
/** Wrong codes left before the challenge locks (only with reason `invalid_code`). */
|
|
157
|
+
attemptsLeft?: number;
|
|
158
|
+
challengeId?: string;
|
|
159
|
+
externalUsername?: string;
|
|
160
|
+
type?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Login Resource ──────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
export type LoginType = 'LOGIN' | 'DISABLE_2FA' | string;
|
|
166
|
+
|
|
167
|
+
/** Label → value pairs, or an explicit ordered list of `{ label, value }`. */
|
|
168
|
+
export type LoginRequestDetails =
|
|
169
|
+
| Record<string, string | number>
|
|
170
|
+
| Array<{ label: string; value: string | number }>;
|
|
171
|
+
|
|
172
|
+
export interface LoginRequestOptions {
|
|
173
|
+
/** User identifier in YOUR system */
|
|
174
|
+
externalUsername: string;
|
|
175
|
+
/**
|
|
176
|
+
* The type of action requiring 2FA confirmation.
|
|
177
|
+
* Built-in types: 'LOGIN', 'DISABLE_2FA'
|
|
178
|
+
* Custom types can be created/configured in your TouchQue Dashboard.
|
|
179
|
+
*/
|
|
180
|
+
type: LoginType;
|
|
181
|
+
/** Optional reference (e.g. your internal transaction ID) */
|
|
182
|
+
referenceId?: string;
|
|
183
|
+
|
|
184
|
+
/** The IP address of the user initiating the request. TouchQue will resolve this to a City/Country. */
|
|
185
|
+
clientIp?: string;
|
|
186
|
+
/**
|
|
187
|
+
* The User-Agent string of the user's browser/device. Shown on the approval
|
|
188
|
+
* screen as the requesting browser and device (e.g. "Chrome · macOS").
|
|
189
|
+
*/
|
|
190
|
+
userAgent?: string;
|
|
191
|
+
/**
|
|
192
|
+
* Transaction context shown on the approval screen, so the user sees what
|
|
193
|
+
* they are approving — e.g. `{ Amount: '1,250.00 USD', Recipient: 'Jane Doe' }`.
|
|
194
|
+
* Keys and values are displayed as-is, in the order given.
|
|
195
|
+
*
|
|
196
|
+
* Limits (the request is rejected with a 400 otherwise — never truncated):
|
|
197
|
+
* at most 8 entries, labels up to 40 characters, values up to 120
|
|
198
|
+
* characters, strings or numbers only, no control characters.
|
|
199
|
+
*
|
|
200
|
+
* Always set this from your server-side state, never from browser input.
|
|
201
|
+
*/
|
|
202
|
+
details?: LoginRequestDetails;
|
|
203
|
+
/** Require the user to authenticate with FaceID / TouchID on their device to approve this request. */
|
|
204
|
+
requireBiometric?: boolean;
|
|
205
|
+
/**
|
|
206
|
+
* Force Number Matching (anti-push-bombing).
|
|
207
|
+
* When true, the user must type a matching code shown on their screen to approve.
|
|
208
|
+
* The backend also auto-enables this if MFA fatigue is detected (2+ failed requests in 10 min).
|
|
209
|
+
*/
|
|
210
|
+
requireNumberMatch?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface LoginRequestResponse {
|
|
214
|
+
requestId: string;
|
|
215
|
+
/** Challenge code for number matching UI (if enabled for this integration) */
|
|
216
|
+
challengeCode?: string;
|
|
217
|
+
message: string;
|
|
218
|
+
expiresAt: string;
|
|
219
|
+
/**
|
|
220
|
+
* Present only when this integration has behavioral biometrics enabled
|
|
221
|
+
* (TenantPolicy.behavioralBiometricsEnabled). Pass this down to your
|
|
222
|
+
* frontend along with `requestId` to initialize the behavioral widget in
|
|
223
|
+
* `@touchque/web` (`tq.behavioral.attach(...)`), scoped to your 2FA
|
|
224
|
+
* challenge UI. Single-purpose and short-lived — do not reuse across requests.
|
|
225
|
+
*/
|
|
226
|
+
telemetryToken?: string;
|
|
227
|
+
/**
|
|
228
|
+
* The workspace policy requires a phishing-resistant factor for this action:
|
|
229
|
+
* no push was sent. Approve it with a passkey in the browser
|
|
230
|
+
* (`@touchque/web` `passkeys.approveLogin({ requestId })`).
|
|
231
|
+
*/
|
|
232
|
+
requiresPasskey?: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** How an approved request was confirmed, and whether that is phishing-resistant. */
|
|
236
|
+
export interface ApprovalAssurance {
|
|
237
|
+
/** true only for passkeys (WebAuthn): the signature is bound to your site's origin. */
|
|
238
|
+
phishingResistant: boolean;
|
|
239
|
+
/** 'passkey' | 'push' | 'recovery_code' | ... */
|
|
240
|
+
method: string | null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type LoginStatus = 'PENDING' | 'CONFIRMED' | 'REJECTED' | 'EXPIRED';
|
|
244
|
+
|
|
245
|
+
export interface LoginStatusResponse {
|
|
246
|
+
status: LoginStatus;
|
|
247
|
+
/** How the request was confirmed ('DEVICE', 'WEBAUTHN', ...); null until confirmed */
|
|
248
|
+
confirmedVia?: string | null;
|
|
249
|
+
/**
|
|
250
|
+
* Device-signed proof of what was approved (only when the approving phone
|
|
251
|
+
* holds a Secure Enclave key). Check it with `verifyApprovalProof`.
|
|
252
|
+
*/
|
|
253
|
+
approvalProof?: import('./approvalProof').ApprovalProof | null;
|
|
254
|
+
/** Set once CONFIRMED: rely on `assurance.phishingResistant` for high-assurance sessions. */
|
|
255
|
+
assurance?: ApprovalAssurance | null;
|
|
256
|
+
/** The request can only be approved with a passkey (phishing-resistant policy). */
|
|
257
|
+
requiresPasskey?: boolean;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface WaitForApprovalOptions {
|
|
261
|
+
requestId: string;
|
|
262
|
+
/** How long to poll in total, ms (default: 30000 = 30s) */
|
|
263
|
+
timeout?: number;
|
|
264
|
+
/** Polling interval, ms (default: 1500) */
|
|
265
|
+
pollInterval?: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface WaitForApprovalResult {
|
|
269
|
+
approved: boolean;
|
|
270
|
+
status: LoginStatus;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Webhook Resource ────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
export interface VerifyWebhookOptions {
|
|
276
|
+
/** Raw request body as string, exactly as received (before JSON.parse). */
|
|
277
|
+
rawBody: string;
|
|
278
|
+
/**
|
|
279
|
+
* The signature to check against. Pass the value of the `x-signature`
|
|
280
|
+
* header. Optional: if omitted, the SDK falls back to the `signature`
|
|
281
|
+
* field TouchQue also embeds in the JSON body.
|
|
282
|
+
*/
|
|
283
|
+
signature?: string;
|
|
284
|
+
/**
|
|
285
|
+
* Reject the webhook if its `timestamp` is more than this many seconds
|
|
286
|
+
* from now (replay protection). Default 300 (5 minutes). Set to 0 to
|
|
287
|
+
* disable the freshness check.
|
|
288
|
+
*/
|
|
289
|
+
toleranceSeconds?: number;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface WebhookPayload {
|
|
293
|
+
event: string;
|
|
294
|
+
requestId: string;
|
|
295
|
+
externalUsername: string;
|
|
296
|
+
status: LoginStatus;
|
|
297
|
+
timestamp: string;
|
|
298
|
+
/**
|
|
299
|
+
* On a SUCCESS webhook, the approving phone's device-signed proof of what
|
|
300
|
+
* was approved (when it holds a Secure Enclave key). Check it with
|
|
301
|
+
* `verifyApprovalProof` before releasing money or data.
|
|
302
|
+
*/
|
|
303
|
+
approvalProof?: import('./approvalProof').ApprovalProof;
|
|
304
|
+
/** On SUCCESS: how it was approved and whether that was phishing-resistant. */
|
|
305
|
+
assurance?: ApprovalAssurance;
|
|
306
|
+
[key: string]: unknown;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── Protect Wrapper ─────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
export interface ProtectOptions<TArgs extends any[]> {
|
|
312
|
+
/** The action type (e.g. 'WITHDRAW', 'LOGIN') */
|
|
313
|
+
type: string;
|
|
314
|
+
/** Function to extract the externalUsername from the wrapped function's arguments */
|
|
315
|
+
getUserIdentifier: (...args: TArgs) => string;
|
|
316
|
+
/** Optional function to extract a referenceId from the arguments */
|
|
317
|
+
getReferenceId?: (...args: TArgs) => string | undefined;
|
|
318
|
+
/**
|
|
319
|
+
* Optional function returning the end user's request context (IP address,
|
|
320
|
+
* User-Agent) and transaction details shown on the approval screen.
|
|
321
|
+
*/
|
|
322
|
+
getContext?: (...args: TArgs) => Pick<LoginRequestOptions, 'clientIp' | 'userAgent' | 'details'> | undefined;
|
|
323
|
+
/** Timeout in ms */
|
|
324
|
+
timeout?: number;
|
|
325
|
+
/** Polling interval in ms */
|
|
326
|
+
pollInterval?: number;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Errors ──────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
export interface TouchQueErrorData {
|
|
332
|
+
error?: string;
|
|
333
|
+
message?: string;
|
|
334
|
+
code?: string;
|
|
335
|
+
/** Offline sign: why a code was not approved (`invalid_code`, `locked`, `expired`, `used`, …). */
|
|
336
|
+
reason?: string;
|
|
337
|
+
/** Offline sign: wrong codes left before the challenge locks. */
|
|
338
|
+
attemptsLeft?: number;
|
|
339
|
+
/** Seconds to wait before retrying (429 / 423). */
|
|
340
|
+
retryAfter?: number;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─────────────────────────────────────────────────────────────
|
|
344
|
+
// From src/steps.ts — the headless step-up flow (start/check/complete)
|
|
345
|
+
// ─────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
// src/steps.ts
|
|
348
|
+
// The headless step-up flow shared by every integration (Express, Next.js,
|
|
349
|
+
// touchqueRouter, or your own framework): start → show the step in YOUR UI →
|
|
350
|
+
// check → complete exactly once.
|
|
351
|
+
//
|
|
352
|
+
// A "step" is plain JSON that is safe to send to the browser: what state the
|
|
353
|
+
// approval is in and whatever the user has to see (the matching number, the
|
|
354
|
+
// enrollment QR code, the offline QR code). It never contains API secrets.
|
|
355
|
+
|
|
356
|
+
import * as crypto from 'crypto';
|
|
357
|
+
import type { HttpClient } from './core/HttpClient';
|
|
358
|
+
import type { Auth } from './resources/Auth';
|
|
359
|
+
import type { Login } from './resources/Login';
|
|
360
|
+
import type { Offline } from './resources/Offline';
|
|
361
|
+
import type { ApprovalAssurance, LoginRequestDetails } from './types';
|
|
362
|
+
import { TouchQueAPIError, TouchQueConfigError, TouchQueError } from './errors';
|
|
363
|
+
import { detailsDigest } from './approvalProof';
|
|
364
|
+
|
|
365
|
+
export type StepState =
|
|
366
|
+
| 'waiting' // push sent — show `number` (if any) and wait
|
|
367
|
+
| 'approved'
|
|
368
|
+
| 'rejected'
|
|
369
|
+
| 'expired'
|
|
370
|
+
| 'enroll' // no phone linked yet — show `enroll.qrCodeDataUrl`
|
|
371
|
+
| 'passkey_required' // policy: approve with a passkey (browser ceremony)
|
|
372
|
+
| 'offline' // offline approval started — show `offline.qrDataUrl`, ask for the code
|
|
373
|
+
| 'blocked' // refused by policy / risk / action disabled — see `reason`
|
|
374
|
+
| 'frozen' // too many rejections — see `retryAfter`
|
|
375
|
+
| 'rate_limited'; // too many requests — see `retryAfter`
|
|
376
|
+
|
|
377
|
+
export interface Step {
|
|
378
|
+
state: StepState;
|
|
379
|
+
requestId?: string;
|
|
380
|
+
/** Number matching: show this on screen; the user picks it on the phone. */
|
|
381
|
+
number?: string;
|
|
382
|
+
expiresAt?: string;
|
|
383
|
+
/** Transaction details the phone shows (amount, recipient…). */
|
|
384
|
+
details?: Array<{ label: string; value: string }>;
|
|
385
|
+
enroll?: { qrCodeDataUrl: string; recoveryCodes?: string[]; expiresAt?: string };
|
|
386
|
+
offline?: { challengeId: string; qrDataUrl?: string; expiresAt?: string; totpAvailable?: boolean; attemptsLeft?: number };
|
|
387
|
+
reason?: string;
|
|
388
|
+
retryAfter?: number;
|
|
389
|
+
assurance?: ApprovalAssurance;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface StartOptions {
|
|
393
|
+
/** Your user's id in YOUR system (email, user id…). */
|
|
394
|
+
user: string;
|
|
395
|
+
/** Shown on the phone and bound to the approval. Build it from server-side state. */
|
|
396
|
+
details?: LoginRequestDetails;
|
|
397
|
+
/** Your own transaction id, bound to the approval. */
|
|
398
|
+
referenceId?: string;
|
|
399
|
+
/** End user's IP / user agent (location and device shown on the phone). */
|
|
400
|
+
ip?: string;
|
|
401
|
+
userAgent?: string;
|
|
402
|
+
/** Show an enrollment QR when the user has not linked a phone yet (default true). */
|
|
403
|
+
enroll?: boolean;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export interface Approval {
|
|
407
|
+
requestId: string;
|
|
408
|
+
user: string;
|
|
409
|
+
action: string;
|
|
410
|
+
/** How it was approved; `phishingResistant` is true only for passkeys. */
|
|
411
|
+
assurance: ApprovalAssurance | null;
|
|
412
|
+
confirmedVia: string | null;
|
|
413
|
+
/** Device-signed proof of what was approved (see verifyApprovalProof). */
|
|
414
|
+
approvalProof: unknown;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
type Resources = { http: HttpClient; auth: Auth; login: Login; offline: Offline };
|
|
418
|
+
|
|
419
|
+
/** Normalizes details the same way the API does: object or [{label, value}] → [{label, value}] strings. */
|
|
420
|
+
export function normalizeDetails(details?: LoginRequestDetails): Array<{ label: string; value: string }> {
|
|
421
|
+
if (!details) return [];
|
|
422
|
+
const pairs = Array.isArray(details)
|
|
423
|
+
? details.map((d) => [d.label, d.value] as const)
|
|
424
|
+
: Object.entries(details as Record<string, unknown>);
|
|
425
|
+
return pairs.map(([label, value]) => ({ label: String(label).trim(), value: String(value).trim() }));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const codeOf = (err: unknown): string | undefined =>
|
|
429
|
+
err instanceof TouchQueAPIError ? (err.code || (err.data as { error?: string })?.error) : undefined;
|
|
430
|
+
|
|
431
|
+
/** Starts an approval for `action` — never waits. */
|
|
432
|
+
export async function start(r: Resources, action: string, opts: StartOptions): Promise<Step> {
|
|
433
|
+
if (!opts?.user) throw new TouchQueConfigError('start() needs the user id (options.user)');
|
|
434
|
+
const details = normalizeDetails(opts.details);
|
|
435
|
+
try {
|
|
436
|
+
const res = await r.login.request({
|
|
437
|
+
externalUsername: opts.user,
|
|
438
|
+
type: action,
|
|
439
|
+
referenceId: opts.referenceId,
|
|
440
|
+
clientIp: opts.ip,
|
|
441
|
+
userAgent: opts.userAgent,
|
|
442
|
+
details: details.length ? details : undefined,
|
|
443
|
+
});
|
|
444
|
+
if (res.requiresPasskey) {
|
|
445
|
+
return { state: 'passkey_required', requestId: res.requestId, expiresAt: res.expiresAt, details: details.length ? details : undefined };
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
state: 'waiting',
|
|
449
|
+
requestId: res.requestId,
|
|
450
|
+
number: res.challengeCode || undefined,
|
|
451
|
+
expiresAt: res.expiresAt,
|
|
452
|
+
details: details.length ? details : undefined,
|
|
453
|
+
};
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if (!(err instanceof TouchQueAPIError)) throw err;
|
|
456
|
+
const code = codeOf(err);
|
|
457
|
+
const data = err.data as { retryAfter?: number; reason?: string; error?: string };
|
|
458
|
+
if (err.status === 404 && (code === 'device_not_linked' || /linked device/i.test(err.message))) {
|
|
459
|
+
if (opts.enroll === false) return { state: 'enroll' };
|
|
460
|
+
return enrollStep(r, opts.user);
|
|
461
|
+
}
|
|
462
|
+
if (err.status === 423) return { state: 'frozen', retryAfter: data.retryAfter };
|
|
463
|
+
if (err.status === 429) return { state: 'rate_limited', retryAfter: data.retryAfter };
|
|
464
|
+
if (err.status === 400 && (code === 'unknown_action' || /Invalid action type/i.test(err.message))) {
|
|
465
|
+
throw new TouchQueConfigError(
|
|
466
|
+
`Unknown action "${action}". Create it once with tq.actions.define('${action}') or in the Dashboard (Action Types).`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (err.status === 403) {
|
|
470
|
+
const reason = code === 'blocked' ? data.reason || 'policy'
|
|
471
|
+
: code === 'passkey_not_registered' || data.error === 'phishing_resistant_required' ? 'passkey_not_registered'
|
|
472
|
+
: code === 'action_disabled' ? 'action_disabled'
|
|
473
|
+
: code || 'blocked';
|
|
474
|
+
return { state: 'blocked', reason };
|
|
475
|
+
}
|
|
476
|
+
throw err;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* First-time linking: a fresh QR for a user with no phone. Never unlinks a phone:
|
|
482
|
+
* a secret is only re-issued when the account is confirmed NOT linked.
|
|
483
|
+
*/
|
|
484
|
+
async function enrollStep(r: Resources, user: string): Promise<Step> {
|
|
485
|
+
try {
|
|
486
|
+
const gen = await r.auth.generateSecret({ externalUsername: user });
|
|
487
|
+
return { state: 'enroll', enroll: { qrCodeDataUrl: gen.qrCodeDataUrl, recoveryCodes: gen.recoveryCodes, expiresAt: gen.expiresAt } };
|
|
488
|
+
} catch (err) {
|
|
489
|
+
if (!(err instanceof TouchQueAPIError) || err.status !== 409) throw err;
|
|
490
|
+
const current = await r.auth.getUser({ externalUsername: user }).catch(() => null);
|
|
491
|
+
if (current && (current.used || current.deviceId)) {
|
|
492
|
+
// Linked in the meantime — the caller just starts again.
|
|
493
|
+
return { state: 'enroll', reason: 'already_linked' };
|
|
494
|
+
}
|
|
495
|
+
// A QR was issued earlier and not scanned yet: issue a new one (no device to lose).
|
|
496
|
+
const reset = await r.auth.resetSecret({ externalUsername: user });
|
|
497
|
+
return { state: 'enroll', enroll: { qrCodeDataUrl: reset.qrCodeDataUrl as string, recoveryCodes: reset.recoveryCodes, expiresAt: reset.expiresAt } };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Current state of a started approval. */
|
|
502
|
+
export async function check(r: Resources, requestId: string): Promise<Step> {
|
|
503
|
+
const s = await r.login.status(requestId);
|
|
504
|
+
const map: Record<string, StepState> = { PENDING: 'waiting', CONFIRMED: 'approved', REJECTED: 'rejected', EXPIRED: 'expired' };
|
|
505
|
+
const state: StepState = s.status === 'PENDING' && s.requiresPasskey ? 'passkey_required' : map[s.status] || 'waiting';
|
|
506
|
+
return { state, requestId, ...(state === 'approved' && s.assurance ? { assurance: s.assurance } : {}) };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export interface CompleteExpectations {
|
|
510
|
+
user: string;
|
|
511
|
+
action: string;
|
|
512
|
+
details?: LoginRequestDetails;
|
|
513
|
+
referenceId?: string;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Uses an approved request exactly once and checks it is for THIS user, action and
|
|
518
|
+
* transaction. Throws if it was already used, not approved, or approved for something else.
|
|
519
|
+
*/
|
|
520
|
+
export async function complete(r: Resources, requestId: string, expect: CompleteExpectations): Promise<Approval> {
|
|
521
|
+
const res = await r.http.post<{
|
|
522
|
+
externalUsername: string; type: string; referenceId: string | null; details: Array<{ label: string; value: string }> | null;
|
|
523
|
+
confirmedVia: string | null; assurance: ApprovalAssurance | null; approvalProof: unknown;
|
|
524
|
+
}>(`/login/${encodeURIComponent(requestId)}/consume`, {});
|
|
525
|
+
const sameUser = String(res.externalUsername).toLowerCase() === String(expect.user).toLowerCase();
|
|
526
|
+
const sameDetails = detailsDigest(normalizeDetails(expect.details)) === detailsDigest(res.details || []);
|
|
527
|
+
const sameRef = (res.referenceId || null) === (expect.referenceId || null);
|
|
528
|
+
if (!sameUser || res.type !== expect.action || !sameDetails || !sameRef) {
|
|
529
|
+
throw new TouchQueError('TouchQue: this approval is for a different user, action or transaction.');
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
requestId,
|
|
533
|
+
user: res.externalUsername,
|
|
534
|
+
action: res.type,
|
|
535
|
+
assurance: res.assurance,
|
|
536
|
+
confirmedVia: res.confirmedVia,
|
|
537
|
+
approvalProof: res.approvalProof,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ─── Guard token ──────────────────────────────────────────────────────────
|
|
542
|
+
// The browser echoes this back while it waits. It is signed with a key derived
|
|
543
|
+
// from your API secret and binds the approval to one user, action and
|
|
544
|
+
// transaction, so it cannot be replayed for another user, amount or route.
|
|
545
|
+
|
|
546
|
+
export interface GuardTokenClaims {
|
|
547
|
+
u: string; // user
|
|
548
|
+
a: string; // action
|
|
549
|
+
d: string; // details digest
|
|
550
|
+
r: string | null; // referenceId
|
|
551
|
+
st: StepState;
|
|
552
|
+
rid?: string; // requestId
|
|
553
|
+
n?: string; // matching number
|
|
554
|
+
oc?: string; // offline challenge id
|
|
555
|
+
exp: number; // ms
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const b64u = (b: Buffer) => b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
559
|
+
const fromB64u = (s: string) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
560
|
+
const tokenKey = (apiSecret: string) => crypto.createHmac('sha256', apiSecret).update('touchque-guard-token-v1').digest();
|
|
561
|
+
|
|
562
|
+
export function signGuardToken(apiSecret: string, claims: GuardTokenClaims): string {
|
|
563
|
+
const body = b64u(Buffer.from(JSON.stringify(claims), 'utf8'));
|
|
564
|
+
const mac = b64u(crypto.createHmac('sha256', tokenKey(apiSecret)).update(body).digest());
|
|
565
|
+
return `v1.${body}.${mac}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function verifyGuardToken(apiSecret: string, token: unknown): GuardTokenClaims | null {
|
|
569
|
+
if (typeof token !== 'string' || token.length > 4096) return null;
|
|
570
|
+
const [v, body, mac] = token.split('.');
|
|
571
|
+
if (v !== 'v1' || !body || !mac) return null;
|
|
572
|
+
const expected = crypto.createHmac('sha256', tokenKey(apiSecret)).update(body).digest();
|
|
573
|
+
const given = fromB64u(mac);
|
|
574
|
+
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return null;
|
|
575
|
+
try {
|
|
576
|
+
const claims = JSON.parse(fromB64u(body).toString('utf8')) as GuardTokenClaims;
|
|
577
|
+
if (typeof claims.exp !== 'number' || claims.exp < Date.now()) return null;
|
|
578
|
+
return claims;
|
|
579
|
+
} catch {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
}
|