const BASE = '/api/experience'; async function request(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${BASE}${path}`, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin', }); return res; } // --- Shared --- export async function identifyUser(verificationId: string): Promise { const res = await request('POST', '/identification', { verificationId }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Identification failed (${res.status})`); } } export async function submitInteraction(): Promise { const res = await request('POST', '/submit'); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Submit failed (${res.status})`); } const data = await res.json(); return data.redirectTo; } // --- Sign-in --- export async function initInteraction(): Promise { const res = await request('PUT', '', { interactionEvent: 'SignIn' }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Failed to initialize sign-in (${res.status})`); } } function detectIdentifierType(input: string): 'email' | 'username' { return input.includes('@') ? 'email' : 'username'; } export async function verifyPassword( identifier: string, password: string ): Promise { const type = detectIdentifierType(identifier); const res = await request('POST', '/verification/password', { identifier: { type, value: identifier }, password, }); if (!res.ok) { const err = await res.json().catch(() => ({})); if (res.status === 422) { throw new Error('Invalid credentials'); } throw new Error(err.message || `Authentication failed (${res.status})`); } const data = await res.json(); return data.verificationId; } export class MfaRequiredError extends Error { constructor() { super('MFA verification required'); this.name = 'MfaRequiredError'; } } async function trySubmit(): Promise<{ ok: true; redirectTo: string } | { ok: false; status: number; code: string; message: string }> { const res = await request('POST', '/submit'); if (res.ok) { const data = await res.json(); return { ok: true, redirectTo: data.redirectTo }; } const err = await res.json().catch(() => ({})); return { ok: false, status: res.status, code: err.code ?? '', message: err.message ?? `Submit failed (${res.status})` }; } async function skipMfaBinding(): Promise { await request('POST', '/profile/mfa/mfa-skipped'); } export async function signIn(identifier: string, password: string): Promise { await initInteraction(); const verificationId = await verifyPassword(identifier, password); await identifyUser(verificationId); const result = await trySubmit(); if (result.ok) return result.redirectTo; // MFA already enrolled — user must verify (show TOTP input) if (result.code === 'user.missing_mfa' || result.code === 'session.mfa.require_mfa_verification') { throw new MfaRequiredError(); } // MFA not enrolled, UserControlled policy — skip the binding prompt. // Also fallback: any 422 with an MFA-related code we don't recognize — try skip before failing. if (result.status === 422 && result.code.includes('mfa')) { await skipMfaBinding(); const retry = await trySubmit(); if (retry.ok) return retry.redirectTo; throw new Error(retry.message); } throw new Error(result.message); } // --- Registration --- export async function initRegistration(): Promise { const res = await request('PUT', '', { interactionEvent: 'Register' }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Failed to initialize registration (${res.status})`); } } export async function sendVerificationCode(email: string): Promise { const res = await request('POST', '/verification/verification-code', { identifier: { type: 'email', value: email }, interactionEvent: 'Register', }); if (!res.ok) { const err = await res.json().catch(() => ({})); if (res.status === 422) { throw new Error('This email is already registered'); } throw new Error(err.message || `Failed to send verification code (${res.status})`); } const data = await res.json(); return data.verificationId; } export async function verifyCode( email: string, verificationId: string, code: string ): Promise { const res = await request('POST', '/verification/verification-code/verify', { identifier: { type: 'email', value: email }, verificationId, code, }); if (!res.ok) { const err = await res.json().catch(() => ({})); if (res.status === 422) { throw new Error('Invalid or expired verification code'); } throw new Error(err.message || `Verification failed (${res.status})`); } const data = await res.json(); return data.verificationId; } export async function addProfile(type: string, value: string): Promise { const res = await request('POST', '/profile', { type, value }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Failed to update profile (${res.status})`); } } /** Phase 1: init registration + send verification email. Returns verificationId for phase 2. */ export async function startRegistration(email: string): Promise { await initRegistration(); return sendVerificationCode(email); } /** Phase 2: verify code, set password, create user, submit. Returns redirect URL. */ export async function completeRegistration( email: string, password: string, verificationId: string, code: string ): Promise { const verifiedId = await verifyCode(email, verificationId, code); await addProfile('password', password); await identifyUser(verifiedId); const result = await trySubmit(); if (result.ok) return result.redirectTo; // MFA not enrolled, UserControlled policy — skip the binding prompt if (result.status === 422 && result.code.includes('mfa')) { await skipMfaBinding(); const retry = await trySubmit(); if (retry.ok) return retry.redirectTo; throw new Error(retry.message); } throw new Error(result.message); } // --- Forgot Password --- export async function initForgotPassword(): Promise { const res = await request('PUT', '', { interactionEvent: 'ForgotPassword' }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Failed to initialize password reset (${res.status})`); } } export async function forgotPasswordSendCode(email: string): Promise { const res = await request('POST', '/verification/verification-code', { identifier: { type: 'email', value: email }, interactionEvent: 'ForgotPassword', }); if (!res.ok) { const err = await res.json().catch(() => ({})); if (res.status === 422) { throw new Error('No account found with this email'); } throw new Error(err.message || `Failed to send reset code (${res.status})`); } const data = await res.json(); return data.verificationId; } export async function forgotPasswordVerifyAndReset( email: string, verificationId: string, code: string, newPassword: string, ): Promise { const verifiedId = await verifyCode(email, verificationId, code); await identifyUser(verifiedId); await addProfile('password', newPassword); await submitInteraction(); } export async function startForgotPassword(email: string): Promise { await initForgotPassword(); return forgotPasswordSendCode(email); } // --- MFA Verification --- export async function verifyTotp(code: string): Promise { const res = await request('POST', '/verification/totp/verify', { code }); if (!res.ok) { const err = await res.json().catch(() => ({})); if (res.status === 422) { throw new Error('Invalid code, please try again'); } throw new Error(err.message || `TOTP verification failed (${res.status})`); } const data = await res.json(); return data.verificationId; } export async function verifyBackupCode(code: string): Promise { const res = await request('POST', '/verification/backup-code/verify', { code }); if (!res.ok) { const err = await res.json().catch(() => ({})); if (res.status === 422) { const msg = err.code === 'backup_code_consumed' ? 'This backup code has already been used' : 'Invalid backup code'; throw new Error(msg); } throw new Error(err.message || `Backup code verification failed (${res.status})`); } const data = await res.json(); return data.verificationId; } export async function submitMfa(verificationId: string): Promise { await identifyUser(verificationId); return submitInteraction(); } // --- WebAuthn MFA Verification --- export async function startWebAuthnAuth(): Promise> { const res = await request('POST', '/verification/web-authn/authentication'); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Failed to start passkey authentication (${res.status})`); } const data = await res.json(); return data; } export async function verifyWebAuthnAuth(payload: Record): Promise { const res = await request('POST', '/verification/web-authn/authentication/verify', payload); if (!res.ok) { const err = await res.json().catch(() => ({})); if (res.status === 422) { throw new Error('Passkey verification failed. Please try again.'); } throw new Error(err.message || `Passkey verification failed (${res.status})`); } const data = await res.json(); return data.verificationId; }