1import { getLofiApp } from "./app.ts";23type PasskeyBackupErrorCode =4 | "not-supported"5 | "invalid-secret"6 | "create-failed"7 | "get-failed"8 | "no-credential"9 | "invalid-credential"10 | "verification-failed";1112/** Stable categories for recoverable passkey backup and restore failures. */13export type RecoverablePasskeyErrorCode =14 | "cancelled"15 | "unsupported"16 | "credential-missing"17 | "rp-id-mismatch"18 | "verification-failed"19 | "invalid-credential"20 | "backup-failed"21 | "restore-failed";2223const messages: Record<RecoverablePasskeyErrorCode, string> = {24 cancelled: "The passkey prompt was cancelled. Nothing on this device was replaced.",25 unsupported:26 "This browser cannot create or restore a recoverable passkey. Use the recovery phrase instead.",27 "credential-missing":28 "No recoverable passkey is available for this app and account. Check your passkey provider or use the recovery phrase.",29 "rp-id-mismatch":30 "This passkey belongs to a different app hostname. Open the canonical app address or use the recovery phrase.",31 "verification-failed":32 "The authenticator did not verify you. Unlock the passkey provider and try again, or use the recovery phrase.",33 "invalid-credential":34 "That credential is not a recoverable lofi account passkey. Choose the account-recovery passkey or use the recovery phrase.",35 "backup-failed":36 "The account could not be backed up to a passkey. The current account is unchanged; save the recovery phrase and try again.",37 "restore-failed":38 "The passkey could not restore this account. The current account is unchanged; use the recovery phrase or try again.",39};4041/** A non-secret, actionable recoverable-passkey failure. */42export class RecoverablePasskeyError extends Error {43 /** Stable error class name for diagnostics and error boundaries. */44 override readonly name = "RecoverablePasskeyError";45 /** Creates a mapped passkey error with safe user-facing guidance. */46 constructor(readonly code: RecoverablePasskeyErrorCode, options?: ErrorOptions) {47 super(messages[code], options);48 }49}5051export interface PasskeyBackupAdapter {52 backup(secret: string, displayName: string): Promise<void>;53 restore(): Promise<string>;54}5556type VendorPasskeyError = Error & { code: PasskeyBackupErrorCode; cause?: unknown };5758function isVendorPasskeyError(error: unknown): error is VendorPasskeyError {59 return error instanceof Error && error.name === "PasskeyBackupError" &&60 typeof (error as { code?: unknown }).code === "string";61}6263function causeName(error: VendorPasskeyError): string {64 const cause = error.cause;65 return cause && typeof cause === "object" && "name" in cause ? String(cause.name) : "";66}6768export function mapPasskeyBackupError(69 error: unknown,70 operation: "backup" | "restore",71): RecoverablePasskeyError {72 if (error instanceof RecoverablePasskeyError) return error;73 if (!isVendorPasskeyError(error)) {74 return new RecoverablePasskeyError(75 operation === "backup" ? "backup-failed" : "restore-failed",76 );77 }78 const vendorCode: PasskeyBackupErrorCode = error.code;79 const domName = causeName(error);80 if (vendorCode === "not-supported") return new RecoverablePasskeyError("unsupported");81 if (vendorCode === "no-credential") return new RecoverablePasskeyError("credential-missing");82 if (vendorCode === "verification-failed") {83 return new RecoverablePasskeyError("verification-failed");84 }85 if (vendorCode === "invalid-credential" || vendorCode === "invalid-secret") {86 return new RecoverablePasskeyError("invalid-credential");87 }88 if (domName === "SecurityError") return new RecoverablePasskeyError("rp-id-mismatch");89 if (domName === "NotAllowedError" || domName === "AbortError") {90 return new RecoverablePasskeyError("cancelled");91 }92 return new RecoverablePasskeyError(93 operation === "backup" ? "backup-failed" : "restore-failed",94 );95}9697export function configuredPasskeyRpId(): string {98 const configured = getLofiApp().passkey?.rpId?.trim();99 if (configured) return configured;100 return globalThis.location?.hostname ?? "localhost";101}102103export function createPasskeyBackupAdapter(): PasskeyBackupAdapter {104 const app = getLofiApp();105 let implementation: Promise<PasskeyBackupAdapter> | undefined;106 const resolve = () =>107 implementation ??= import("jazz-tools/passkey-backup").then(({ BrowserPasskeyBackup }) =>108 new BrowserPasskeyBackup({109 appName: app.name,110 appHostname: configuredPasskeyRpId(),111 })112 );113 return {114 async backup(secret, displayName) {115 await (await resolve()).backup(secret, displayName);116 },117 async restore() {118 return await (await resolve()).restore();119 },120 };121}122123/** Runs the vendor backup boundary with lofi's stable error contract. */124export async function backupSecretWithPasskey(125 adapter: PasskeyBackupAdapter,126 secret: string,127 displayName: string,128): Promise<void> {129 try {130 await adapter.backup(secret, displayName);131 } catch (error) {132 throw mapPasskeyBackupError(error, "backup");133 }134}135136/** Runs the vendor restore boundary with lofi's stable error contract. */137export async function restoreSecretWithPasskey(138 adapter: PasskeyBackupAdapter,139): Promise<string> {140 try {141 return await adapter.restore();142 } catch (error) {143 throw mapPasskeyBackupError(error, "restore");144 }145}