// Thin wrappers around the gateway public auth REST surface used by // the email-code login flow. The two exported functions correspond // 1:1 to the OpenAPI operations defined in // `backend/openapi.yaml`: // // POST /api/v1/public/auth/send-email-code // POST /api/v1/public/auth/confirm-email-code // // Both endpoints are unauthenticated — the device session does not // exist yet during send-code, and confirm-code is the call that // creates one. Persisting the returned `device_session_id` is the // caller's responsibility (see `lib/session-store.svelte.ts`). // // `Accept-Language` is set automatically by the browser; the gateway // reads it for the auth-mail localisation. We do not duplicate the // value into the optional `locale` body field. const SEND_EMAIL_CODE_PATH = "/api/v1/public/auth/send-email-code"; const CONFIRM_EMAIL_CODE_PATH = "/api/v1/public/auth/confirm-email-code"; export interface SendEmailCodeResult { challengeId: string; } export interface SendEmailCodeOptions { /** * locale is forwarded inside the JSON body and read by the * gateway in preference to the request `Accept-Language` header. * The body field is the canonical channel because Safari/WebKit * silently drops JS-set `Accept-Language` headers, while the * body round-trips correctly on every supported engine. When the * caller omits this option the browser-default Accept-Language * remains the gateway's only signal and the auth-mail uses the * system locale. */ locale?: string; } export interface ConfirmEmailCodeInput { challengeId: string; code: string; publicKey: Uint8Array; timeZone: string; } export interface ConfirmEmailCodeResult { deviceSessionId: string; } /** * AuthError is thrown by `sendEmailCode` and `confirmEmailCode` for * every non-2xx gateway response. `code` mirrors the stable * machine-readable identifier from the gateway error envelope * (`invalid_request`, `service_unavailable`, `internal_error`, ...); * `status` is the HTTP status that produced the error. */ export class AuthError extends Error { readonly code: string; readonly status: number; constructor(code: string, message: string, status: number) { super(message); this.name = "AuthError"; this.code = code; this.status = status; } } /** * sendEmailCode issues a login challenge for `email`. The gateway * returns the same opaque `challenge_id` shape regardless of whether * the address belongs to a new, existing, or throttled account, so * the caller cannot use the response to enumerate accounts. */ export async function sendEmailCode( baseUrl: string, email: string, options?: SendEmailCodeOptions, ): Promise { const requestBody: Record = { email }; if (options?.locale !== undefined && options.locale !== "") { requestBody.locale = options.locale; } const response = await fetch(joinUrl(baseUrl, SEND_EMAIL_CODE_PATH), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(requestBody), }); if (!response.ok) { throw await readAuthError(response); } const responseBody = (await response.json()) as { challenge_id?: unknown }; if ( typeof responseBody.challenge_id !== "string" || responseBody.challenge_id.length === 0 ) { throw new AuthError( "internal_error", "gateway returned a malformed send-email-code response", response.status, ); } return { challengeId: responseBody.challenge_id }; } /** * confirmEmailCode submits the verification code and the device's * Ed25519 public key. On success the gateway returns the new device * session identifier; persistence of that identifier is the caller's * responsibility. */ export async function confirmEmailCode( baseUrl: string, input: ConfirmEmailCodeInput, ): Promise { const response = await fetch(joinUrl(baseUrl, CONFIRM_EMAIL_CODE_PATH), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ challenge_id: input.challengeId, code: input.code, client_public_key: encodeBase64(input.publicKey), time_zone: input.timeZone, }), }); if (!response.ok) { throw await readAuthError(response); } const body = (await response.json()) as { device_session_id?: unknown }; if ( typeof body.device_session_id !== "string" || body.device_session_id.length === 0 ) { throw new AuthError( "internal_error", "gateway returned a malformed confirm-email-code response", response.status, ); } return { deviceSessionId: body.device_session_id }; } async function readAuthError(response: Response): Promise { let code = ""; let message = ""; try { const body = (await response.json()) as { error?: { code?: unknown; message?: unknown }; }; const err = body.error; if (err && typeof err.code === "string") { code = err.code; } if (err && typeof err.message === "string") { message = err.message; } } catch { // Body was not JSON or could not be parsed; fall through to // generic defaults below. } if (code.length === 0) { code = response.status >= 500 ? "internal_error" : "invalid_request"; } if (message.length === 0) { message = response.status >= 500 ? "service is temporarily unavailable" : `request rejected (${response.status})`; } return new AuthError(code, message, response.status); } function joinUrl(baseUrl: string, path: string): string { const trimmedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; const trimmedPath = path.startsWith("/") ? path : `/${path}`; return `${trimmedBase}${trimmedPath}`; } function encodeBase64(bytes: Uint8Array): string { let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]!); } return btoa(binary); }