// Programme D2 — persistent customer accounts + multi-device passkeys. // Typed client for the /identity/v1 surface. The session lives in an HttpOnly // `amanpay_session` cookie the server sets; the browser never reads it. State-changing // requests carry `X-CSRF-Token` (the csrf_token returned by enroll/login/stepup/recovery). // WebAuthn challenges are ALWAYS produced by the backend — we only run the ceremonies. import { apiRequest, ApiRequestError } from './client' import { isWebAuthnSupported, PasskeyCancelled, PasskeyUnsupported, serializeAssertion, serializeRegistration, toCreationOptions, toRequestOptions, } from '../auth/passkey' const V1 = '/identity/v1' // ---- Types (only operator-safe / customer-safe fields are modelled) ---- export interface D2Account { id: string public_handle: string display_alias: string role: string status: string preferred_language: string profile_generation?: number passkey_count?: number } export interface D2Passkey { id: string nickname: string device_type: string backup_eligible: boolean backup_state: string transports: string[] created_at: string last_used_at: string | null revoked: boolean } export interface D2Session { id: string created_at: string last_seen_at: string device_nickname: string current: boolean } export interface D2Invitation { id: string role: string purpose: string max_uses: number use_count: number expires_at: string created_at: string revoked: boolean claimed: boolean } export interface D2OperatorAccount { id: string public_handle: string display_alias: string role: string status: string preferred_language: string passkey_count: number active_session_count: number created_at: string } export interface D2AccountEvent { id: string kind: string at: string detail?: string } export interface InvitationValidation { valid: boolean role: string purpose: string tenant: { name_en: string; name_ar: string } } export interface AuthResult { account: D2Account csrf_token: string } export interface DeletionReceipt { deleted: boolean retired_generation: number note: string } export interface EnrollProfile { code: string public_handle: string display_alias: string preferred_language: string consent_accepted: boolean } // ---- In-memory D2 store (mirrors auth/session.ts; lost on refresh by design) ---- let d2Account: D2Account | null = null let d2Csrf: string | null = null const listeners = new Set<() => void>() function emit(): void { listeners.forEach((fn) => fn()) } export function setD2Account(account: D2Account | null): void { d2Account = account emit() } export function getD2Account(): D2Account | null { return d2Account } export function setD2Csrf(token: string | null): void { d2Csrf = token emit() } export function getD2Csrf(): string | null { return d2Csrf } /** Clears the in-memory account + csrf (server clears the cookie separately). */ export function clearD2(): void { d2Account = null d2Csrf = null emit() } export function onD2Change(fn: () => void): () => void { listeners.add(fn) return () => { listeners.delete(fn) } } function csrfHeaders(): Record { const c = getD2Csrf() return c ? { 'X-CSRF-Token': c } : {} } function adopt(r: AuthResult): AuthResult { setD2Account(r.account) setD2Csrf(r.csrf_token) return r } /** True when an error is the backend's recent-auth challenge (403 recent_auth_required). */ export function isRecentAuthRequired(e: unknown): boolean { return e instanceof ApiRequestError && e.status === 403 && /recent_auth_required/.test(e.message) } /** True when a passkey delete was blocked because it is the account's only passkey. */ export function isFinalPasskeyProtected(e: unknown): boolean { return e instanceof ApiRequestError && e.status === 422 && /final_passkey_protected/.test(e.message) } // ---- WebAuthn ceremony helpers (reuse auth/passkey serializers) ---- async function runCreate(options: unknown): Promise { if (!isWebAuthnSupported()) throw new PasskeyUnsupported('WebAuthn not supported') let cred: PublicKeyCredential | null try { cred = (await navigator.credentials.create(toCreationOptions(options))) as PublicKeyCredential | null } catch (e) { throw new PasskeyCancelled((e as Error)?.name ?? 'cancelled') } if (!cred) throw new PasskeyCancelled('no credential') return serializeRegistration(cred) } async function runGet(options: unknown): Promise { if (!isWebAuthnSupported()) throw new PasskeyUnsupported('WebAuthn not supported') let cred: PublicKeyCredential | null try { cred = (await navigator.credentials.get(toRequestOptions(options))) as PublicKeyCredential | null } catch (e) { throw new PasskeyCancelled((e as Error)?.name ?? 'cancelled') } if (!cred) throw new PasskeyCancelled('no credential') return serializeAssertion(cred) } // ---- Step-up ceremony + retry wrapper ---- /** Runs the step-up assertion ceremony and rotates the stored csrf token. */ export async function stepUp(): Promise { const options = await apiRequest(`${V1}/stepup/options`, { body: {}, headers: csrfHeaders() }) const credential = await runGet(options) const r = await apiRequest<{ ok: boolean; csrf_token: string }>(`${V1}/stepup/verify`, { body: { credential }, headers: csrfHeaders(), }) setD2Csrf(r.csrf_token) } /** Runs `fn`; on a 403 recent_auth_required, performs step-up then retries once. */ export async function withStepUp(fn: () => Promise): Promise { try { return await fn() } catch (e) { if (isRecentAuthRequired(e)) { await stepUp() return fn() } throw e } } // ---- Status / demo ---- export interface IdentityStatus { enabled: boolean ready?: boolean demo_open_enroll?: boolean rp_id?: string origin?: string } /** Safe, secret-free D2 status. Absent surface (D2 dormant) throws — callers treat that as off. */ export async function getIdentityStatus(): Promise { return apiRequest(`${V1}/status`) } /** One-click demo enrollment (only offered when the server reports demo_open_enroll). * The server mints its own single-use invite; we run the passkey ceremony and adopt the session. */ export async function demoEnroll( publicHandle: string, displayAlias: string, language = 'en', role: 'customer' | 'operator' = 'customer', ): Promise { const options = await apiRequest(`${V1}/demo/enrollment/options`, { body: { public_handle: publicHandle, display_alias: displayAlias, preferred_language: language, role }, }) const credential = await runCreate(options) return adopt(await apiRequest(`${V1}/enrollment/verify`, { body: { credential } })) } /** No-passkey demo login — works everywhere (incl. the HF embed iframe, iPad, any browser). * Creates a demo account + session with no WebAuthn ceremony. Only when the server reports * demo_open_enroll. Passkey enrollment remains the real security story on the direct origin. */ export async function demoQuickSession(role: 'customer' | 'operator' = 'customer'): Promise { return adopt(await apiRequest(`${V1}/demo/quick-session`, { body: { role } })) } // ---- Public / auth ---- export async function validateInvitation(code: string): Promise { return apiRequest(`${V1}/invitations/validate`, { body: { code } }) } /** Full enrollment: options -> create() -> verify (adopts account + csrf). */ export async function enroll(profile: EnrollProfile): Promise { const options = await apiRequest(`${V1}/enrollment/options`, { body: profile }) const credential = await runCreate(options) return adopt(await apiRequest(`${V1}/enrollment/verify`, { body: { credential } })) } /** Sign in with a passkey. Omit `publicHandle` for a discoverable (resident-key) sign-in. */ export async function signIn(publicHandle?: string): Promise { const body = publicHandle ? { public_handle: publicHandle } : {} const options = await apiRequest(`${V1}/authentication/options`, { body }) const credential = await runGet(options) return adopt(await apiRequest(`${V1}/authentication/verify`, { body: { credential } })) } export async function getSession(): Promise<{ authenticated: boolean; account?: D2Account }> { const r = await apiRequest<{ authenticated: boolean; account?: D2Account }>(`${V1}/session`) if (r.authenticated && r.account) setD2Account(r.account) return r } export async function logout(): Promise { try { await apiRequest<{ ok: boolean }>(`${V1}/logout`, { body: {}, headers: csrfHeaders() }) } finally { clearD2() } } /** Recover access with an operator-issued code by registering a fresh passkey. */ export async function recover(code: string): Promise { const options = await apiRequest(`${V1}/recovery/options`, { body: { code } }) const credential = await runCreate(options) return adopt(await apiRequest(`${V1}/recovery/verify`, { body: { credential } })) } // ---- Authenticated customer ---- export async function getAccount(): Promise { const r = await apiRequest<{ account: D2Account }>(`${V1}/account`) setD2Account(r.account) return r.account } export async function listPasskeys(): Promise { const r = await apiRequest<{ passkeys: D2Passkey[] }>(`${V1}/passkeys`) return r.passkeys } /** Register an additional passkey on this device (step-up on recent_auth_required). */ export async function addPasskey(nickname: string): Promise { return withStepUp(async () => { const options = await apiRequest(`${V1}/passkeys/options`, { body: {}, headers: csrfHeaders() }) const credential = await runCreate(options) const r = await apiRequest<{ passkey: D2Passkey }>(`${V1}/passkeys/verify`, { body: { credential, nickname }, headers: csrfHeaders(), }) return r.passkey }) } export async function renamePasskey(id: string, nickname: string): Promise { await withStepUp(() => apiRequest<{ ok: boolean }>(`${V1}/passkeys/${id}`, { method: 'PATCH', body: { nickname }, headers: csrfHeaders() }), ) } export async function revokePasskey(id: string): Promise { await withStepUp(() => apiRequest<{ ok: boolean }>(`${V1}/passkeys/${id}`, { method: 'DELETE', headers: csrfHeaders() }), ) } export async function listSessions(): Promise { const r = await apiRequest<{ sessions: D2Session[] }>(`${V1}/sessions`) return r.sessions } export async function revokeSession(id: string): Promise { await apiRequest<{ ok: boolean }>(`${V1}/sessions/${id}`, { method: 'DELETE', headers: csrfHeaders() }) } export async function revokeOtherSessions(): Promise { await apiRequest<{ ok: boolean }>(`${V1}/sessions/revoke-others`, { body: {}, headers: csrfHeaders() }) } /** Pause the account (needs recent auth). Server clears the cookie; we clear local state. */ export async function pauseAccount(): Promise<{ ok: boolean; status: string }> { return withStepUp(async () => { const r = await apiRequest<{ ok: boolean; status: string }>(`${V1}/account/pause`, { body: {}, headers: csrfHeaders(), }) clearD2() return r }) } /** Delete the account: options -> get() -> confirm. Returns the deletion receipt. */ export async function deleteAccount(): Promise { const options = await apiRequest(`${V1}/account/delete/options`, { body: {}, headers: csrfHeaders() }) const credential = await runGet(options) const r = await apiRequest(`${V1}/account/delete/confirm`, { body: { credential }, headers: csrfHeaders(), }) clearD2() return r } // ---- Operator ---- export async function createInvitations( role: string, count: number, ttlSeconds: number, ): Promise<{ id: string; code: string }[]> { const r = await apiRequest<{ invitations: { id: string; code: string }[] }>(`${V1}/operator/invitations`, { body: { role, count, ttl_seconds: ttlSeconds }, headers: csrfHeaders(), }) return r.invitations } export async function listInvitations(): Promise { const r = await apiRequest<{ invitations: D2Invitation[] }>(`${V1}/operator/invitations`) return r.invitations } export async function revokeInvitation(id: string): Promise { await apiRequest<{ ok: boolean }>(`${V1}/operator/invitations/${id}`, { method: 'DELETE', headers: csrfHeaders() }) } export async function listOperatorAccounts(): Promise { const r = await apiRequest<{ accounts: D2OperatorAccount[] }>(`${V1}/operator/accounts`) return r.accounts } export async function getOperatorAccount( id: string, ): Promise<{ account: D2OperatorAccount; events: D2AccountEvent[] }> { return apiRequest<{ account: D2OperatorAccount; events: D2AccountEvent[] }>(`${V1}/operator/accounts/${id}`) } export async function operatorPauseAccount(id: string): Promise { await apiRequest<{ ok: boolean }>(`${V1}/operator/accounts/${id}/pause`, { body: {}, headers: csrfHeaders() }) } export async function operatorReactivateAccount(id: string): Promise { await apiRequest<{ ok: boolean }>(`${V1}/operator/accounts/${id}/reactivate`, { body: {}, headers: csrfHeaders() }) } export async function operatorDeleteAccount(id: string): Promise { await apiRequest<{ ok: boolean }>(`${V1}/operator/accounts/${id}/delete`, { body: {}, headers: csrfHeaders() }) } export async function operatorRecoveryInvitation(id: string): Promise { const r = await apiRequest<{ code: string }>(`${V1}/operator/accounts/${id}/recovery-invitation`, { body: {}, headers: csrfHeaders(), }) return r.code }