clone

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

feat/141-effect-library
lofi / package / runtime / session.ts
session.ts 27.4K
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 {52 completePopExchange,53 type DevicePublicKey,54 exportDevicePublicKey,55 getOrCreatePopKeyPair,56} from "./pop.ts";57import { holdProvisionCapability } from "./provision.ts";58import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";59import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";60import {61 backupSecretWithPasskey,62 createPasskeyBackupAdapter,63 RecoverablePasskeyError,64 restoreSecretWithPasskey,65} from "./passkey-recovery.ts";66import {67 getRuntime,68 getRuntimePrincipal,69 recreateRuntime,70 reloadBrowserRuntime,71 replaceRuntimePrincipal,72 runtimeCanReconnect,73 runtimeRecreatedEvent,74 updateRuntimeDiagnostics,75} from "./runtime.ts";76import { electManagedNamespace, readNamespaceState } from "./namespace-state.ts";7778/** A non-secret description of the sync location in effect. */79export type SessionSink = {80 /** `declared` is a runtime-declared sink; `default` is the compiled managed app. */81 source: "declared" | "default";82 /** The sink host (never the full URL — a ticket URL is a bearer credential). */83 host: string;84 /** The user-facing label from enrollment, when one was given. */85 label?: string;86};8788/** A snapshot of the account: what is possible and what the user has chosen. */89export type Session = {90 /**91 * Stable, app-scoped Jazz sharing identity for the active account. Named92 * `user_id` because it relays the sync principal's vocabulary verbatim —93 * the same identifier permission rows and directory tables carry.94 */95 user_id: string | null;96 /** Whether a sync location exists (declared or compiled), so backup + sync is possible. */97 syncAvailable: boolean;98 /** The sync location in effect, or `null` while the device is local-only. */99 sink: SessionSink | null;100 /** Whether the user has elected to back up and sync this account on this device. */101 backedUp: boolean;102 /** Whether writes replicate right now (available *and* elected). */103 syncing: boolean;104 /**105 * Whether this boot found sync elected by a different account than the one106 * in hand. Transport is then suppressed; the remediation is to stop sync107 * (releasing the election) or restore the owning account.108 */109 syncOwnerMismatch: boolean;110 /**111 * Whether a passkey guards the recovery phrase on this device — revealing it112 * then requires a user-verifying ceremony. This confirms the person is present;113 * it does **not** encrypt the account secret held on the device.114 */115 phraseGuarded: boolean;116 /** Whether this device created a passkey containing a recoverable account backup. */117 passkeyRecoverable: boolean;118};119120// A per-device flag recording that the user enrolled a passkey to guard the121// recovery phrase. The passkey itself is a resident credential on the device;122// this only remembers to ask for it before revealing the phrase.123const phraseGuardKey = `lofi:phrase-passkey:${appId}`;124const recoverablePasskeyKey = `lofi:recoverable-passkey:${appId}`;125126// The guard record pins the enrolled credential's id so the reveal ceremony127// accepts only that passkey. Legacy records stored the bare flag "1" — those128// devices stay guarded but unpinned until the guard is re-enrolled.129type PhraseGuard = { guarded: boolean; credentialId: string | null };130131function readPhraseGuard(): PhraseGuard {132 if (typeof localStorage === "undefined") return { guarded: false, credentialId: null };133 try {134 const raw = localStorage.getItem(phraseGuardKey);135 if (!raw) return { guarded: false, credentialId: null };136 if (raw === "1") return { guarded: true, credentialId: null };137 try {138 const value = JSON.parse(raw) as { id?: unknown };139 return {140 guarded: true,141 credentialId: typeof value.id === "string" && value.id ? value.id : null,142 };143 } catch {144 return { guarded: true, credentialId: null };145 }146 } catch {147 return { guarded: false, credentialId: null };148 }149}150151function phraseGuarded(): boolean {152 return readPhraseGuard().guarded;153}154155function setPhraseGuard(credentialId: string): void {156 if (typeof localStorage === "undefined") return;157 try {158 localStorage.setItem(phraseGuardKey, JSON.stringify({ id: credentialId }));159 } catch {160 // A private-mode storage failure must not break the ceremony itself.161 }162}163164function clearPhraseGuard(): void {165 if (typeof localStorage === "undefined") return;166 try {167 localStorage.removeItem(phraseGuardKey);168 } catch {169 // A private-mode storage failure must not break the ceremony itself.170 }171}172173function passkeyRecoverable(): boolean {174 if (typeof localStorage === "undefined") return false;175 try {176 return localStorage.getItem(recoverablePasskeyKey) === "1";177 } catch {178 return false;179 }180}181182function setPasskeyRecoverable(value: boolean): void {183 if (typeof localStorage === "undefined") return;184 try {185 if (value) localStorage.setItem(recoverablePasskeyKey, "1");186 else localStorage.removeItem(recoverablePasskeyKey);187 } catch {188 // The credential remains the source of truth if local metadata cannot persist.189 }190}191192function sessionSink(): SessionSink | null {193 const sink = activeSink();194 if (!sink) return null;195 let host: string;196 try {197 host = new URL(sink.serverUrl).host;198 } catch {199 host = "unknown";200 }201 return { source: sink.source, host, ...(sink.label ? { label: sink.label } : {}) };202}203204/** Reads the current session. Synchronous — it never prompts or touches the network. */205export function readSession(): Session {206 return {207 user_id: getRuntimePrincipal(),208 syncAvailable: syncAvailable(),209 sink: sessionSink(),210 backedUp: syncElected(),211 syncing: syncing(),212 syncOwnerMismatch: readSyncOwnerVerdict().state === "foreign",213 phraseGuarded: phraseGuarded(),214 passkeyRecoverable: passkeyRecoverable(),215 };216}217218/** Resolves the runtime before returning a session with a stable `user_id`. */219export async function readAccountSession(): Promise<Session> {220 await getRuntime();221 return readSession();222}223224// The account secret the runtime is already using. `getOrCreateSecret` is a225// read-through: the runtime created it on first boot, so this returns that same226// secret rather than minting a new one. It still takes the app's secret lock so227// a call that races a first boot in another tab observes that boot's secret.228async function currentSecret(): Promise<string> {229 return await withAuthSecretLock(appId, () => BrowserAuthSecretStore.getOrCreateSecret({ appId }));230}231232/**233 * Enrolls a passkey to guard the recovery phrase on this device. Enrollment is a234 * user-verifying ceremony, so it doubles as the confirmation for the reveal that235 * immediately follows. Returns `true` when a guard was set; `false` (without236 * throwing) when this browser or origin cannot enroll a passkey, so the caller237 * can still show the phrase while telling the user it is unguarded. Re-throws a238 * `cancelled` ceremony so the caller can abort.239 */240export async function createBackupPasskey(label?: string): Promise<boolean> {241 try {242 const credential = await enrollDeviceCredential(label ? { label } : {});243 setPhraseGuard(credential.id);244 return true;245 } catch (error) {246 if (247 error instanceof AuthError &&248 (error.code === "unsupported" || error.code === "origin-rejected")249 ) {250 // A failed re-enrollment must not remove a guard that already stands:251 // the existing credential still protects the reveal, so report the252 // device as guarded rather than silently downgrading it.253 if (readPhraseGuard().guarded) return true;254 clearPhraseGuard();255 return false;256 }257 throw error;258 }259}260261/**262 * Non-secret confirmation that a recoverable passkey was created for an263 * account and RP-ID. `user_id` relays the sync principal's vocabulary264 * verbatim, matching {@link Session.user_id}.265 */266export type PasskeyBackupReceipt = { user_id: string; rpId: string };267268/**269 * Stores the current 32-byte local-first secret inside Jazz's resident,270 * user-verifying passkey backup. Unlike `createBackupPasskey`, this credential271 * can restore the account and is not merely a local phrase-reveal guard.272 */273export async function createRecoverablePasskeyBackup(274 displayName?: string,275): Promise<PasskeyBackupReceipt> {276 const runtime = await getRuntime();277 const user_id = runtime.db.getAuthState().session?.user_id;278 if (!user_id) throw new RecoverablePasskeyError("backup-failed");279 await backupSecretWithPasskey(280 createPasskeyBackupAdapter(),281 await currentSecret(),282 displayName?.trim() || `${getLofiApp().name} account ${user_id.slice(0, 8)}`,283 );284 setPasskeyRecoverable(true);285 return {286 user_id,287 rpId: getLofiApp().passkey?.rpId?.trim() || globalThis.location?.hostname || "localhost",288 };289}290291/**292 * Runs the user-verifying passkey ceremony that must precede revealing the phrase293 * on a guarded device. The ceremony is pinned to the enrolled credential, so no294 * other passkey for the same RP ID can satisfy it (guards enrolled before pinning295 * remain discoverable until re-enrolled). A no-op when the device is not guarded.296 * Throws `cancelled` if the user dismisses the prompt — the phrase is then not297 * revealed.298 */299export async function confirmPhraseAccess(): Promise<void> {300 const guard = readPhraseGuard();301 if (!guard.guarded) return;302 await authenticateDeviceCredential(303 guard.credentialId ? { credentialId: guard.credentialId } : {},304 );305}306307/**308 * Reveals the current account's recovery phrase — the same 32-byte secret encoded309 * as words. Show it for the user to write down; never persist it. Works whether310 * or not sync is on, but only matters once the account syncs, since the phrase311 * recovers what has been backed up.312 */313export async function revealRecoveryPhrase(): Promise<string> {314 return toRecoveryPhrase(await currentSecret());315}316317/**318 * Elects to back up and sync this account: replication to the managed Jazz app319 * turns on and the existing local data pushes up under the same identity. Pair320 * it with {@link revealRecoveryPhrase} so the user has a way back in. No-op when321 * no Jazz app is configured.322 *323 * In browsers with `SharedWorker` support, first-time election reloads the page324 * to reopen the runtime on the elected namespace; the returned promise never325 * settles there, and the app resumes after reload. Do not put follow-up UI326 * behind the `await` — read the post-reload session state instead.327 *328 * Throws {@link SyncOwnerError} when sync on this device was elected by a329 * different account: stop sync (releasing the election) or restore the owning330 * account first. Electing under an unclaimed election records this account as331 * the owner.332 */333export async function enableSyncBackup(): Promise<Session> {334 // The documented no-op: without a sync location there is nothing to sync335 // to, and electing the managed namespace anyway would hide the local rows336 // behind an empty database on every later boot.337 if (!syncAvailable()) return readSession();338 await assertAndRecordSyncOwnerForElection();339 const alreadyManaged = readNamespaceState().mode === "managed";340 setSyncElected(true);341 if (alreadyManaged && runtimeCanReconnect()) {342 await (await getRuntime()).db.reconnect();343 } else {344 if (!alreadyManaged) electManagedNamespace({ migrateLocalRows: true });345 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {346 return await reloadBrowserRuntime();347 }348 await recreateRuntime();349 }350 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));351 return readSession();352}353354// The election half of the sync-owner pin: refuse to elect under an account355// that is not the recorded owner, and claim ownership when none is recorded.356// The secret resolver is injectable so tests adjudicate without a secret357// store. Exported for those tests; the entry does not re-export it.358export async function assertAndRecordSyncOwnerForElection(359 resolveSecret: () => Promise<string> = currentSecret,360): Promise<void> {361 const fingerprint = await secretFingerprint(await resolveSecret());362 const verdict = adjudicateSyncOwner(fingerprint);363 if (verdict.state === "foreign") throw new SyncOwnerError(verdict.owner_user_id);364 if (verdict.state === "unclaimed") {365 // The principal may not be known yet (first election precedes the managed366 // boot); the fingerprint adjudicates, and boot backfills the name.367 recordSyncOwner({ fingerprint, user_id: getRuntimePrincipal() });368 }369}370371/**372 * Stable categories for a refused ticket enrollment, relayed from the node's373 * store preflight: `no_schema` — the store holds no deployed schema for this374 * app, so nothing could sync; `ticket_rejected` — the node no longer accepts375 * the ticket (revoked, or the node was reset). Both are definite answers from376 * the node; an unreachable store is deliberately not a refusal category.377 */378export type SyncEnrollmentFailureCode = "no_schema" | "ticket_rejected";379380const enrollmentMessages: Record<SyncEnrollmentFailureCode, (scope?: string) => string> = {381 no_schema: (scope) =>382 "The node accepted the ticket, but its store has no schema deployed for this app, so " +383 "nothing can sync. Enrollment was not kept. " +384 (scope === "provision"385 ? "Provision the store, then enroll again."386 : "Provision the store from a provision-scoped ticket, then enroll this device again."),387 ticket_rejected: () =>388 "The node rejected this ticket — it was revoked or the node was reset. Enrollment was " +389 "not kept. Get a fresh ticket from the node and enroll again.",390};391392/**393 * Thrown by {@link enrollSyncTicket} when the node's store preflight refuses394 * the enrollment: the sink declaration is rolled back, no provision395 * capability is held, and sync is not elected, so the device stays exactly as396 * it was before the attempt. The message is user-presentable and names the397 * remediation for its {@link SyncEnrollmentFailureCode}. A merely unreachable398 * store never throws this — it enrolls with the warning recorded in runtime399 * diagnostics (`storeStatus`) instead, because a flaky network must not block400 * a legitimate ticket.401 */402export class SyncEnrollmentError extends Error {403 /** Stable error class name for diagnostics and UI boundaries. */404 override readonly name = "SyncEnrollmentError";405 /** The preflight answer that refused the enrollment. */406 readonly code: SyncEnrollmentFailureCode;407408 /** Creates the refusal with a remediation message aware of the ticket's scope. */409 constructor(code: SyncEnrollmentFailureCode, scope?: "sync" | "provision") {410 super(enrollmentMessages[code](scope));411 this.code = code;412 }413}414415/** True when an error is a refused ticket enrollment (see {@link SyncEnrollmentError}). */416export function isSyncEnrollmentError(error: unknown): error is SyncEnrollmentError {417 return error instanceof SyncEnrollmentError;418}419420/** Options for {@link enrollSyncTicket}. */421export type EnrollSyncTicketOptions = {422 /** Fetch implementation for the scope-down exchange (tests inject one). */423 fetcher?: typeof fetch;424};425426// Injectable dependencies for the enrollment flow; production callers go427// through enrollSyncTicket, tests drive this directly.428type TicketEnrollmentDeps = {429 fetcher?: typeof fetch;430 keyStore?: DeviceKeyStore;431 preflight?: (ticketUrl: string) => Promise<TicketStoreStatus>;432 timeoutMs?: number;433 elect?: () => Promise<Session>;434};435436// The enrollment flow behind enrollSyncTicket, with its network, key-custody,437// and election dependencies injectable. Exported for tests; the entry does438// not re-export it.439export async function performTicketEnrollment(440 ticket: string,441 deps: TicketEnrollmentDeps = {},442): Promise<Session> {443 // Only a provision-scoped ticket reaches the scope-down exchange, so only444 // then is there a binding to offer.445 let deviceKeyPair: CryptoKeyPair | undefined;446 let devicePublicKey: DevicePublicKey | undefined;447 const parsed = parseSyncTicket(ticket);448 if (parsed?.scope === "provision") {449 try {450 deviceKeyPair = await getOrCreatePopKeyPair(parsed.appId);451 devicePublicKey = await exportDevicePublicKey(deviceKeyPair);452 } catch {453 // No usable key custody in this context; the exchange still derives a454 // ticket, held as a bearer credential exactly as before.455 }456 }457 const split = await splitTicketForEnrollment(ticket, deps.fetcher, devicePublicKey);458 const previous = readDeclaredSink();459 const declared = await declareSinkFromTicket(split.sinkTicket, deps.keyStore, split.pop);460 // A PoP-bound derived ticket rejects every bare request, including the461 // metadata preflight. Prove possession first and ask store-status through462 // the short-lived connect URL, just as the managed runtime does for sync.463 // Without this exchange a healthy store looks unreachable, while a real464 // no-schema refusal can be lost and enrollment incorrectly kept.465 let preflightServerUrl = declared.serverUrl;466 if (split.pop && deviceKeyPair) {467 preflightServerUrl = await completePopExchange({468 serverUrl: declared.serverUrl,469 appId: declared.appId,470 ticketId: split.pop.ticketId,471 keyPair: deviceKeyPair,472 ...(deps.fetcher ? { fetcher: deps.fetcher } : {}),473 }) ?? declared.serverUrl;474 }475 // The preflight decides whether enrollment is kept: a store that answers476 // with a definite refusal rolls the declaration back before anything else477 // (election, provision custody) observes it. An unreachable store is not a478 // refusal — flaky networks must not block enrollment — so it enrolls with479 // the warning recorded where status surfaces read it.480 const status = await resolveStoreStatus({481 connect: true,482 sink: { serverUrl: preflightServerUrl },483 ...(deps.preflight ? { preflight: deps.preflight } : {}),484 ...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),485 });486 if (status.state === "no_schema" || status.state === "ticket_rejected") {487 if (previous === null) clearDeclaredSink();488 else await restorePreviousSink(previous, deps.keyStore);489 throw new SyncEnrollmentError(status.state, declared.scope);490 }491 updateRuntimeDiagnostics((diagnostics) => {492 diagnostics.storeStatus = status;493 });494 if (split.provisionUrl !== null) holdProvisionCapability(split.provisionUrl);495 return await (deps.elect ?? enableSyncBackup)();496}497498async function restorePreviousSink(499 previous: DataSinkDeclaration,500 keyStore?: DeviceKeyStore,501): Promise<void> {502 // declareDataSink refuses to replace a *different* sink; enrollment over an503 // existing declaration can only have re-declared the same store (or thrown504 // before reaching the preflight), so restoring the snapshot always passes.505 await declareDataSink(previous, keyStore);506}507508/**509 * Enrolls a `lofisync1.` app-connect ticket as this device's sync location and510 * elects to back up and sync in one step: the ticket's URL becomes the sync511 * server, the local data pushes up under the same identity, and the session512 * reflects the declared sink. Throws `DataSinkError` (see `data-sink.ts`) for513 * malformed tickets, ws URLs, an app id that contradicts a compiled-in managed514 * app, or a different sink already declared on this device.515 *516 * A `provision`-scoped ticket is split before anything persists: the node's517 * scope-down exchange mints a derived sync ticket, that becomes the declared518 * sink, and the provision capability is only *held* in memory (see519 * `provision.ts` — sealing it behind a passkey is a separate, explicit520 * ceremony). The exchange is offered this device's public key (see `pop.ts`),521 * and when the node binds the derived ticket to it, connecting thereafter522 * requires the proof-of-possession exchange — the enrolled credential stops523 * being a pure bearer string. Against a node without the exchange, the ticket524 * enrolls as pasted, exactly as before.525 *526 * Before electing, the node's store answers a bounded metadata preflight and527 * that answer decides whether enrollment is kept: a store with no schema for528 * this app or a rejected ticket rolls the declaration back and throws529 * {@link SyncEnrollmentError} — the device stays exactly as it was. A store530 * that is merely unreachable enrolls anyway, with the warning recorded in531 * runtime diagnostics (`storeStatus`). {@link SyncOwnerError} propagates from532 * the election when sync on this device belongs to a different account.533 *534 * Enrollment ends by electing sync, so the {@link enableSyncBackup} reload535 * caveat applies: on `SharedWorker`-capable browsers the page reloads and the536 * returned promise never settles.537 */538export async function enrollSyncTicket(539 ticket: string,540 options: EnrollSyncTicketOptions = {},541): Promise<Session> {542 return await performTicketEnrollment(ticket, { fetcher: options.fetcher });543}544545/**546 * Stops replicating this account to the server and returns to local-only. The547 * local data and the account are untouched — this only detaches the network, so548 * electing to sync again resumes against the same account. Also releases the549 * sync-owner pin, so a different account on this device may elect afterwards.550 * Safe to call whatever state the transport is in: a runtime that was created551 * without a configured server has nothing to detach and none is forced open.552 */553export async function stopSyncBackup(): Promise<Session> {554 setSyncElected(false);555 clearSyncOwner();556 // Only a transport that was actually configured can disconnect: a runtime557 // created local-only (a stale tab, an unopenable sink) has nothing to558 // detach, and the vendor client throws when asked to. The election above is559 // already cleared either way, which is the state stop-sync promises.560 if (runtimeCanReconnect()) {561 await (await getRuntime()).db.disconnect();562 }563 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));564 return readSession();565}566567/** Confirmation required before recovery may replace a different local-only account. */568export type AccountReplacementOptions = {569 /** Explicit acknowledgement that a different local-only account may be discarded. */570 confirmLocalReplacement?: boolean;571};572573/** Raised when account recovery needs explicit acknowledgement of local replacement. */574export class AccountReplacementError extends Error {575 /** Stable error class name for diagnostics and error boundaries. */576 override readonly name = "AccountReplacementError";577 /** Stable category for user-facing confirmation flows. */578 readonly code = "confirmation-required";579 /** Creates the non-secret replacement warning. */580 constructor() {581 super(582 "Restoring would replace this device's local-only account. Confirm replacement only after saving any data that has not synced.",583 );584 }585}586587async function replaceAccountSecret(588 secret: string,589 options: AccountReplacementOptions,590 onConfirmed?: () => void,591): Promise<Session> {592 const current = await currentSecret();593 if (current === secret) {594 // The device already holds this account; there is no principal to replace.595 // Electing backup and sync is the whole remaining intent, and the election596 // path migrates a never-synced device's local rows instead of hiding them.597 onConfirmed?.();598 return await enableSyncBackup();599 }600 if (!syncElected() && !options.confirmLocalReplacement) {601 throw new AccountReplacementError();602 }603 onConfirmed?.();604 await replaceRuntimePrincipal(secret, {605 onSecretSaved: async () => {606 // Commit election only after the restored secret is durably saved: a607 // replacement that fails earlier leaves the device booting its intact608 // local account instead of an empty managed namespace.609 if (!syncAvailable()) return;610 setSyncElected(true);611 electManagedNamespace({ migrateLocalRows: false });612 // An explicit restore ceremony adopts sync ownership: the restored613 // account is who the user chose to sync as. The principal's name is614 // backfilled by the first managed boot.615 recordSyncOwner({ fingerprint: await secretFingerprint(secret), user_id: null });616 },617 });618 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));619 return readSession();620}621622/**623 * Reconstructs an account from its recovery phrase, elects sync, and replaces624 * the active runtime. Throws {@link RecoveryError} for malformed phrases and625 * {@link AccountReplacementError} when confirmation is required. On626 * `SharedWorker`-capable browsers the replacement reloads the page and the627 * returned promise never settles; the app resumes on the restored account628 * after reload.629 */630export async function restoreFromRecoveryPhrase(631 phrase: string,632 options: AccountReplacementOptions = {},633): Promise<Session> {634 return await replaceAccountSecret(fromRecoveryPhrase(phrase), options);635}636637/**638 * Restores a passkey-backed secret and recreates Jazz on that stable principal.639 * The {@link restoreFromRecoveryPhrase} reload caveat applies: on640 * `SharedWorker`-capable browsers the page reloads and the returned promise641 * never settles.642 */643export async function restoreFromPasskey(644 options: AccountReplacementOptions = {},645): Promise<Session> {646 const secret = await restoreSecretWithPasskey(createPasskeyBackupAdapter());647 // Recoverability is recorded only once replacement is confirmed: an aborted648 // restore must not leave metadata claiming this device's account is backed649 // up by the passkey that was merely asserted.650 return await replaceAccountSecret(secret, options, () => setPasskeyRecoverable(true));651}652653/** True when an error is a recovery-phrase problem the user can fix and retry. */654export function isRecoveryError(error: unknown): error is RecoveryError {655 return error instanceof RecoveryError;656}657658/** True when an error came from a passkey ceremony (enroll / confirm). */659export function isAuthError(error: unknown): error is AuthError {660 return error instanceof AuthError;661}662663/** True when an error came from a recoverable passkey backup or restore ceremony. */664export function isRecoverablePasskeyError(error: unknown): error is RecoverablePasskeyError {665 return error instanceof RecoverablePasskeyError;666}667668/** True when recovery requires explicit confirmation before replacing a local account. */669export function isAccountReplacementError(error: unknown): error is AccountReplacementError {670 return error instanceof AccountReplacementError;671}