clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

main
lofi / package / runtime / passkey-recovery.ts
passkey-recovery.ts 5.3K
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:25 "A passkey was not created or opened. Nothing on this device was replaced; try again or use the recovery phrase.",26 unsupported:27 "This browser cannot create or restore a recoverable passkey. Use the recovery phrase instead.",28 "credential-missing":29 "No recoverable passkey is available for this app and account. Check your passkey provider or use the recovery phrase.",30 "rp-id-mismatch":31 "This passkey belongs to a different app hostname. Open the canonical app address or use the recovery phrase.",32 "verification-failed":33 "The authenticator did not verify you. Unlock the passkey provider and try again, or use the recovery phrase.",34 "invalid-credential":35 "That credential is not a recoverable lofi account passkey. Choose the account-recovery passkey or use the recovery phrase.",36 "backup-failed":37 "The account could not be backed up to a passkey. The current account is unchanged; save the recovery phrase and try again.",38 "restore-failed":39 "The passkey could not restore this account. The current account is unchanged; use the recovery phrase or try again.",40};4142/** A non-secret, actionable recoverable-passkey failure. */43export class RecoverablePasskeyError extends Error {44 /** Stable error class name for diagnostics and error boundaries. */45 override readonly name = "RecoverablePasskeyError";46 /** Creates a mapped passkey error with safe user-facing guidance. */47 constructor(readonly code: RecoverablePasskeyErrorCode, options?: ErrorOptions) {48 super(messages[code], options);49 }50}5152export interface PasskeyBackupAdapter {53 backup(secret: string, displayName: string): Promise<void>;54 restore(): Promise<string>;55}5657type VendorPasskeyError = Error & { code: PasskeyBackupErrorCode; cause?: unknown };5859function isVendorPasskeyError(error: unknown): error is VendorPasskeyError {60 return error instanceof Error && error.name === "PasskeyBackupError" &&61 typeof (error as { code?: unknown }).code === "string";62}6364function causeName(error: VendorPasskeyError): string {65 const cause = error.cause;66 return cause && typeof cause === "object" && "name" in cause ? String(cause.name) : "";67}6869export function mapPasskeyBackupError(70 error: unknown,71 operation: "backup" | "restore",72): RecoverablePasskeyError {73 if (error instanceof RecoverablePasskeyError) return error;74 if (!isVendorPasskeyError(error)) {75 return new RecoverablePasskeyError(76 operation === "backup" ? "backup-failed" : "restore-failed",77 );78 }79 const vendorCode: PasskeyBackupErrorCode = error.code;80 const domName = causeName(error);81 if (vendorCode === "not-supported") return new RecoverablePasskeyError("unsupported");82 if (vendorCode === "no-credential") return new RecoverablePasskeyError("credential-missing");83 if (vendorCode === "verification-failed") {84 return new RecoverablePasskeyError("verification-failed");85 }86 if (vendorCode === "invalid-credential" || vendorCode === "invalid-secret") {87 return new RecoverablePasskeyError("invalid-credential");88 }89 if (domName === "SecurityError") return new RecoverablePasskeyError("rp-id-mismatch");90 if (domName === "NotAllowedError" || domName === "AbortError") {91 return new RecoverablePasskeyError("cancelled");92 }93 return new RecoverablePasskeyError(94 operation === "backup" ? "backup-failed" : "restore-failed",95 );96}9798export function configuredPasskeyRpId(): string {99 const configured = getLofiApp().passkey?.rpId?.trim();100 if (configured) return configured;101 return globalThis.location?.hostname ?? "localhost";102}103104export function createPasskeyBackupAdapter(): PasskeyBackupAdapter {105 const app = getLofiApp();106 let implementation: Promise<PasskeyBackupAdapter> | undefined;107 const resolve = () =>108 implementation ??= import("jazz-tools/passkey-backup").then(({ BrowserPasskeyBackup }) =>109 new BrowserPasskeyBackup({110 appName: app.name,111 appHostname: configuredPasskeyRpId(),112 })113 );114 return {115 async backup(secret, displayName) {116 await (await resolve()).backup(secret, displayName);117 },118 async restore() {119 return await (await resolve()).restore();120 },121 };122}123124/** Runs the vendor backup boundary with lofi's stable error contract. */125export async function backupSecretWithPasskey(126 adapter: PasskeyBackupAdapter,127 secret: string,128 displayName: string,129): Promise<void> {130 try {131 await adapter.backup(secret, displayName);132 } catch (error) {133 throw mapPasskeyBackupError(error, "backup");134 }135}136137/** Runs the vendor restore boundary with lofi's stable error contract. */138export async function restoreSecretWithPasskey(139 adapter: PasskeyBackupAdapter,140): Promise<string> {141 try {142 return await adapter.restore();143 } catch (error) {144 throw mapPasskeyBackupError(error, "restore");145 }146}