clone

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

feat/184-task-notice-migration
lofi / package / runtime / session.ts
session.ts 26.6K
1/**2 * Package-owned account session runtime.3 * Account session for lofi's local-first identity: the glue between the account4 * secret the runtime opens and the user's choice to back it up and sync.5 *6 * First boot is **local-only** — a random per-device account opens immediately,7 * with no ceremony and no network. From there the user can *elect* to back up8 * and sync: revealing a recovery phrase (the portable, honest backup of the9 * exact same account) and turning on replication to the managed Jazz app. The10 * account identity never changes, so electing to sync preserves every write made11 * while local-only. Restoring a phrase on a new device reconstructs the same12 * account and its synced data flows back down.13 *14 * The small, honest UI that drives this lives in author space15 * (`src/islands/AccountGate.tsx`) — this module is the framework side.16 *17 * @module18 */1920import { BrowserAuthSecretStore } from "jazz-tools";21import { getLofiApp } from "./app.ts";22import { withAuthSecretLock } from "./auth-secret-lock.ts";23import {24 activeSink,25 appId,26 setSyncElected,27 syncAvailable,28 syncElected,29 syncing,30} from "./config.ts";31import {32 clearDeclaredSink,33 type DataSinkDeclaration,34 declareDataSink,35 declareSinkFromTicket,36 parseSyncTicket,37 readDeclaredSink,38 splitTicketForEnrollment,39} from "./data-sink.ts";40import type { DeviceKeyStore } from "./envelope.ts";41import { resolveStoreStatus } from "./store-status.ts";42import type { TicketStoreStatus } from "../schema/store.ts";43import {44 adjudicateSyncOwner,45 clearSyncOwner,46 readSyncOwnerVerdict,47 recordSyncOwner,48 secretFingerprint,49 SyncOwnerError,50} from "./sync-owner.ts";51import { type DevicePublicKey, exportDevicePublicKey, getOrCreatePopKeyPair } from "./pop.ts";52import { holdProvisionCapability } from "./provision.ts";53import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";54import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";55import {56 backupSecretWithPasskey,57 createPasskeyBackupAdapter,58 RecoverablePasskeyError,59 restoreSecretWithPasskey,60} from "./passkey-recovery.ts";61import {62 getRuntime,63 getRuntimePrincipal,64 recreateRuntime,65 reloadBrowserRuntime,66 replaceRuntimePrincipal,67 runtimeCanReconnect,68 runtimeRecreatedEvent,69 updateRuntimeDiagnostics,70} from "./runtime.ts";71import { electManagedNamespace, readNamespaceState } from "./namespace-state.ts";7273/** A non-secret description of the sync location in effect. */74export type SessionSink = {75 /** `declared` is a runtime-declared sink; `default` is the compiled managed app. */76 source: "declared" | "default";77 /** The sink host (never the full URL — a ticket URL is a bearer credential). */78 host: string;79 /** The user-facing label from enrollment, when one was given. */80 label?: string;81};8283/** A snapshot of the account: what is possible and what the user has chosen. */84export type Session = {85 /**86 * Stable, app-scoped Jazz sharing identity for the active account. Named87 * `user_id` because it relays the sync principal's vocabulary verbatim —88 * the same identifier permission rows and directory tables carry.89 */90 user_id: string | null;91 /** Whether a sync location exists (declared or compiled), so backup + sync is possible. */92 syncAvailable: boolean;93 /** The sync location in effect, or `null` while the device is local-only. */94 sink: SessionSink | null;95 /** Whether the user has elected to back up and sync this account on this device. */96 backedUp: boolean;97 /** Whether writes replicate right now (available *and* elected). */98 syncing: boolean;99 /**100 * Whether this boot found sync elected by a different account than the one101 * in hand. Transport is then suppressed; the remediation is to stop sync102 * (releasing the election) or restore the owning account.103 */104 syncOwnerMismatch: boolean;105 /**106 * Whether a passkey guards the recovery phrase on this device — revealing it107 * then requires a user-verifying ceremony. This confirms the person is present;108 * it does **not** encrypt the account secret held on the device.109 */110 phraseGuarded: boolean;111 /** Whether this device created a passkey containing a recoverable account backup. */112 passkeyRecoverable: boolean;113};114115// A per-device flag recording that the user enrolled a passkey to guard the116// recovery phrase. The passkey itself is a resident credential on the device;117// this only remembers to ask for it before revealing the phrase.118const phraseGuardKey = `lofi:phrase-passkey:${appId}`;119const recoverablePasskeyKey = `lofi:recoverable-passkey:${appId}`;120121// The guard record pins the enrolled credential's id so the reveal ceremony122// accepts only that passkey. Legacy records stored the bare flag "1" — those123// devices stay guarded but unpinned until the guard is re-enrolled.124type PhraseGuard = { guarded: boolean; credentialId: string | null };125126function readPhraseGuard(): PhraseGuard {127 if (typeof localStorage === "undefined") return { guarded: false, credentialId: null };128 try {129 const raw = localStorage.getItem(phraseGuardKey);130 if (!raw) return { guarded: false, credentialId: null };131 if (raw === "1") return { guarded: true, credentialId: null };132 try {133 const value = JSON.parse(raw) as { id?: unknown };134 return {135 guarded: true,136 credentialId: typeof value.id === "string" && value.id ? value.id : null,137 };138 } catch {139 return { guarded: true, credentialId: null };140 }141 } catch {142 return { guarded: false, credentialId: null };143 }144}145146function phraseGuarded(): boolean {147 return readPhraseGuard().guarded;148}149150function setPhraseGuard(credentialId: string): void {151 if (typeof localStorage === "undefined") return;152 try {153 localStorage.setItem(phraseGuardKey, JSON.stringify({ id: credentialId }));154 } catch {155 // A private-mode storage failure must not break the ceremony itself.156 }157}158159function clearPhraseGuard(): void {160 if (typeof localStorage === "undefined") return;161 try {162 localStorage.removeItem(phraseGuardKey);163 } catch {164 // A private-mode storage failure must not break the ceremony itself.165 }166}167168function passkeyRecoverable(): boolean {169 if (typeof localStorage === "undefined") return false;170 try {171 return localStorage.getItem(recoverablePasskeyKey) === "1";172 } catch {173 return false;174 }175}176177function setPasskeyRecoverable(value: boolean): void {178 if (typeof localStorage === "undefined") return;179 try {180 if (value) localStorage.setItem(recoverablePasskeyKey, "1");181 else localStorage.removeItem(recoverablePasskeyKey);182 } catch {183 // The credential remains the source of truth if local metadata cannot persist.184 }185}186187function sessionSink(): SessionSink | null {188 const sink = activeSink();189 if (!sink) return null;190 let host: string;191 try {192 host = new URL(sink.serverUrl).host;193 } catch {194 host = "unknown";195 }196 return { source: sink.source, host, ...(sink.label ? { label: sink.label } : {}) };197}198199/** Reads the current session. Synchronous — it never prompts or touches the network. */200export function readSession(): Session {201 return {202 user_id: getRuntimePrincipal(),203 syncAvailable: syncAvailable(),204 sink: sessionSink(),205 backedUp: syncElected(),206 syncing: syncing(),207 syncOwnerMismatch: readSyncOwnerVerdict().state === "foreign",208 phraseGuarded: phraseGuarded(),209 passkeyRecoverable: passkeyRecoverable(),210 };211}212213/** Resolves the runtime before returning a session with a stable `user_id`. */214export async function readAccountSession(): Promise<Session> {215 await getRuntime();216 return readSession();217}218219// The account secret the runtime is already using. `getOrCreateSecret` is a220// read-through: the runtime created it on first boot, so this returns that same221// secret rather than minting a new one. It still takes the app's secret lock so222// a call that races a first boot in another tab observes that boot's secret.223async function currentSecret(): Promise<string> {224 return await withAuthSecretLock(appId, () => BrowserAuthSecretStore.getOrCreateSecret({ appId }));225}226227/**228 * Enrolls a passkey to guard the recovery phrase on this device. Enrollment is a229 * user-verifying ceremony, so it doubles as the confirmation for the reveal that230 * immediately follows. Returns `true` when a guard was set; `false` (without231 * throwing) when this browser or origin cannot enroll a passkey, so the caller232 * can still show the phrase while telling the user it is unguarded. Re-throws a233 * `cancelled` ceremony so the caller can abort.234 */235export async function createBackupPasskey(label?: string): Promise<boolean> {236 try {237 const credential = await enrollDeviceCredential(label ? { label } : {});238 setPhraseGuard(credential.id);239 return true;240 } catch (error) {241 if (242 error instanceof AuthError &&243 (error.code === "unsupported" || error.code === "origin-rejected")244 ) {245 // A failed re-enrollment must not remove a guard that already stands:246 // the existing credential still protects the reveal, so report the247 // device as guarded rather than silently downgrading it.248 if (readPhraseGuard().guarded) return true;249 clearPhraseGuard();250 return false;251 }252 throw error;253 }254}255256/**257 * Non-secret confirmation that a recoverable passkey was created for an258 * account and RP-ID. `user_id` relays the sync principal's vocabulary259 * verbatim, matching {@link Session.user_id}.260 */261export type PasskeyBackupReceipt = { user_id: string; rpId: string };262263/**264 * Stores the current 32-byte local-first secret inside Jazz's resident,265 * user-verifying passkey backup. Unlike `createBackupPasskey`, this credential266 * can restore the account and is not merely a local phrase-reveal guard.267 */268export async function createRecoverablePasskeyBackup(269 displayName?: string,270): Promise<PasskeyBackupReceipt> {271 const runtime = await getRuntime();272 const user_id = runtime.db.getAuthState().session?.user_id;273 if (!user_id) throw new RecoverablePasskeyError("backup-failed");274 await backupSecretWithPasskey(275 createPasskeyBackupAdapter(),276 await currentSecret(),277 displayName?.trim() || `${getLofiApp().name} account ${user_id.slice(0, 8)}`,278 );279 setPasskeyRecoverable(true);280 return {281 user_id,282 rpId: getLofiApp().passkey?.rpId?.trim() || globalThis.location?.hostname || "localhost",283 };284}285286/**287 * Runs the user-verifying passkey ceremony that must precede revealing the phrase288 * on a guarded device. The ceremony is pinned to the enrolled credential, so no289 * other passkey for the same RP ID can satisfy it (guards enrolled before pinning290 * remain discoverable until re-enrolled). A no-op when the device is not guarded.291 * Throws `cancelled` if the user dismisses the prompt — the phrase is then not292 * revealed.293 */294export async function confirmPhraseAccess(): Promise<void> {295 const guard = readPhraseGuard();296 if (!guard.guarded) return;297 await authenticateDeviceCredential(298 guard.credentialId ? { credentialId: guard.credentialId } : {},299 );300}301302/**303 * Reveals the current account's recovery phrase — the same 32-byte secret encoded304 * as words. Show it for the user to write down; never persist it. Works whether305 * or not sync is on, but only matters once the account syncs, since the phrase306 * recovers what has been backed up.307 */308export async function revealRecoveryPhrase(): Promise<string> {309 return toRecoveryPhrase(await currentSecret());310}311312/**313 * Elects to back up and sync this account: replication to the managed Jazz app314 * turns on and the existing local data pushes up under the same identity. Pair315 * it with {@link revealRecoveryPhrase} so the user has a way back in. No-op when316 * no Jazz app is configured.317 *318 * In browsers with `SharedWorker` support, first-time election reloads the page319 * to reopen the runtime on the elected namespace; the returned promise never320 * settles there, and the app resumes after reload. Do not put follow-up UI321 * behind the `await` — read the post-reload session state instead.322 *323 * Throws {@link SyncOwnerError} when sync on this device was elected by a324 * different account: stop sync (releasing the election) or restore the owning325 * account first. Electing under an unclaimed election records this account as326 * the owner.327 */328export async function enableSyncBackup(): Promise<Session> {329 // The documented no-op: without a sync location there is nothing to sync330 // to, and electing the managed namespace anyway would hide the local rows331 // behind an empty database on every later boot.332 if (!syncAvailable()) return readSession();333 await assertAndRecordSyncOwnerForElection();334 const alreadyManaged = readNamespaceState().mode === "managed";335 setSyncElected(true);336 if (alreadyManaged && runtimeCanReconnect()) {337 await (await getRuntime()).db.reconnect();338 } else {339 if (!alreadyManaged) electManagedNamespace({ migrateLocalRows: true });340 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {341 return await reloadBrowserRuntime();342 }343 await recreateRuntime();344 }345 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));346 return readSession();347}348349// The election half of the sync-owner pin: refuse to elect under an account350// that is not the recorded owner, and claim ownership when none is recorded.351// The secret resolver is injectable so tests adjudicate without a secret352// store. Exported for those tests; the entry does not re-export it.353export async function assertAndRecordSyncOwnerForElection(354 resolveSecret: () => Promise<string> = currentSecret,355): Promise<void> {356 const fingerprint = await secretFingerprint(await resolveSecret());357 const verdict = adjudicateSyncOwner(fingerprint);358 if (verdict.state === "foreign") throw new SyncOwnerError(verdict.owner_user_id);359 if (verdict.state === "unclaimed") {360 // The principal may not be known yet (first election precedes the managed361 // boot); the fingerprint adjudicates, and boot backfills the name.362 recordSyncOwner({ fingerprint, user_id: getRuntimePrincipal() });363 }364}365366/**367 * Stable categories for a refused ticket enrollment, relayed from the node's368 * store preflight: `no_schema` — the store holds no deployed schema for this369 * app, so nothing could sync; `ticket_rejected` — the node no longer accepts370 * the ticket (revoked, or the node was reset). Both are definite answers from371 * the node; an unreachable store is deliberately not a refusal category.372 */373export type SyncEnrollmentFailureCode = "no_schema" | "ticket_rejected";374375const enrollmentMessages: Record<SyncEnrollmentFailureCode, (scope?: string) => string> = {376 no_schema: (scope) =>377 "The node accepted the ticket, but its store has no schema deployed for this app, so " +378 "nothing can sync. Enrollment was not kept. " +379 (scope === "provision"380 ? "Provision the store, then enroll again."381 : "Provision the store from a provision-scoped ticket, then enroll this device again."),382 ticket_rejected: () =>383 "The node rejected this ticket — it was revoked or the node was reset. Enrollment was " +384 "not kept. Get a fresh ticket from the node and enroll again.",385};386387/**388 * Thrown by {@link enrollSyncTicket} when the node's store preflight refuses389 * the enrollment: the sink declaration is rolled back, no provision390 * capability is held, and sync is not elected, so the device stays exactly as391 * it was before the attempt. The message is user-presentable and names the392 * remediation for its {@link SyncEnrollmentFailureCode}. A merely unreachable393 * store never throws this — it enrolls with the warning recorded in runtime394 * diagnostics (`storeStatus`) instead, because a flaky network must not block395 * a legitimate ticket.396 */397export class SyncEnrollmentError extends Error {398 /** Stable error class name for diagnostics and UI boundaries. */399 override readonly name = "SyncEnrollmentError";400 /** The preflight answer that refused the enrollment. */401 readonly code: SyncEnrollmentFailureCode;402403 /** Creates the refusal with a remediation message aware of the ticket's scope. */404 constructor(code: SyncEnrollmentFailureCode, scope?: "sync" | "provision") {405 super(enrollmentMessages[code](scope));406 this.code = code;407 }408}409410/** True when an error is a refused ticket enrollment (see {@link SyncEnrollmentError}). */411export function isSyncEnrollmentError(error: unknown): error is SyncEnrollmentError {412 return error instanceof SyncEnrollmentError;413}414415/** Options for {@link enrollSyncTicket}. */416export type EnrollSyncTicketOptions = {417 /** Fetch implementation for the scope-down exchange (tests inject one). */418 fetcher?: typeof fetch;419};420421// Injectable dependencies for the enrollment flow; production callers go422// through enrollSyncTicket, tests drive this directly.423type TicketEnrollmentDeps = {424 fetcher?: typeof fetch;425 keyStore?: DeviceKeyStore;426 preflight?: (ticketUrl: string) => Promise<TicketStoreStatus>;427 timeoutMs?: number;428 elect?: () => Promise<Session>;429};430431// The enrollment flow behind enrollSyncTicket, with its network, key-custody,432// and election dependencies injectable. Exported for tests; the entry does433// not re-export it.434export async function performTicketEnrollment(435 ticket: string,436 deps: TicketEnrollmentDeps = {},437): Promise<Session> {438 // Only a provision-scoped ticket reaches the scope-down exchange, so only439 // then is there a binding to offer.440 let devicePublicKey: DevicePublicKey | undefined;441 const parsed = parseSyncTicket(ticket);442 if (parsed?.scope === "provision") {443 try {444 devicePublicKey = await exportDevicePublicKey(await getOrCreatePopKeyPair(parsed.appId));445 } catch {446 // No usable key custody in this context; the exchange still derives a447 // ticket, held as a bearer credential exactly as before.448 }449 }450 const split = await splitTicketForEnrollment(ticket, deps.fetcher, devicePublicKey);451 const previous = readDeclaredSink();452 const declared = await declareSinkFromTicket(split.sinkTicket, deps.keyStore, split.pop);453 // The preflight decides whether enrollment is kept: a store that answers454 // with a definite refusal rolls the declaration back before anything else455 // (election, provision custody) observes it. An unreachable store is not a456 // refusal — flaky networks must not block enrollment — so it enrolls with457 // the warning recorded where status surfaces read it.458 const status = await resolveStoreStatus({459 connect: true,460 sink: { serverUrl: declared.serverUrl },461 ...(deps.preflight ? { preflight: deps.preflight } : {}),462 ...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),463 });464 if (status.state === "no_schema" || status.state === "ticket_rejected") {465 if (previous === null) clearDeclaredSink();466 else await restorePreviousSink(previous, deps.keyStore);467 throw new SyncEnrollmentError(status.state, declared.scope);468 }469 updateRuntimeDiagnostics((diagnostics) => {470 diagnostics.storeStatus = status;471 });472 if (split.provisionUrl !== null) holdProvisionCapability(split.provisionUrl);473 return await (deps.elect ?? enableSyncBackup)();474}475476async function restorePreviousSink(477 previous: DataSinkDeclaration,478 keyStore?: DeviceKeyStore,479): Promise<void> {480 // declareDataSink refuses to replace a *different* sink; enrollment over an481 // existing declaration can only have re-declared the same store (or thrown482 // before reaching the preflight), so restoring the snapshot always passes.483 await declareDataSink(previous, keyStore);484}485486/**487 * Enrolls a `lofisync1.` app-connect ticket as this device's sync location and488 * elects to back up and sync in one step: the ticket's URL becomes the sync489 * server, the local data pushes up under the same identity, and the session490 * reflects the declared sink. Throws `DataSinkError` (see `data-sink.ts`) for491 * malformed tickets, ws URLs, an app id that contradicts a compiled-in managed492 * app, or a different sink already declared on this device.493 *494 * A `provision`-scoped ticket is split before anything persists: the node's495 * scope-down exchange mints a derived sync ticket, that becomes the declared496 * sink, and the provision capability is only *held* in memory (see497 * `provision.ts` — sealing it behind a passkey is a separate, explicit498 * ceremony). The exchange is offered this device's public key (see `pop.ts`),499 * and when the node binds the derived ticket to it, connecting thereafter500 * requires the proof-of-possession exchange — the enrolled credential stops501 * being a pure bearer string. Against a node without the exchange, the ticket502 * enrolls as pasted, exactly as before.503 *504 * Before electing, the node's store answers a bounded metadata preflight and505 * that answer decides whether enrollment is kept: a store with no schema for506 * this app or a rejected ticket rolls the declaration back and throws507 * {@link SyncEnrollmentError} — the device stays exactly as it was. A store508 * that is merely unreachable enrolls anyway, with the warning recorded in509 * runtime diagnostics (`storeStatus`). {@link SyncOwnerError} propagates from510 * the election when sync on this device belongs to a different account.511 *512 * Enrollment ends by electing sync, so the {@link enableSyncBackup} reload513 * caveat applies: on `SharedWorker`-capable browsers the page reloads and the514 * returned promise never settles.515 */516export async function enrollSyncTicket(517 ticket: string,518 options: EnrollSyncTicketOptions = {},519): Promise<Session> {520 return await performTicketEnrollment(ticket, { fetcher: options.fetcher });521}522523/**524 * Stops replicating this account to the server and returns to local-only. The525 * local data and the account are untouched — this only detaches the network, so526 * electing to sync again resumes against the same account. Also releases the527 * sync-owner pin, so a different account on this device may elect afterwards.528 * Safe to call whatever state the transport is in: a runtime that was created529 * without a configured server has nothing to detach and none is forced open.530 */531export async function stopSyncBackup(): Promise<Session> {532 setSyncElected(false);533 clearSyncOwner();534 // Only a transport that was actually configured can disconnect: a runtime535 // created local-only (a stale tab, an unopenable sink) has nothing to536 // detach, and the vendor client throws when asked to. The election above is537 // already cleared either way, which is the state stop-sync promises.538 if (runtimeCanReconnect()) {539 await (await getRuntime()).db.disconnect();540 }541 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));542 return readSession();543}544545/** Confirmation required before recovery may replace a different local-only account. */546export type AccountReplacementOptions = {547 /** Explicit acknowledgement that a different local-only account may be discarded. */548 confirmLocalReplacement?: boolean;549};550551/** Raised when account recovery needs explicit acknowledgement of local replacement. */552export class AccountReplacementError extends Error {553 /** Stable error class name for diagnostics and error boundaries. */554 override readonly name = "AccountReplacementError";555 /** Stable category for user-facing confirmation flows. */556 readonly code = "confirmation-required";557 /** Creates the non-secret replacement warning. */558 constructor() {559 super(560 "Restoring would replace this device's local-only account. Confirm replacement only after saving any data that has not synced.",561 );562 }563}564565async function replaceAccountSecret(566 secret: string,567 options: AccountReplacementOptions,568 onConfirmed?: () => void,569): Promise<Session> {570 const current = await currentSecret();571 if (current === secret) {572 // The device already holds this account; there is no principal to replace.573 // Electing backup and sync is the whole remaining intent, and the election574 // path migrates a never-synced device's local rows instead of hiding them.575 onConfirmed?.();576 return await enableSyncBackup();577 }578 if (!syncElected() && !options.confirmLocalReplacement) {579 throw new AccountReplacementError();580 }581 onConfirmed?.();582 await replaceRuntimePrincipal(secret, {583 onSecretSaved: async () => {584 // Commit election only after the restored secret is durably saved: a585 // replacement that fails earlier leaves the device booting its intact586 // local account instead of an empty managed namespace.587 if (!syncAvailable()) return;588 setSyncElected(true);589 electManagedNamespace({ migrateLocalRows: false });590 // An explicit restore ceremony adopts sync ownership: the restored591 // account is who the user chose to sync as. The principal's name is592 // backfilled by the first managed boot.593 recordSyncOwner({ fingerprint: await secretFingerprint(secret), user_id: null });594 },595 });596 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));597 return readSession();598}599600/**601 * Reconstructs an account from its recovery phrase, elects sync, and replaces602 * the active runtime. Throws {@link RecoveryError} for malformed phrases and603 * {@link AccountReplacementError} when confirmation is required. On604 * `SharedWorker`-capable browsers the replacement reloads the page and the605 * returned promise never settles; the app resumes on the restored account606 * after reload.607 */608export async function restoreFromRecoveryPhrase(609 phrase: string,610 options: AccountReplacementOptions = {},611): Promise<Session> {612 return await replaceAccountSecret(fromRecoveryPhrase(phrase), options);613}614615/**616 * Restores a passkey-backed secret and recreates Jazz on that stable principal.617 * The {@link restoreFromRecoveryPhrase} reload caveat applies: on618 * `SharedWorker`-capable browsers the page reloads and the returned promise619 * never settles.620 */621export async function restoreFromPasskey(622 options: AccountReplacementOptions = {},623): Promise<Session> {624 const secret = await restoreSecretWithPasskey(createPasskeyBackupAdapter());625 // Recoverability is recorded only once replacement is confirmed: an aborted626 // restore must not leave metadata claiming this device's account is backed627 // up by the passkey that was merely asserted.628 return await replaceAccountSecret(secret, options, () => setPasskeyRecoverable(true));629}630631/** True when an error is a recovery-phrase problem the user can fix and retry. */632export function isRecoveryError(error: unknown): error is RecoveryError {633 return error instanceof RecoveryError;634}635636/** True when an error came from a passkey ceremony (enroll / confirm). */637export function isAuthError(error: unknown): error is AuthError {638 return error instanceof AuthError;639}640641/** True when an error came from a recoverable passkey backup or restore ceremony. */642export function isRecoverablePasskeyError(error: unknown): error is RecoverablePasskeyError {643 return error instanceof RecoverablePasskeyError;644}645646/** True when recovery requires explicit confirmation before replacing a local account. */647export function isAccountReplacementError(error: unknown): error is AccountReplacementError {648 return error instanceof AccountReplacementError;649}