diff --git a/README.md b/README.md index 1c31dd7..81080b3 100644 --- a/README.md +++ b/README.md @@ -927,8 +927,10 @@ Session-based age and identity verification. The SDK creates a verification sess Creates a verification session and returns a URL for the user to complete the flow. Costs **10 credits** (age) or **15 credits** (identity) when the user completes the verification. +Session creation is never retried automatically: a retry after a lost response would create a second session. If the call times out, create a new session yourself. + ```typescript -import { Tuteliq, VerificationMode, DocumentType } from '@tuteliq/sdk' +import { Tuteliq, VerificationMode, DocumentType, ChipPolicy } from '@tuteliq/sdk' const tuteliq = new Tuteliq(process.env.TUTELIQ_API_KEY) @@ -937,115 +939,130 @@ const session = await tuteliq.createVerificationSession({ mode: VerificationMode.AGE, }) -console.log(session.session_id) // 'abc123...' -console.log(session.url) // 'https://verify.tuteliq.ai/age/?session=...&token=...' -console.log(session.expires_at) // '2026-03-05T12:00:00Z' +console.log(session.session_id) // 'abc123...' +console.log(session.url) // 'https://verify.tuteliq.ai/age/?session=...&token=...' +console.log(session.expires_at) // 1772712000000 (epoch ms) +console.log(session.verification_mode) // 'full' | 'liveness_only' // Identity verification with options const session = await tuteliq.createVerificationSession({ mode: VerificationMode.IDENTITY, document_type: DocumentType.PASSPORT, // Optional hint for the web UI - redirect_url: 'https://example.com/done', // Optional redirect after completion - external_id: 'user_123', // Optional tracking - customer_id: 'cust_456', // Optional multi-tenant ID - metadata: { source: 'onboarding' }, // Optional custom data + chip_policy: ChipPolicy.INSTEAD_OF_PHOTO, // Optional, default 'optional' }) ``` +`chip_policy` sets how a passport or ID card chip read counts as document evidence: `optional` (the document photo is required and a chip read adds to it), `required` (the photo and a valid chip read are both required) or `instead_of_photo` (a chip read that proves itself genuine replaces the photo; otherwise the user is asked for a photo). + +`redirect_url`, `external_id`, `customer_id` and `metadata` are deprecated: the API does not accept them and the SDK no longer sends them. Set the redirect URL in your dashboard verification settings. + +If your plan's monthly verification allowance is used up, the call throws a `VerificationLimitError` and no session is created: + +```typescript +import { VerificationLimitError } from '@tuteliq/sdk' + +try { + await tuteliq.createVerificationSession({ mode: VerificationMode.AGE }) +} catch (error) { + if (error instanceof VerificationLimitError) { + console.log(error.limitDetails?.next_period_start) // when the allowance renews + console.log(error.limitDetails?.buy_credits_url) // where to buy more + } +} +``` + #### `getVerificationSession(sessionId)` -Polls the status of a verification session. When `status` is `completed`, the result contains full document intelligence — MRZ validation, barcode reading, document authenticity, face matching, and liveness. +Polls the status of a verification session. + +**The extracted document fields, `date_of_birth`, `failure_reasons` and `capture` are released once**: only the first poll after the session reaches `completed` or `failed` carries them. Later polls return the decision alone. Store them on that first read, or subscribe to the `verification.completed` webhook, which pushes the same outcome as it is produced. ```typescript import { VerificationSessionStatus } from '@tuteliq/sdk' const session = await tuteliq.getVerificationSession('abc123...') -console.log(session.status) // VerificationSessionStatus.COMPLETED -console.log(session.mode) // VerificationMode.AGE +console.log(session.status) // VerificationSessionStatus.COMPLETED +console.log(session.step_progress) // e.g. 'processing', for progress UI +console.log(session.created_at) // epoch ms if (session.status === VerificationSessionStatus.COMPLETED && session.result) { - // Core result - console.log(session.result.status) // 'verified' | 'failed' | 'needs_review' - console.log(session.result.age) // 22 - console.log(session.result.date_of_birth) // '1990-01-15' - console.log(session.result.is_minor) // false - console.log(session.result.failure_reasons) // [] - - // Document extraction and validation - console.log(session.result.document.ocr_confidence) // 92 - console.log(session.result.document.name_extracted) // 'John Doe' - console.log(session.result.document.document_number) // 'AB1234567' - console.log(session.result.document.document_number_valid) // true (45-country validation) - console.log(session.result.document.country_code) // 'US' - console.log(session.result.document.expired) // false - - // MRZ validation (passports, ID cards with MRZ) - if (session.result.document.mrz_valid !== null) { - console.log(session.result.document.mrz_valid) // true - console.log(session.result.document.mrz_fields?.surname) // 'DOE' - console.log(session.result.document.mrz_fields?.given_names) // 'JOHN' - console.log(session.result.document.mrz_fields?.nationality) // 'USA' - } + // The decision (every poll) + console.log(session.result.status) // 'verified' | 'failed' | 'needs_review' + console.log(session.result.age) // 34 + console.log(session.result.age_range) // { low, high } when the age came from the selfie alone + console.log(session.result.is_minor) // false + console.log(session.result.face_match) // { matched: true, confidence: 0.97 } + console.log(session.result.liveness) // { valid: true } + console.log(session.verification_mode) // 'full' (document + live face) | 'liveness_only' + console.log(session.document_evidence) // 'photo' | 'chip' | 'photo_and_chip' + + // Released once, on the first poll after completion + console.log(session.result.date_of_birth) // '1992-04-01' + console.log(session.result.failure_reasons) // null or string[] + console.log(session.result.document?.first_name) // 'Alex' + console.log(session.result.document?.last_name) // 'Example' + console.log(session.result.document?.document_number) // 'X0000000' + console.log(session.result.document?.expiration_date) // '2031-01-01' + console.log(session.result.document?.chip_verified) // true when the data came from the chip +} +``` - // PDF417 barcode (US/CA driver's licenses) - if (session.result.barcode) { - console.log(session.result.barcode.format) // 'PDF417' - console.log(session.result.barcode.has_aamva) // true - console.log(session.result.barcode.fields?.first_name) // 'JOHN' - console.log(session.result.barcode.fields?.state) // 'CA' - } +`status` can also be `document_required`: the session is waiting for the user to add a document photo before it can finish. - // AI-powered document authenticity - if (session.result.document_authenticity) { - console.log(session.result.document_authenticity.is_authentic) // true - console.log(session.result.document_authenticity.confidence) // 0.92 - console.log(session.result.document_authenticity.security_features_visible) // ['hologram', 'microprint'] - console.log(session.result.document_authenticity.anomalies) // [] - console.log(session.result.document_authenticity.recapture_detected) // false - } +#### `cancelVerificationSession(sessionId)` - // Face matching - console.log(session.result.face_match) // { matched: true, distance: 0.3, confidence: 0.95 } +Cancels an active verification session. No credits are consumed. A cancelled session reads as `expired`. - // Liveness - console.log(session.result.liveness) // { valid: true } -} +```typescript +await tuteliq.cancelVerificationSession('abc123...') ``` -#### `cancelVerificationSession(sessionId)` +#### `getVerificationConfig({ country, region })` -Cancels an active verification session. No credits are consumed. +Returns the age verification settings that apply to a market under your API key: which check a user there will get, and the age threshold. ```typescript -await tuteliq.cancelVerificationSession('abc123...') +const config = await tuteliq.getVerificationConfig({ country: 'US', region: 'CA' }) + +console.log(config.mode) // 'full' | 'liveness_only' +console.log(config.age_threshold) // 18 +``` + +#### `getVerification(verificationId)` + +Retrieves a past verification result, including its age. + +```typescript +const result = await tuteliq.getVerification('vrf_001') + +console.log(result.status) // 'verified' +console.log(result.age) // 22 +console.log(result.is_minor) // false ``` #### `getAgeVerification(verificationId)` -Retrieves a past age verification result by its verification ID. +Retrieves a past age verification result by its verification ID. It carries no age and no personal data. ```typescript const result = await tuteliq.getAgeVerification('vrf_001') console.log(result.status) // 'verified' -console.log(result.age) // 22 console.log(result.is_minor) // false console.log(result.created_at) // '2026-03-05T11:00:00Z' ``` #### `getIdentityVerification(verificationId)` -Retrieves a past identity verification result by its verification ID. +Retrieves a past identity verification result by its verification ID. It carries no personal data. ```typescript const result = await tuteliq.getIdentityVerification('vrf_002') -console.log(result.status) // 'verified' -console.log(result.full_name) // 'John Doe' -console.log(result.date_of_birth) // '1990-01-15' -console.log(result.document_type) // 'passport' -console.log(result.country_code) // 'US' +console.log(result.status) // 'verified' +console.log(result.face_matched) // true +console.log(result.liveness_valid) // true ``` #### Verification Enums @@ -1055,9 +1072,12 @@ The SDK provides enums to avoid hardcoded strings: ```typescript import { VerificationMode, // AGE, IDENTITY - DocumentType, // PASSPORT, ID_CARD, DRIVERS_LICENSE + DocumentType, // PASSPORT, NATIONAL_ID, DRIVERS_LICENSE (ID_CARD is a deprecated alias of NATIONAL_ID) + ChipPolicy, // OPTIONAL, REQUIRED, INSTEAD_OF_PHOTO + VerificationCheck, // FULL, LIVENESS_ONLY + DocumentEvidence, // PHOTO, CHIP, PHOTO_AND_CHIP VerificationStatus, // VERIFIED, FAILED, NEEDS_REVIEW - VerificationSessionStatus, // PENDING, IN_PROGRESS, COMPLETED, FAILED, EXPIRED, CANCELLED + VerificationSessionStatus, // PENDING, IN_PROGRESS, DOCUMENT_REQUIRED, COMPLETED, FAILED, EXPIRED } from '@tuteliq/sdk' ``` @@ -1160,6 +1180,33 @@ const { secret } = await tuteliq.regenerateWebhookSecret('webhook-123') // Update your verification logic with the new secret ``` +#### Verifying deliveries: `verifyWebhookSignature(rawBody, header, secret, options?)` + +Each delivery is signed with your webhook secret in the `X-Tuteliq-Signature-V2` header. Verify it over the raw request body, before parsing JSON. The helper checks the signature in constant time and rejects deliveries older (or further ahead) than `toleranceSeconds` (default 300). Retries of a delivery reuse its `id`, so also drop any `id` you have already processed. + +```typescript +import express from 'express' +import { verifyWebhookSignature, WebhookEventType, type WebhookPayload } from '@tuteliq/sdk' + +app.post('/webhooks/tuteliq', express.raw({ type: 'application/json' }), async (req, res) => { + const ok = await verifyWebhookSignature( + req.body, // raw bytes + req.get('X-Tuteliq-Signature-V2'), + process.env.TUTELIQ_WEBHOOK_SECRET!, + ) + if (!ok) return res.status(400).end() + + const payload: WebhookPayload = JSON.parse(req.body.toString('utf8')) + if (payload.event === WebhookEventType.VERIFICATION_COMPLETED && payload.data.verification) { + // Session outcome, including extracted document fields: treat as personal data + console.log(payload.data.verification.status, payload.data.verification.is_minor) + } + res.status(200).end() +}) +``` + +Event types include `verification.completed` and `minor.detected` alongside the detection events. `data.verification` is present on `verification.completed` from a verification session. + --- ### Pricing diff --git a/src/client.ts b/src/client.ts index 524ce59..b01a877 100644 --- a/src/client.ts +++ b/src/client.ts @@ -117,7 +117,10 @@ import { VerificationSession, VerificationSessionResult, VerificationRetrieveResult, + AgeVerificationRetrieveResult, IdentityRetrieveResult, + GetVerificationConfigInput, + VerificationConfig, // Synthetic content types DetectSyntheticTextInput, SyntheticTextResult, @@ -179,6 +182,7 @@ import { NetworkError, QuotaExceededError, TierAccessError, + VerificationLimitError, } from './errors.js'; import { withRetry } from './utils/retry.js'; @@ -673,6 +677,11 @@ export class Tuteliq { throw new ValidationError(message, details, { code, suggestion, links, requestId }); case 401: throw new AuthenticationError(message, { code, suggestion, links, requestId }); + case 402: + if (code === 'VERIFY_11014') { + throw new VerificationLimitError(message, { code, suggestion, links, requestId, details: details as never }); + } + throw new TuteliqError(message, status, details, { code, suggestion, links, requestId }); case 403: throw new TierAccessError(message, { code, suggestion, links, requestId, details: details as never }); case 404: @@ -2869,6 +2878,13 @@ export class Tuteliq { * Returns a session with a `url` to open in a new tab or web view. * The web UI handles all document capture, liveness checks, and submission. * + * Never retried automatically: a retry after a lost response would create + * a second session (and, on plans with a verification allowance, take a + * second unit). On a timeout or network error, create a new session + * yourself if you need one. + * + * @throws {VerificationLimitError} When the monthly verification allowance is used up. + * * @example * ```typescript * const session = await tuteliq.createVerificationSession({ @@ -2887,12 +2903,15 @@ export class Tuteliq { throw new ValidationError('Verification mode must be "age" or "identity"'); } - const response = await this.requestWithRetry<{ + // `request`, not `requestWithRetry`: see the doc comment. Only the + // fields the API accepts are sent. + const response = await this.request<{ session_id: string; mobile_url: string; expires_at: number; mode: VerificationSession['mode']; - verification_mode?: string; + verification_mode?: VerificationSession['verification_mode']; + chip_policy?: VerificationSession['chip_policy']; recommended_image_width?: number; }>( 'POST', @@ -2900,10 +2919,7 @@ export class Tuteliq { { mode: input.mode, ...(input.document_type && { document_type: input.document_type }), - ...(input.redirect_url && { redirect_url: input.redirect_url }), - ...(input.external_id && { external_id: input.external_id }), - ...(input.customer_id && { customer_id: input.customer_id }), - ...(input.metadata && { metadata: input.metadata }), + ...(input.chip_policy && { chip_policy: input.chip_policy }), } ); @@ -2917,6 +2933,7 @@ export class Tuteliq { expires_at: response.expires_at, mode: response.mode, ...(response.verification_mode !== undefined && { verification_mode: response.verification_mode }), + ...(response.chip_policy !== undefined && { chip_policy: response.chip_policy }), ...(response.recommended_image_width !== undefined && { recommended_image_width: response.recommended_image_width }), }; } @@ -2926,14 +2943,19 @@ export class Tuteliq { * * Poll this endpoint to check when verification is complete. * + * The extracted document fields, `date_of_birth`, `failure_reasons` and + * `capture` are released only on the first poll after the session + * completes or fails; later polls return the decision alone. Store them + * on that first read, or use the `verification.completed` webhook. + * * @example * ```typescript * const status = await tuteliq.getVerificationSession('sess_abc123') * - * if (status.status === 'completed') { - * if (status.age_result) { - * console.log('Result:', status.result) - * } + * if (status.status === 'completed' && status.result) { + * console.log('Decision:', status.result.status, 'Minor:', status.result.is_minor) + * // Present on this first read only: + * console.log('Document:', status.result.document) * } * ``` */ @@ -2977,12 +2999,12 @@ export class Tuteliq { * console.log('Is minor:', result.is_minor) * ``` */ - async getAgeVerification(verificationId: string): Promise { + async getAgeVerification(verificationId: string): Promise { if (!verificationId) { throw new ValidationError('Verification ID is required'); } - return this.requestWithRetry( + return this.requestWithRetry( 'GET', `/api/v1/verify/age/${verificationId}` ); @@ -2995,7 +3017,7 @@ export class Tuteliq { * ```typescript * const result = await tuteliq.getIdentityVerification('vrf_abc123') * console.log('Status:', result.status) - * console.log('Name:', result.full_name) + * console.log('Face matched:', result.face_matched) * ``` */ async getIdentityVerification(verificationId: string): Promise { @@ -3009,6 +3031,48 @@ export class Tuteliq { ); } + /** + * Retrieve a past verification result by ID, including its age. + * + * @example + * ```typescript + * const result = await tuteliq.getVerification('vrf_abc123') + * console.log('Age:', result.age, 'Minor:', result.is_minor) + * ``` + */ + async getVerification(verificationId: string): Promise { + if (!verificationId) { + throw new ValidationError('Verification ID is required'); + } + + return this.requestWithRetry( + 'GET', + `/api/v1/verify/${encodeURIComponent(verificationId)}` + ); + } + + /** + * Get the age verification settings that apply to a market under this + * API key: which check a user there will get and the age threshold. + * + * @example + * ```typescript + * const config = await tuteliq.getVerificationConfig({ country: 'US', region: 'CA' }) + * console.log(config.mode, config.age_threshold) + * ``` + */ + async getVerificationConfig(input: GetVerificationConfigInput = {}): Promise { + const params = new URLSearchParams(); + if (input.country) params.set('country', input.country); + if (input.region) params.set('region', input.region); + const query = params.toString(); + + return this.requestWithRetry( + 'GET', + `/api/v1/verify/age/config${query ? `?${query}` : ''}` + ); + } + // ========================================================================= // Voice Streaming // ========================================================================= diff --git a/src/constants.ts b/src/constants.ts index c46f38b..d0e7f02 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -162,6 +162,12 @@ export enum WebhookEventType { GROOMING_DETECTED = 'grooming.detected', SELF_HARM_DETECTED = 'self_harm.detected', BULLYING_SEVERE = 'bullying.severe', + /** A verification session finished. Session-flow deliveries carry the outcome in `data.verification`. */ + VERIFICATION_COMPLETED = 'verification.completed', + /** An age verification found the person to be a minor. */ + MINOR_DETECTED = 'minor.detected', + CSAM_DETECTED = 'csam.detected', + CSAM_CRITICAL = 'csam.critical', } // ============================================================================= @@ -203,6 +209,23 @@ export enum ErrorCode { ANALYSIS_FAILED = 'ANALYSIS_5001', UNSUPPORTED_TYPE = 'ANALYSIS_5002', NO_CONTENT = 'ANALYSIS_5003', + + // Verification errors (VERIFY_*) + VERIFY_NO_DOB_FOUND = 'VERIFY_11002', + VERIFY_FACE_NOT_DETECTED = 'VERIFY_11003', + VERIFY_FACE_MISMATCH = 'VERIFY_11004', + VERIFY_LIVENESS_FAILED = 'VERIFY_11005', + VERIFY_SESSION_NOT_FOUND = 'VERIFY_11006', + VERIFY_SESSION_EXPIRED = 'VERIFY_11007', + VERIFY_SESSION_INVALID_TOKEN = 'VERIFY_11008', + VERIFY_SESSION_ALREADY_USED = 'VERIFY_11009', + VERIFY_SESSION_WRONG_STATE = 'VERIFY_11010', + VERIFY_NOT_ENABLED = 'VERIFY_11011', + VERIFY_IDENTITY_NOT_ENABLED = 'VERIFY_11012', + /** The monthly verification allowance is used up (402). See VerificationLimitError. */ + VERIFICATION_LIMIT_REACHED = 'VERIFY_11014', + /** The verification allowance could not be checked (503). Temporary; try again. */ + VERIFICATION_ALLOWANCE_UNAVAILABLE = 'VERIFY_11015', } // ============================================================================= @@ -324,10 +347,44 @@ export enum VerificationMode { */ export enum DocumentType { PASSPORT = 'passport', - ID_CARD = 'id_card', + NATIONAL_ID = 'national_id', + /** @deprecated Use NATIONAL_ID. Kept as an alias; it sends the same value. */ + ID_CARD = 'national_id', DRIVERS_LICENSE = 'drivers_license', } +/** + * How a passport or ID card chip read counts as document evidence. + * - `optional` (API default): the document photo is required and a chip read adds to it. + * - `required`: the photo and a valid chip read are both required. + * - `instead_of_photo`: a chip read that proves itself genuine replaces the photo; + * otherwise the session asks for a photo. + */ +export enum ChipPolicy { + OPTIONAL = 'optional', + REQUIRED = 'required', + INSTEAD_OF_PHOTO = 'instead_of_photo', +} + +/** + * The check a verification rests on: a document and a live face (`full`), + * or a live face alone (`liveness_only`). + */ +export enum VerificationCheck { + FULL = 'full', + LIVENESS_ONLY = 'liveness_only', +} + +/** + * What the document evidence was, when a result rests on a document: + * the document photo, the chip read, or both. + */ +export enum DocumentEvidence { + PHOTO = 'photo', + CHIP = 'chip', + PHOTO_AND_CHIP = 'photo_and_chip', +} + /** * Verification result status */ @@ -346,6 +403,9 @@ export enum VerificationSessionStatus { COMPLETED = 'completed', FAILED = 'failed', EXPIRED = 'expired', + /** Waiting for the user to add a document photo before the session can finish. Not final. */ + DOCUMENT_REQUIRED = 'document_required', + /** @deprecated The API never returns this. A cancelled session reads as `expired`. */ CANCELLED = 'cancelled', } diff --git a/src/errors.ts b/src/errors.ts index e8540ac..ba6b07e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -181,3 +181,41 @@ export class TierAccessError extends TuteliqError { return this.details as SubscriptionErrorDetails | undefined; } } + +/** + * Details returned when the monthly verification allowance is used up. + * Populated from `error.details` on the API response. + */ +export interface VerificationLimitDetails { + /** Always "verification_limit_reached" */ + reason?: string; + /** Billing period the allowance belongs to */ + period?: string; + /** ISO timestamp (UTC) when the next period, and a fresh allowance, starts */ + next_period_start?: string; + /** Verifications included per month on the current plan */ + included_per_month?: number; + /** Where to buy more verification credits */ + buy_credits_url?: string; +} + +/** + * Error thrown when the monthly verification allowance is used up (402, + * VERIFY_11014). No session was created. New verifications are refused until + * `next_period_start`, or until more credits are bought. + */ +export class VerificationLimitError extends TuteliqError { + constructor( + message = 'The monthly verification allowance is used up.', + meta?: ErrorMeta & { details?: VerificationLimitDetails } + ) { + super(message, 402, meta?.details, meta); + this.name = 'VerificationLimitError'; + Object.setPrototypeOf(this, VerificationLimitError.prototype); + } + + /** Strongly-typed accessor for the allowance details. */ + get limitDetails(): VerificationLimitDetails | undefined { + return this.details as VerificationLimitDetails | undefined; + } +} diff --git a/src/index.ts b/src/index.ts index 623fd5e..7e0b647 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,9 @@ export { DocumentType, VerificationStatus, VerificationSessionStatus, + ChipPolicy, + VerificationCheck, + DocumentEvidence, Detection, CONVERSATION_TYPES, } from './constants.js'; @@ -36,8 +39,13 @@ export { NetworkError, QuotaExceededError, TierAccessError, + VerificationLimitError, } from './errors.js'; -export type { ErrorMeta, SubscriptionErrorDetails } from './errors.js'; +export type { ErrorMeta, SubscriptionErrorDetails, VerificationLimitDetails } from './errors.js'; + +// Webhook signature verification +export { verifyWebhookSignature } from './webhooks.js'; +export type { VerifyWebhookSignatureOptions } from './webhooks.js'; // Type exports export type { @@ -164,6 +172,9 @@ export type { UpdateWebhookResult, DeleteWebhookResult, TestWebhookResult, + WebhookPayload, + WebhookEventData, + VerificationWebhookData, RegenerateSecretResult, // Pricing types @@ -243,7 +254,13 @@ export type { AgeVerificationResult, IdentityVerificationResult, VerificationRetrieveResult, + AgeVerificationRetrieveResult, IdentityRetrieveResult, + GetVerificationConfigInput, + VerificationConfig, + CaptureSummary, + AgeRange, + NationalIdResult, // Synthetic content types SyntheticClassification, @@ -289,7 +306,6 @@ export type { IncidentSource, IncidentSeverity, IncidentCategory, - SpeakerStance, ListIncidentsInput, IncidentSummaryRow, ListIncidentsResult, diff --git a/src/types/incidents.ts b/src/types/incidents.ts index 059056c..dda3692 100644 --- a/src/types/incidents.ts +++ b/src/types/incidents.ts @@ -8,6 +8,8 @@ * fields the caller must decrypt client-side with their RSA private key. */ +import type { SpeakerStance } from './detection-fields.js'; + export type IncidentSource = 'text' | 'voice' | 'image' | 'video' | 'video_stream'; /** An incident's stored `risk_level`. Detection responses and webhooks spell `moderate` as `medium`. */ export type IncidentSeverity = 'low' | 'moderate' | 'high' | 'critical'; @@ -32,9 +34,6 @@ export type IncidentCategory = | 'tfgbv' | 'synthetic_content'; -/** Whether the speaker is doing what was detected or reporting someone else doing it. */ -export type SpeakerStance = 'deploying' | 'describing_third_party' | 'unclear'; - export interface ListIncidentsInput { /** Filter by risk_category (e.g. 'bullying', 'grooming', 'synthetic_content'). */ category?: IncidentCategory | (string & {}); diff --git a/src/types/verification.ts b/src/types/verification.ts index ad21725..4fc67bb 100644 --- a/src/types/verification.ts +++ b/src/types/verification.ts @@ -12,6 +12,9 @@ import { DocumentType, VerificationStatus, VerificationSessionStatus, + ChipPolicy, + VerificationCheck, + DocumentEvidence, } from '../constants.js'; // ============================================================================= @@ -23,16 +26,28 @@ export interface CreateVerificationSessionInput { mode: VerificationMode; /** Preferred document type (optional hint for the web UI) */ document_type?: DocumentType; - /** URL to redirect the user after verification completes (optional) */ + /** How a passport or ID card chip read counts as document evidence. The API defaults to `optional`. */ + chip_policy?: `${ChipPolicy}`; + /** + * @deprecated The API does not accept this field and the SDK no longer sends it. + * Set the redirect URL in the dashboard verification settings instead. + */ redirect_url?: string; - /** Your external reference ID for this verification */ + /** @deprecated The API does not accept this field and the SDK no longer sends it. */ external_id?: string; - /** Your end-customer identifier */ + /** @deprecated The API does not accept this field and the SDK no longer sends it. */ customer_id?: string; - /** Custom key-value metadata */ + /** @deprecated The API does not accept this field and the SDK no longer sends it. */ metadata?: Record; } +export interface GetVerificationConfigInput { + /** ISO 3166-1 alpha-2 country code of the end user (e.g. "DE", "US") */ + country?: string; + /** Region or state code within the country (e.g. "CA") */ + region?: string; +} + // ============================================================================= // Session Types // ============================================================================= @@ -46,8 +61,14 @@ export interface VerificationSession { expires_at: number; /** Verification mode */ mode: VerificationMode; - /** Which checks the session will run (e.g. "document_and_selfie") */ - verification_mode?: string; + /** + * The check configured for this key. For age sessions it may still be + * resolved per market on the user's device; the poll reports the check + * the result actually rests on. + */ + verification_mode?: `${VerificationCheck}`; + /** How a chip read counts as document evidence for this session */ + chip_policy?: `${ChipPolicy}`; /** * Width, in pixels, the capture UI should request from the camera. * Small print (document number, issuing authority, issue date) only @@ -57,19 +78,97 @@ export interface VerificationSession { recommended_image_width?: number; } +/** + * Status and outcome of a verification session, from `getVerificationSession`. + * + * The extracted document fields, `date_of_birth`, `failure_reasons` and + * `capture` are released once: only the first authenticated poll after the + * session reaches `completed` or `failed` carries them. Later polls return the + * decision alone. Persist them on that first read, or subscribe to the + * `verification.completed` webhook, which pushes them as they are produced. + */ export interface VerificationSessionResult { /** Unique session identifier */ session_id: string; /** Current session status */ status: VerificationSessionStatus; - /** Verification mode */ + /** @deprecated Not returned by the poll. Read it from the created session. */ mode?: VerificationMode; - /** Verification result (present when status is 'completed') */ + /** Verification result (null until there is one) */ result?: VerificationResult | null; - /** ISO timestamp when the session was created */ - created_at?: string; - /** ISO timestamp when the session expires */ - expires_at?: string; + /** Present once there is a result: the check it rests on */ + verification_mode?: `${VerificationCheck}`; + /** Present when the result rests on a document: what the evidence was */ + document_evidence?: `${DocumentEvidence}`; + /** + * Name of the step the user has reached, for progress UI + * (e.g. "uploading", "processing", "done"). Not a closed list: new steps may appear. + */ + step_progress?: string | null; + /** How a chip read counts as document evidence, as set at creation */ + chip_policy?: `${ChipPolicy}`; + /** How the uploads were captured, as reported by the capture client. Released once, with the result. */ + capture?: CaptureSummary | null; + /** Epoch milliseconds when the session was created */ + created_at?: number; + /** Epoch milliseconds when the session expires */ + expires_at?: number; +} + +/** + * How the uploads were captured and which on-device checks ran, as the + * capture client reported it. Self-reported: informative, never a verdict. + */ +export interface CaptureSummary { + self_reported: true; + sdk: { name: string; version: string; platform: string; os_version: string; device_model: string } | null; + pipeline: string | null; + pipeline_version: number | null; + face: { + camera: string | null; + liveness_engine: string | null; + liveness_engine_version: number | null; + depth: { + verdict: 'live' | 'flat' | 'inconclusive'; + frames_with_depth: number | null; + nose_relief_mm: number | null; + interpupillary_mm: number | null; + distance_cm: number | null; + } | null; + } | null; + document: { + camera: string | null; + auto_captured: boolean | null; + liveness: { + verdict: 'genuine' | 'suspicious' | 'inconclusive'; + tilt_range_degrees: number | null; + optical_variability: number | null; + screen_score: number | null; + reasons: string[]; + } | null; + } | null; + chip: { + access_protocol: string | null; + access_key: string | null; + access_key_from_photo: boolean | null; + chip_authentication: string | null; + active_authentication: string | null; + data_groups: number[]; + } | null; + /** What each uploaded liveness frame shows, in upload order */ + liveness_frame_kinds: string[] | null; +} + +/** Age verification settings that apply to a market, from `getVerificationConfig`. */ +export interface VerificationConfig { + /** The check a user in this market will get under this key's settings */ + mode: `${VerificationCheck}`; + /** Age at or above which a person counts as an adult in this market */ + age_threshold: number; + /** Country the settings were resolved for (upper-cased), when one was given */ + country?: string; + /** Market the country belongs to, when it maps to one */ + market?: string; } // ============================================================================= @@ -94,7 +193,7 @@ export interface MrzFields { given_names: string | null; } -/** PDF417/barcode data (US/CA driver's licenses) */ +/** @deprecated Not returned by the session poll or the webhook. */ export interface BarcodeResult { /** Barcode format (e.g., "PDF417") */ format: string; @@ -112,7 +211,7 @@ export interface BarcodeResult { }; } -/** AI-powered document authenticity analysis */ +/** @deprecated Not returned by the session poll or the webhook. */ export interface DocumentAuthenticityResult { /** Whether the document appears authentic */ is_authentic: boolean | null; @@ -132,30 +231,70 @@ export interface DocumentAuthenticityResult { overall_assessment: string | null; } -/** Document extraction and validation details */ +/** A national identity number found on the document */ +export interface NationalIdResult { + value: string; + /** Whether the number passed its country's validation */ + valid: boolean; + /** Kind of number (country-specific) */ + type: string; + /** ISO country code the number belongs to */ + country_code: string; +} + +/** + * Document extraction and validation details. + * + * Only `ocr_confidence` is always present. The extracted fields are released + * once (see `VerificationSessionResult`) and absent on later polls. + */ export interface DocumentDetails { /** OCR confidence (0-100) */ ocr_confidence: number; /** Full name extracted from document */ - name_extracted: string | null; + name_extracted?: string | null; + /** Given name(s) */ + first_name?: string | null; + /** Surname */ + last_name?: string | null; /** DOB extracted from document */ - dob_extracted: string | null; + dob_extracted?: string | null; /** Document number */ - document_number: string | null; - /** Whether document number passed algorithmic validation (45 countries) */ - document_number_valid: boolean | null; + document_number?: string | null; + /** Whether document number passed algorithmic validation */ + document_number_valid?: boolean | null; + /** Sex as printed on the document */ + sex?: string | null; /** ISO country code */ - country_code: string | null; + country_code?: string | null; + /** Country name */ + country_name?: string | null; /** Detected document type */ - document_type: string | null; + document_type?: string | null; /** Expiration date */ - expiration_date: string | null; + expiration_date?: string | null; /** Whether the document has expired */ - expired: boolean | null; - /** Whether MRZ check digits passed (null if no MRZ) */ - mrz_valid: boolean | null; - /** Parsed MRZ fields (null if no MRZ) */ - mrz_fields: MrzFields | null; + expired?: boolean | null; + /** Issue date */ + issue_date?: string | null; + /** Height in centimetres, where the document states it */ + height_cm?: number | null; + /** Place of birth */ + place_of_birth?: string | null; + /** Nationality */ + nationality?: string | null; + /** Issuing authority */ + issuing_authority?: string | null; + /** The document data came from the document's chip and passed its integrity check */ + chip_verified?: boolean; + /** The chip's signer chains to a trusted issuing country. Null when the chip was not used or this could not be checked. */ + chip_issuer_trusted?: boolean | null; + /** National identity number, when the document carries one */ + national_id?: NationalIdResult | null; + /** Whether MRZ check digits passed (null if no MRZ). Webhook deliveries only. */ + mrz_valid?: boolean | null; + /** Parsed MRZ fields (null if no MRZ). Webhook deliveries only. */ + mrz_fields?: MrzFields | null; } // ============================================================================= @@ -165,8 +304,8 @@ export interface DocumentDetails { export interface FaceMatchResult { /** Whether ID face matches selfie */ matched: boolean; - /** Euclidean distance between face descriptors (lower = more similar) */ - distance: number; + /** Distance between the two faces (lower = more similar). Webhook deliveries only. */ + distance?: number; /** Confidence score (0-1, higher = more confident) */ confidence: number; } @@ -174,37 +313,43 @@ export interface FaceMatchResult { export interface LivenessResult { /** Whether liveness check passed */ valid: boolean; - /** Reason for failure (if not valid) */ + /** Reason for failure (if not valid). Webhook deliveries only. */ reason?: string; } +/** Range the true age likely falls in, when the age came from the selfie alone */ +export interface AgeRange { + low: number; + high: number; +} + /** - * Full verification result returned via session. + * Verification result returned via the session poll. * - * Includes document intelligence (MRZ, barcode, authenticity), - * face matching, and liveness validation. + * `date_of_birth`, `failure_reasons` and the extracted `document` fields are + * released once, on the first authenticated poll after completion. */ export interface VerificationResult { /** Verification status */ status: 'verified' | 'failed' | 'needs_review'; /** Calculated age (null if not determined) */ age: number | null; - /** Date of birth in YYYY-MM-DD format */ - date_of_birth: string | null; - /** Whether the person is under 18 */ + /** When the age came from the selfie alone: the range the true age likely falls in */ + age_range?: AgeRange | null; + /** Date of birth in YYYY-MM-DD format. Released once. */ + date_of_birth?: string | null; + /** Whether the person is under the age threshold */ is_minor: boolean | null; - /** Document extraction and validation details */ - document: DocumentDetails; - /** PDF417/barcode data (present for US/CA driver's licenses) */ - barcode: BarcodeResult | null; - /** AI-powered document authenticity analysis */ - document_authenticity: DocumentAuthenticityResult | null; + /** What the document evidence was, when the result rests on a document */ + document_evidence?: `${DocumentEvidence}`; + /** Document extraction and validation details (null when no document was used) */ + document: DocumentDetails | null; /** Face comparison results */ face_match: FaceMatchResult | null; /** Liveness check results */ liveness: LivenessResult; - /** Reasons for any failures (empty array if fully verified) */ - failure_reasons: string[]; + /** Reasons for any failures. Free text, not a closed list. Released once. */ + failure_reasons?: string[] | null; } /** @deprecated Use VerificationResult instead — returned via session flow */ @@ -251,6 +396,7 @@ export interface IdentityVerificationResult { credits_used: number; } +/** A past verification, from `getVerification` (`GET /verify/{id}`). */ export interface VerificationRetrieveResult { /** Unique verification ID */ verification_id: string; @@ -260,6 +406,8 @@ export interface VerificationRetrieveResult { age: number | null; /** Whether the person is under 18 (null if unknown) */ is_minor: boolean | null; + /** OCR confidence of the document read (0-100) */ + ocr_confidence?: number; /** Whether face matched */ face_matched: boolean | null; /** Face match confidence */ @@ -272,19 +420,30 @@ export interface VerificationRetrieveResult { created_at: string; } +/** A past age verification, from `getAgeVerification`. Carries no age and no personal data. */ +export interface AgeVerificationRetrieveResult { + /** Unique verification ID */ + verification_id: string; + /** Verification status */ + status: VerificationStatus; + /** Whether the person is under 18 (null if unknown) */ + is_minor: boolean | null; + /** Whether face matched */ + face_matched: boolean | null; + /** Whether liveness check passed */ + liveness_valid: boolean; + /** Reasons for failure */ + failure_reasons: string[]; + /** ISO timestamp of verification */ + created_at: string; +} + +/** A past identity verification, from `getIdentityVerification`. Carries no personal data. */ export interface IdentityRetrieveResult { /** Unique verification ID */ verification_id: string; /** Verification status */ status: VerificationStatus; - /** Full name extracted from document */ - full_name?: string; - /** Date of birth in YYYY-MM-DD format */ - date_of_birth?: string; - /** Document type used */ - document_type?: string; - /** ISO 3166-1 alpha-2 country code */ - country_code?: string; /** Whether face matched */ face_matched: boolean | null; /** Face match confidence */ diff --git a/src/types/webhooks.ts b/src/types/webhooks.ts index c89810e..e635a4f 100644 --- a/src/types/webhooks.ts +++ b/src/types/webhooks.ts @@ -1,4 +1,5 @@ -import { WebhookEventType } from '../constants.js'; +import { WebhookEventType, VerificationCheck, DocumentEvidence } from '../constants.js'; +import type { AgeRange, DocumentDetails, FaceMatchResult, LivenessResult } from './verification.js'; // Re-export for convenience export { WebhookEventType }; @@ -21,10 +22,10 @@ export interface Webhook { is_active: boolean; /** Consecutive delivery failure count */ failure_count: number; - /** Last time the webhook was triggered (ISO timestamp) */ - last_triggered_at?: string; - /** Last delivery error message */ - last_error?: string; + /** Last time the webhook was triggered (ISO timestamp), null if never */ + last_triggered_at?: string | null; + /** Last delivery error message, null after a successful delivery */ + last_error?: string | null; /** Creation timestamp (ISO) */ created_at: string; /** Last update timestamp (ISO) */ @@ -122,3 +123,75 @@ export interface RegenerateSecretResult { /** The new signing secret */ secret: string; } + +// ============================================================================= +// Webhook Deliveries +// ============================================================================= + +/** + * Verification outcome pushed with `verification.completed` from the session + * flow. This is the push channel for the extracted document fields: it carries + * them as they are produced, so they never depend on a poll. + * Treat it as personal data. + */ +export interface VerificationWebhookData { + status: 'verified' | 'failed' | 'needs_review'; + /** The check the result rests on */ + verification_mode: `${VerificationCheck}`; + /** What the document evidence was. Present only for `full`. */ + document_evidence?: `${DocumentEvidence}`; + age: number | null; + /** When the age came from the selfie alone: the range the true age likely falls in */ + age_range: AgeRange | null; + is_minor: boolean | null; + date_of_birth: string | null; + /** Extracted document fields. Null when no document was captured. */ + document: DocumentDetails | null; + face_match: FaceMatchResult | null; + liveness: LivenessResult | null; + failure_reasons?: string[] | null; +} + +/** The `data` object of a webhook delivery. Note the camelCase keys. */ +export interface WebhookEventData { + /** + * What produced the event, e.g. `age_verification`, `identity_verification`, + * `minor_detected`, or a detection type. + */ + type: string; + severity: string; + riskScore: number; + summary?: string; + categories?: string[]; + recommendedAction?: string; + /** True where delay is itself the harm, so the event can be routed to an on-call path */ + urgentAlert?: boolean; + urgentReason?: string | null; + /** Your correlation fields, echoed from the originating request */ + externalId?: string; + customerId?: string; + metadata?: Record; + /** + * Present on `verification.completed` from a verification session. + * Absent on other events and on verifications made outside a session. + */ + verification?: VerificationWebhookData; +} + +/** + * The JSON body of a webhook delivery. Verify the signature over the raw body + * with `verifyWebhookSignature` before parsing it, and drop deliveries whose + * `id` you have already processed: retries reuse the same `id`. + */ +export interface WebhookPayload { + /** Unique delivery ID (UUID). Use it to drop duplicates and replays. */ + id: string; + event: WebhookEventType; + /** ISO timestamp of the event */ + timestamp: string; + data: WebhookEventData; + metadata?: { + userId: string; + requestId?: string; + }; +} diff --git a/src/webhooks.ts b/src/webhooks.ts new file mode 100644 index 0000000..b41a5f9 --- /dev/null +++ b/src/webhooks.ts @@ -0,0 +1,127 @@ +/** + * Webhook signature verification. + * + * Every delivery carries + * + * X-Tuteliq-Signature-V2: t=,v1= + * + * The timestamp is inside the signed string, so a captured delivery cannot be + * given a fresh one. Uses Web Crypto, so it runs in Node 18+, browsers and + * edge runtimes. + */ + +export interface VerifyWebhookSignatureOptions { + /** Maximum age (or clock skew ahead) of a delivery, in seconds. Default 300. */ + toleranceSeconds?: number; + /** Current time in epoch milliseconds. Defaults to `Date.now()`; for tests. */ + now?: number; +} + +const DEFAULT_TOLERANCE_SECONDS = 300; +const SIGNATURE_HEX = /^[0-9a-f]{64}$/i; + +interface ParsedSignatureHeader { + timestamp: number; + signatures: string[]; +} + +function parseSignatureHeader(header: string): ParsedSignatureHeader | null { + let timestamp: number | null = null; + const signatures: string[] = []; + + for (const part of header.split(',')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + const key = part.slice(0, eq).trim(); + const value = part.slice(eq + 1).trim(); + if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value); + if (key === 'v1' && SIGNATURE_HEX.test(value)) signatures.push(value); + } + + if (timestamp === null || signatures.length === 0) return null; + return { timestamp, signatures }; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +async function getSubtle(): Promise { + if (globalThis.crypto?.subtle) return globalThis.crypto.subtle; + // Node 18 does not expose Web Crypto as a global. + const { webcrypto } = await import('node:crypto'); + return webcrypto.subtle as SubtleCrypto; +} + +function signedBytes(timestamp: number, rawBody: string | Uint8Array): Uint8Array { + const encoder = new TextEncoder(); + const prefix = encoder.encode(`${timestamp}.`); + const body = typeof rawBody === 'string' ? encoder.encode(rawBody) : rawBody; + const out = new Uint8Array(prefix.length + body.length); + out.set(prefix, 0); + out.set(body, prefix.length); + return out; +} + +/** + * Verify the `X-Tuteliq-Signature-V2` header of a webhook delivery. + * + * Pass the raw request body exactly as received (before any JSON parsing), + * the header value, and the webhook's signing secret. Resolves `true` only + * when the signature matches and the timestamp is within the tolerance + * window. The comparison is constant-time. For full replay protection, also + * drop deliveries whose payload `id` you have already processed. + * + * @example + * ```typescript + * import { verifyWebhookSignature, type WebhookPayload } from '@tuteliq/sdk' + * + * app.post('/webhooks/tuteliq', express.raw({ type: 'application/json' }), async (req, res) => { + * const ok = await verifyWebhookSignature( + * req.body, + * req.get('X-Tuteliq-Signature-V2'), + * process.env.TUTELIQ_WEBHOOK_SECRET!, + * ) + * if (!ok) return res.status(400).end() + * const payload: WebhookPayload = JSON.parse(req.body.toString('utf8')) + * // ... + * res.status(200).end() + * }) + * ``` + */ +export async function verifyWebhookSignature( + rawBody: string | Uint8Array, + header: string | string[] | null | undefined, + secret: string, + options: VerifyWebhookSignatureOptions = {} +): Promise { + const value = Array.isArray(header) ? header[0] : header; + if (!value || !secret) return false; + + const parsed = parseSignatureHeader(value); + if (!parsed) return false; + + const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS; + const nowSeconds = Math.floor((options.now ?? Date.now()) / 1000); + if (Math.abs(nowSeconds - parsed.timestamp) > tolerance) return false; + + const subtle = await getSubtle(); + const key = await subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + const data = signedBytes(parsed.timestamp, rawBody); + + // subtle.verify compares in constant time. + for (const signature of parsed.signatures) { + if (await subtle.verify('HMAC', key, hexToBytes(signature), data)) return true; + } + return false; +} diff --git a/tests/batch.test.ts b/tests/batch.test.ts index 75869b9..646a20f 100644 --- a/tests/batch.test.ts +++ b/tests/batch.test.ts @@ -322,7 +322,7 @@ describe('Tuteliq.createVerificationSession', () => { mobile_url: 'https://verify.tuteliq.ai/age/?session=sess-1&token=t', expires_at: 1787253193747, mode: 'age', - verification_mode: 'document_and_selfie', + verification_mode: 'full', recommended_image_width: 3264, }), ); @@ -331,6 +331,6 @@ describe('Tuteliq.createVerificationSession', () => { expect(session.url).toContain('/age/?session=sess-1'); expect(session.recommended_image_width).toBe(3264); - expect(session.verification_mode).toBe('document_and_selfie'); + expect(session.verification_mode).toBe('full'); }); }); diff --git a/tests/client.test.ts b/tests/client.test.ts index d3e0110..41fa752 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -592,7 +592,7 @@ describe('Tuteliq', () => { const mockResponse = { session_id: 'sess_abc123', mobile_url: 'https://verify.tuteliq.ai/age/?session=sess_abc123&token=tok_xyz789', - expires_at: '2026-03-05T12:00:00Z', + expires_at: 1772712000000, mode: 'age', }; @@ -617,7 +617,7 @@ describe('Tuteliq', () => { const mockResponse = { session_id: 'sess_def456', mobile_url: 'https://verify.tuteliq.ai/identity/?session=sess_def456&token=tok_abc123', - expires_at: '2026-03-05T12:00:00Z', + expires_at: 1772712000000, mode: 'identity', }; @@ -638,10 +638,11 @@ describe('Tuteliq', () => { const body = JSON.parse(call[1]?.body as string); expect(body.mode).toBe(VerificationMode.IDENTITY); expect(body.document_type).toBe(DocumentType.PASSPORT); - expect(body.redirect_url).toBe('https://example.com/done'); - expect(body.external_id).toBe('ext_123'); - expect(body.customer_id).toBe('cust_456'); - expect(body.metadata).toEqual({ source: 'onboarding' }); + // The API does not accept these, so they are not sent. + expect(body).not.toHaveProperty('redirect_url'); + expect(body).not.toHaveProperty('external_id'); + expect(body).not.toHaveProperty('customer_id'); + expect(body).not.toHaveProperty('metadata'); }); it('should reject invalid verification mode', async () => { @@ -697,10 +698,8 @@ describe('Tuteliq', () => { const mockResponse = { verification_id: 'vrf_001', status: VerificationStatus.VERIFIED, - age: 22, is_minor: false, face_matched: true, - face_confidence: 0.95, liveness_valid: true, failure_reasons: [], created_at: '2026-03-05T11:00:00Z', @@ -726,10 +725,6 @@ describe('Tuteliq', () => { const mockResponse = { verification_id: 'vrf_002', status: VerificationStatus.VERIFIED, - full_name: 'John Doe', - date_of_birth: '1990-01-15', - document_type: DocumentType.PASSPORT, - country_code: 'US', face_matched: true, face_confidence: 0.92, liveness_valid: true, @@ -742,7 +737,7 @@ describe('Tuteliq', () => { const result = await tuteliq.getIdentityVerification('vrf_002'); expect(result.status).toBe(VerificationStatus.VERIFIED); - expect(result.full_name).toBe('John Doe'); + expect(result.face_matched).toBe(true); expect(fetch).toHaveBeenCalledWith( `${API_BASE_URL}/api/v1/verify/identity/vrf_002`, expect.objectContaining({ method: 'GET' }) diff --git a/tests/verification.test.ts b/tests/verification.test.ts new file mode 100644 index 0000000..7e3b4be --- /dev/null +++ b/tests/verification.test.ts @@ -0,0 +1,376 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createHmac } from 'node:crypto'; +import { Tuteliq } from '../src/client.js'; +import { TimeoutError, ServerError, VerificationLimitError } from '../src/errors.js'; +import { + DocumentType, + VerificationMode, + ChipPolicy, + VerificationCheck, + DocumentEvidence, + VerificationSessionStatus, + WebhookEventType, +} from '../src/constants.js'; +import { verifyWebhookSignature } from '../src/webhooks.js'; +import type { VerificationSessionResult, WebhookPayload } from '../src/types/index.js'; + +const API_BASE_URL = 'https://api.tuteliq.ai'; + +function mockFetchResponse(data: unknown, options: { ok?: boolean; status?: number; headers?: Record } = {}) { + const { ok = true, status = 200, headers = {} } = options; + return { + ok, + status, + json: async () => data, + headers: { + get: (name: string) => headers[name.toLowerCase()] || null, + }, + } as Response; +} + +function sentBody(fetchSpy: ReturnType, call = 0): Record { + return JSON.parse((fetchSpy.mock.calls[call][1] as RequestInit).body as string); +} + +const createdSession = { + session_id: 'sess_example_1', + mobile_url: 'https://verify.tuteliq.ai/age/?session=sess_example_1&token=tok_example', + expires_at: 1787253193747, + mode: 'age', + verification_mode: 'full', + chip_policy: 'instead_of_photo', + recommended_image_width: 3264, +}; + +describe('verification constants', () => { + it('DocumentType sends the national ID value the API accepts', () => { + expect(DocumentType.NATIONAL_ID).toBe('national_id'); + // Kept for existing callers; it now sends the accepted value. + expect(DocumentType.ID_CARD).toBe('national_id'); + expect([...new Set(Object.values(DocumentType))].sort()).toEqual(['drivers_license', 'national_id', 'passport']); + }); + + it('session status includes document_required', () => { + expect(VerificationSessionStatus.DOCUMENT_REQUIRED).toBe('document_required'); + }); + + it('exposes chip policy, check and document evidence values', () => { + expect(Object.values(ChipPolicy)).toEqual(['optional', 'required', 'instead_of_photo']); + expect(Object.values(VerificationCheck)).toEqual(['full', 'liveness_only']); + expect(Object.values(DocumentEvidence)).toEqual(['photo', 'chip', 'photo_and_chip']); + }); + + it('webhook events include verification and CSAM events', () => { + expect(WebhookEventType.VERIFICATION_COMPLETED).toBe('verification.completed'); + expect(WebhookEventType.MINOR_DETECTED).toBe('minor.detected'); + expect(WebhookEventType.CSAM_DETECTED).toBe('csam.detected'); + expect(WebhookEventType.CSAM_CRITICAL).toBe('csam.critical'); + }); +}); + +describe('createVerificationSession', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sends DocumentType.ID_CARD as national_id', async () => { + const client = new Tuteliq('test-api-key', { retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(createdSession)); + + await client.createVerificationSession({ mode: VerificationMode.IDENTITY, document_type: DocumentType.ID_CARD }); + + expect(sentBody(fetchSpy).document_type).toBe('national_id'); + }); + + it('sends chip_policy and returns it', async () => { + const client = new Tuteliq('test-api-key', { retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(createdSession)); + + const session = await client.createVerificationSession({ + mode: VerificationMode.AGE, + chip_policy: ChipPolicy.INSTEAD_OF_PHOTO, + }); + + expect(sentBody(fetchSpy).chip_policy).toBe('instead_of_photo'); + expect(session.chip_policy).toBe('instead_of_photo'); + expect(session.verification_mode).toBe('full'); + }); + + it('does not send fields the API does not accept', async () => { + const client = new Tuteliq('test-api-key', { retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(createdSession)); + + await client.createVerificationSession({ + mode: VerificationMode.AGE, + redirect_url: 'https://example.com/done', + external_id: 'ext_example', + customer_id: 'cust_example', + metadata: { source: 'onboarding' }, + }); + + expect(sentBody(fetchSpy)).toEqual({ mode: 'age' }); + }); + + it('is not retried after a timeout, so a lost response cannot create a second session', async () => { + const client = new Tuteliq('test-api-key', { retries: 3, retryDelay: 1 }); + const abort = Object.assign(new Error('aborted'), { name: 'AbortError' }); + const fetchSpy = vi.spyOn(global, 'fetch') + .mockRejectedValueOnce(abort) + .mockResolvedValue(mockFetchResponse(createdSession)); + + await expect(client.createVerificationSession({ mode: VerificationMode.AGE })).rejects.toThrow(TimeoutError); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('is not retried after a server error', async () => { + const client = new Tuteliq('test-api-key', { retries: 3, retryDelay: 1 }); + const fetchSpy = vi.spyOn(global, 'fetch') + .mockResolvedValueOnce(mockFetchResponse( + { error: { code: 'VERIFY_11015', message: 'The verification allowance could not be checked.' } }, + { ok: false, status: 503 }, + )) + .mockResolvedValue(mockFetchResponse(createdSession)); + + await expect(client.createVerificationSession({ mode: VerificationMode.AGE })).rejects.toThrow(ServerError); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('surfaces an exhausted verification allowance with its details', async () => { + const client = new Tuteliq('test-api-key', { retries: 0 }); + const details = { + reason: 'verification_limit_reached', + period: '2026-11', + next_period_start: '2026-12-01T00:00:00.000Z', + included_per_month: 500, + buy_credits_url: 'https://tuteliq.ai/dashboard/billing', + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse( + { error: { code: 'VERIFY_11014', message: 'Your monthly verification allowance is used up.', details, request_id: 'req_example' } }, + { ok: false, status: 402 }, + )); + + const error = await client.createVerificationSession({ mode: VerificationMode.AGE }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(VerificationLimitError); + const limit = error as VerificationLimitError; + expect(limit.statusCode).toBe(402); + expect(limit.code).toBe('VERIFY_11014'); + expect(limit.requestId).toBe('req_example'); + expect(limit.limitDetails).toEqual(details); + }); +}); + +describe('getVerificationSession', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the served result, including the one-time document fields', async () => { + const client = new Tuteliq('test-api-key', { retries: 0 }); + const served = { + session_id: 'sess_example_1', + status: 'completed', + result: { + status: 'verified', + age: 34, + age_range: null, + is_minor: false, + document_evidence: 'photo_and_chip', + face_match: { matched: true, confidence: 0.97 }, + liveness: { valid: true }, + date_of_birth: '1992-04-01', + failure_reasons: null, + document: { + ocr_confidence: 91, + first_name: 'Alex', + last_name: 'Example', + document_type: 'passport', + chip_verified: true, + chip_issuer_trusted: true, + national_id: null, + }, + }, + verification_mode: 'full', + document_evidence: 'photo_and_chip', + step_progress: 'done', + chip_policy: 'optional', + capture: null, + created_at: 1787253000000, + expires_at: 1787253900000, + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(served)); + + const status: VerificationSessionResult = await client.getVerificationSession('sess_example_1'); + + expect(status.created_at).toBe(1787253000000); + expect(status.verification_mode).toBe(VerificationCheck.FULL); + expect(status.document_evidence).toBe(DocumentEvidence.PHOTO_AND_CHIP); + expect(status.result?.document?.chip_verified).toBe(true); + expect(status.result?.document?.last_name).toBe('Example'); + }); +}); + +describe('getVerificationConfig', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('hits GET /api/v1/verify/age/config with country and region', async () => { + const client = new Tuteliq('test-api-key', { retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ mode: 'liveness_only', age_threshold: 18, country: 'US', market: 'US' }), + ); + + const config = await client.getVerificationConfig({ country: 'US', region: 'CA' }); + + expect(fetchSpy.mock.calls[0][0]).toBe(`${API_BASE_URL}/api/v1/verify/age/config?country=US®ion=CA`); + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe('GET'); + expect(config.mode).toBe(VerificationCheck.LIVENESS_ONLY); + expect(config.age_threshold).toBe(18); + }); + + it('sends no query string without arguments', async () => { + const client = new Tuteliq('test-api-key', { retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ mode: 'full', age_threshold: 18 }), + ); + + await client.getVerificationConfig(); + + expect(fetchSpy.mock.calls[0][0]).toBe(`${API_BASE_URL}/api/v1/verify/age/config`); + }); +}); + +describe('getVerification', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('hits GET /api/v1/verify/{id}', async () => { + const client = new Tuteliq('test-api-key', { retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ + verification_id: 'vrf_example', + status: 'verified', + age: 30, + is_minor: false, + ocr_confidence: 88, + face_matched: true, + face_confidence: 0.95, + liveness_valid: true, + failure_reasons: [], + created_at: '2026-11-01T10:00:00.000Z', + })); + + const result = await client.getVerification('vrf_example'); + + expect(fetchSpy.mock.calls[0][0]).toBe(`${API_BASE_URL}/api/v1/verify/vrf_example`); + expect(result.ocr_confidence).toBe(88); + }); + + it('requires an ID', async () => { + const client = new Tuteliq('test-api-key', { retries: 0 }); + await expect(client.getVerification('')).rejects.toThrow('Verification ID is required'); + }); +}); + +describe('verifyWebhookSignature', () => { + const secret = 'whsec_test_example_secret'; + const body = '{"id":"00000000-0000-4000-8000-000000000001","event":"verification.completed"}'; + const t = 1786000000; + // Computed independently: hex HMAC-SHA256(secret, `${t}.${body}`). + const knownV1 = '1e2bc5967c6151f8609014ae84da2bcc8e2946368ba3a190393dfc651da8dd58'; + const now = t * 1000; + + // The same construction the API uses to sign each delivery. + function sign(payload: string, at: number, key = secret): string { + const v1 = createHmac('sha256', key).update(`${at}.${payload}`).digest('hex'); + return `t=${at},v1=${v1}`; + } + + it('accepts a known vector', async () => { + expect(sign(body, t)).toBe(`t=${t},v1=${knownV1}`); + await expect(verifyWebhookSignature(body, `t=${t},v1=${knownV1}`, secret, { now })).resolves.toBe(true); + }); + + it('accepts the raw body as bytes', async () => { + const bytes = new TextEncoder().encode(body); + await expect(verifyWebhookSignature(bytes, sign(body, t), secret, { now })).resolves.toBe(true); + }); + + it('accepts a header given as an array, as some frameworks pass repeated headers', async () => { + await expect(verifyWebhookSignature(body, [sign(body, t)], secret, { now })).resolves.toBe(true); + }); + + it('rejects a modified body', async () => { + await expect(verifyWebhookSignature(body.replace('verification', 'minor'), sign(body, t), secret, { now })).resolves.toBe(false); + }); + + it('rejects the wrong secret', async () => { + await expect(verifyWebhookSignature(body, sign(body, t, 'whsec_other'), secret, { now })).resolves.toBe(false); + }); + + it('rejects a timestamp swapped onto a captured signature', async () => { + const v1 = sign(body, t).split(',')[1]; + await expect(verifyWebhookSignature(body, `t=${t + 60},${v1}`, secret, { now })).resolves.toBe(false); + }); + + it('rejects a delivery outside the tolerance window, in either direction', async () => { + await expect(verifyWebhookSignature(body, sign(body, t - 301), secret, { now })).resolves.toBe(false); + await expect(verifyWebhookSignature(body, sign(body, t + 301), secret, { now })).resolves.toBe(false); + await expect(verifyWebhookSignature(body, sign(body, t - 300), secret, { now })).resolves.toBe(true); + }); + + it('honours a custom tolerance', async () => { + await expect(verifyWebhookSignature(body, sign(body, t - 30), secret, { now, toleranceSeconds: 10 })).resolves.toBe(false); + await expect(verifyWebhookSignature(body, sign(body, t - 30), secret, { now, toleranceSeconds: 60 })).resolves.toBe(true); + }); + + it('defaults the clock to the current time', async () => { + const at = Math.floor(Date.now() / 1000); + await expect(verifyWebhookSignature(body, sign(body, at), secret)).resolves.toBe(true); + }); + + it.each([ + ['missing header', undefined], + ['empty header', ''], + ['no timestamp', `v1=${knownV1}`], + ['no signature', `t=${t}`], + ['non-numeric timestamp', `t=abc,v1=${knownV1}`], + ['non-hex signature', `t=${t},v1=${'z'.repeat(64)}`], + ['short signature', `t=${t},v1=${knownV1.slice(0, 62)}`], + ['legacy body-only signature', createHmac('sha256', secret).update(body).digest('hex')], + ])('rejects a malformed header (%s)', async (_label, header) => { + await expect(verifyWebhookSignature(body, header, secret, { now })).resolves.toBe(false); + }); + + it('rejects an empty secret', async () => { + await expect(verifyWebhookSignature(body, sign(body, t, ''), '', { now })).resolves.toBe(false); + }); + + it('types a verification delivery', () => { + const payload: WebhookPayload = { + id: '00000000-0000-4000-8000-000000000001', + event: WebhookEventType.VERIFICATION_COMPLETED, + timestamp: '2026-11-01T10:00:00.000Z', + data: { + type: 'age_verification', + severity: 'low', + riskScore: 0.1, + summary: 'Verification verified', + verification: { + status: 'verified', + verification_mode: 'liveness_only', + age: 31, + age_range: { low: 27, high: 35 }, + is_minor: false, + date_of_birth: null, + document: null, + face_match: null, + liveness: { valid: true }, + failure_reasons: null, + }, + }, + }; + expect(payload.data.verification?.age_range?.low).toBe(27); + }); +});