clone

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

feat/prf-provision-custody
lofi / package / runtime / session.ts
session.ts 16.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 {23 activeSink,24 appId,25 setSyncElected,26 syncAvailable,27 syncElected,28 syncing,29} from "./config.ts";30import { declareSinkFromTicket, splitTicketForEnrollment } from "./data-sink.ts";31import { holdProvisionCapability } from "./provision.ts";32import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";33import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";34import {35 backupSecretWithPasskey,36 createPasskeyBackupAdapter,37 RecoverablePasskeyError,38 restoreSecretWithPasskey,39} from "./passkey-recovery.ts";40import {41 getRuntime,42 getRuntimePrincipal,43 recreateRuntime,44 reloadBrowserRuntime,45 replaceRuntimePrincipal,46 runtimeCanReconnect,47 runtimeRecreatedEvent,48} from "./runtime.ts";49import { electManagedNamespace, readNamespaceState } from "./namespace-state.ts";5051/** A non-secret description of the sync location in effect. */52export type SessionSink = {53 /** `declared` is a runtime-declared sink; `default` is the compiled managed app. */54 source: "declared" | "default";55 /** The sink host (never the full URL — a ticket URL is a bearer credential). */56 host: string;57 /** The user-facing label from enrollment, when one was given. */58 label?: string;59};6061/** A snapshot of the account: what is possible and what the user has chosen. */62export type Session = {63 /** Stable, app-scoped Jazz sharing identity for the active account. */64 user_id: string | null;65 /** Whether a sync location exists (declared or compiled), so backup + sync is possible. */66 syncAvailable: boolean;67 /** The sync location in effect, or `null` while the device is local-only. */68 sink: SessionSink | null;69 /** Whether the user has elected to back up and sync this account on this device. */70 backedUp: boolean;71 /** Whether writes replicate right now (available *and* elected). */72 syncing: boolean;73 /**74 * Whether a passkey guards the recovery phrase on this device — revealing it75 * then requires a user-verifying ceremony. This confirms the person is present;76 * it does **not** encrypt the account secret held on the device.77 */78 phraseGuarded: boolean;79 /** Whether this device created a passkey containing a recoverable account backup. */80 passkeyRecoverable: boolean;81};8283// A per-device flag recording that the user enrolled a passkey to guard the84// recovery phrase. The passkey itself is a resident credential on the device;85// this only remembers to ask for it before revealing the phrase.86const phraseGuardKey = `lofi:phrase-passkey:${appId}`;87const recoverablePasskeyKey = `lofi:recoverable-passkey:${appId}`;8889// The guard record pins the enrolled credential's id so the reveal ceremony90// accepts only that passkey. Legacy records stored the bare flag "1" — those91// devices stay guarded but unpinned until the guard is re-enrolled.92type PhraseGuard = { guarded: boolean; credentialId: string | null };9394function readPhraseGuard(): PhraseGuard {95 if (typeof localStorage === "undefined") return { guarded: false, credentialId: null };96 try {97 const raw = localStorage.getItem(phraseGuardKey);98 if (!raw) return { guarded: false, credentialId: null };99 if (raw === "1") return { guarded: true, credentialId: null };100 try {101 const value = JSON.parse(raw) as { id?: unknown };102 return {103 guarded: true,104 credentialId: typeof value.id === "string" && value.id ? value.id : null,105 };106 } catch {107 return { guarded: true, credentialId: null };108 }109 } catch {110 return { guarded: false, credentialId: null };111 }112}113114function phraseGuarded(): boolean {115 return readPhraseGuard().guarded;116}117118function setPhraseGuard(credentialId: string): void {119 if (typeof localStorage === "undefined") return;120 try {121 localStorage.setItem(phraseGuardKey, JSON.stringify({ id: credentialId }));122 } catch {123 // A private-mode storage failure must not break the ceremony itself.124 }125}126127function clearPhraseGuard(): void {128 if (typeof localStorage === "undefined") return;129 try {130 localStorage.removeItem(phraseGuardKey);131 } catch {132 // A private-mode storage failure must not break the ceremony itself.133 }134}135136function passkeyRecoverable(): boolean {137 if (typeof localStorage === "undefined") return false;138 try {139 return localStorage.getItem(recoverablePasskeyKey) === "1";140 } catch {141 return false;142 }143}144145function setPasskeyRecoverable(value: boolean): void {146 if (typeof localStorage === "undefined") return;147 try {148 if (value) localStorage.setItem(recoverablePasskeyKey, "1");149 else localStorage.removeItem(recoverablePasskeyKey);150 } catch {151 // The credential remains the source of truth if local metadata cannot persist.152 }153}154155function sessionSink(): SessionSink | null {156 const sink = activeSink();157 if (!sink) return null;158 let host: string;159 try {160 host = new URL(sink.serverUrl).host;161 } catch {162 host = "unknown";163 }164 return { source: sink.source, host, ...(sink.label ? { label: sink.label } : {}) };165}166167/** Reads the current session. Synchronous — it never prompts or touches the network. */168export function readSession(): Session {169 return {170 user_id: getRuntimePrincipal(),171 syncAvailable: syncAvailable(),172 sink: sessionSink(),173 backedUp: syncElected(),174 syncing: syncing(),175 phraseGuarded: phraseGuarded(),176 passkeyRecoverable: passkeyRecoverable(),177 };178}179180/** Resolves the runtime before returning a session with a stable `user_id`. */181export async function readAccountSession(): Promise<Session> {182 await getRuntime();183 return readSession();184}185186// The account secret the runtime is already using. `getOrCreateSecret` is a187// read-through: the runtime created it on first boot, so this returns that same188// secret rather than minting a new one.189async function currentSecret(): Promise<string> {190 return await BrowserAuthSecretStore.getOrCreateSecret({ appId });191}192193/**194 * Enrolls a passkey to guard the recovery phrase on this device. Enrollment is a195 * user-verifying ceremony, so it doubles as the confirmation for the reveal that196 * immediately follows. Returns `true` when a guard was set; `false` (without197 * throwing) when this browser or origin cannot enroll a passkey, so the caller198 * can still show the phrase while telling the user it is unguarded. Re-throws a199 * `cancelled` ceremony so the caller can abort.200 */201export async function createBackupPasskey(label?: string): Promise<boolean> {202 try {203 const credential = await enrollDeviceCredential(label ? { label } : {});204 setPhraseGuard(credential.id);205 return true;206 } catch (error) {207 if (208 error instanceof AuthError &&209 (error.code === "unsupported" || error.code === "origin-rejected")210 ) {211 // A failed re-enrollment must not remove a guard that already stands:212 // the existing credential still protects the reveal, so report the213 // device as guarded rather than silently downgrading it.214 if (readPhraseGuard().guarded) return true;215 clearPhraseGuard();216 return false;217 }218 throw error;219 }220}221222/** Non-secret confirmation that a recoverable passkey was created for an account and RP-ID. */223export type PasskeyBackupReceipt = { user_id: string; rpId: string };224225/**226 * Stores the current 32-byte local-first secret inside Jazz's resident,227 * user-verifying passkey backup. Unlike `createBackupPasskey`, this credential228 * can restore the account and is not merely a local phrase-reveal guard.229 */230export async function createRecoverablePasskeyBackup(231 displayName?: string,232): Promise<PasskeyBackupReceipt> {233 const runtime = await getRuntime();234 const user_id = runtime.db.getAuthState().session?.user_id;235 if (!user_id) throw new RecoverablePasskeyError("backup-failed");236 await backupSecretWithPasskey(237 createPasskeyBackupAdapter(),238 await currentSecret(),239 displayName?.trim() || `${getLofiApp().name} account ${user_id.slice(0, 8)}`,240 );241 setPasskeyRecoverable(true);242 return {243 user_id,244 rpId: getLofiApp().passkey?.rpId?.trim() || globalThis.location?.hostname || "localhost",245 };246}247248/**249 * Runs the user-verifying passkey ceremony that must precede revealing the phrase250 * on a guarded device. The ceremony is pinned to the enrolled credential, so no251 * other passkey for the same RP ID can satisfy it (guards enrolled before pinning252 * remain discoverable until re-enrolled). A no-op when the device is not guarded.253 * Throws `cancelled` if the user dismisses the prompt — the phrase is then not254 * revealed.255 */256export async function confirmPhraseAccess(): Promise<void> {257 const guard = readPhraseGuard();258 if (!guard.guarded) return;259 await authenticateDeviceCredential(260 guard.credentialId ? { credentialId: guard.credentialId } : {},261 );262}263264/**265 * Reveals the current account's recovery phrase — the same 32-byte secret encoded266 * as words. Show it for the user to write down; never persist it. Works whether267 * or not sync is on, but only matters once the account syncs, since the phrase268 * recovers what has been backed up.269 */270export async function revealRecoveryPhrase(): Promise<string> {271 return toRecoveryPhrase(await currentSecret());272}273274/**275 * Elects to back up and sync this account: replication to the managed Jazz app276 * turns on and the existing local data pushes up under the same identity. Pair277 * it with {@link revealRecoveryPhrase} so the user has a way back in. No-op when278 * no Jazz app is configured.279 */280export async function enableSyncBackup(): Promise<Session> {281 // The documented no-op: without a sync location there is nothing to sync282 // to, and electing the managed namespace anyway would hide the local rows283 // behind an empty database on every later boot.284 if (!syncAvailable()) return readSession();285 const alreadyManaged = readNamespaceState().mode === "managed";286 setSyncElected(true);287 if (alreadyManaged && runtimeCanReconnect()) {288 await (await getRuntime()).db.reconnect();289 } else {290 if (!alreadyManaged) electManagedNamespace({ migrateLocalRows: true });291 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {292 return await reloadBrowserRuntime();293 }294 await recreateRuntime();295 }296 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));297 return readSession();298}299300/** Options for {@link enrollSyncTicket}. */301export type EnrollSyncTicketOptions = {302 /** Fetch implementation for the scope-down exchange (tests inject one). */303 fetcher?: typeof fetch;304};305306/**307 * Enrolls a `lofisync1.` app-connect ticket as this device's sync location and308 * elects to back up and sync in one step: the ticket's URL becomes the sync309 * server, the local data pushes up under the same identity, and the session310 * reflects the declared sink. Throws `DataSinkError` (see `data-sink.ts`) for311 * malformed tickets, ws URLs, an app id that contradicts a compiled-in managed312 * app, or a different sink already declared on this device.313 *314 * A `provision`-scoped ticket is split before anything persists: the node's315 * scope-down exchange mints a derived sync ticket, that becomes the declared316 * sink, and the provision capability is only *held* in memory (see317 * `provision.ts` — sealing it behind a passkey is a separate, explicit318 * ceremony). Against a node without the exchange, the ticket enrolls as319 * pasted, exactly as before.320 */321export async function enrollSyncTicket(322 ticket: string,323 options: EnrollSyncTicketOptions = {},324): Promise<Session> {325 const split = await splitTicketForEnrollment(ticket, options.fetcher);326 await declareSinkFromTicket(split.sinkTicket);327 if (split.provisionUrl !== null) holdProvisionCapability(split.provisionUrl);328 return await enableSyncBackup();329}330331/**332 * Stops replicating this account to the server and returns to local-only. The333 * local data and the account are untouched — this only detaches the network, so334 * electing to sync again resumes against the same account.335 */336export async function stopSyncBackup(): Promise<Session> {337 const runtime = await getRuntime();338 setSyncElected(false);339 await runtime.db.disconnect();340 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));341 return readSession();342}343344/** Confirmation required before recovery may replace a different local-only account. */345export type AccountReplacementOptions = {346 /** Explicit acknowledgement that a different local-only account may be discarded. */347 confirmLocalReplacement?: boolean;348};349350/** Raised when account recovery needs explicit acknowledgement of local replacement. */351export class AccountReplacementError extends Error {352 /** Stable error class name for diagnostics and error boundaries. */353 override readonly name = "AccountReplacementError";354 /** Stable category for user-facing confirmation flows. */355 readonly code = "confirmation-required";356 /** Creates the non-secret replacement warning. */357 constructor() {358 super(359 "Restoring would replace this device's local-only account. Confirm replacement only after saving any data that has not synced.",360 );361 }362}363364async function replaceAccountSecret(365 secret: string,366 options: AccountReplacementOptions,367 onConfirmed?: () => void,368): Promise<Session> {369 const current = await currentSecret();370 if (current === secret) {371 // The device already holds this account; there is no principal to replace.372 // Electing backup and sync is the whole remaining intent, and the election373 // path migrates a never-synced device's local rows instead of hiding them.374 onConfirmed?.();375 return await enableSyncBackup();376 }377 if (!syncElected() && !options.confirmLocalReplacement) {378 throw new AccountReplacementError();379 }380 onConfirmed?.();381 await replaceRuntimePrincipal(secret, {382 onSecretSaved: () => {383 // Commit election only after the restored secret is durably saved: a384 // replacement that fails earlier leaves the device booting its intact385 // local account instead of an empty managed namespace.386 if (!syncAvailable()) return;387 setSyncElected(true);388 electManagedNamespace({ migrateLocalRows: false });389 },390 });391 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));392 return readSession();393}394395/**396 * Reconstructs an account from its recovery phrase, elects sync, and replaces397 * the active runtime. Throws {@link RecoveryError} for malformed phrases and398 * {@link AccountReplacementError} when confirmation is required.399 */400export async function restoreFromRecoveryPhrase(401 phrase: string,402 options: AccountReplacementOptions = {},403): Promise<Session> {404 return await replaceAccountSecret(fromRecoveryPhrase(phrase), options);405}406407/** Restores a passkey-backed secret and recreates Jazz on that stable principal. */408export async function restoreFromPasskey(409 options: AccountReplacementOptions = {},410): Promise<Session> {411 const secret = await restoreSecretWithPasskey(createPasskeyBackupAdapter());412 // Recoverability is recorded only once replacement is confirmed: an aborted413 // restore must not leave metadata claiming this device's account is backed414 // up by the passkey that was merely asserted.415 return await replaceAccountSecret(secret, options, () => setPasskeyRecoverable(true));416}417418/** True when an error is a recovery-phrase problem the user can fix and retry. */419export function isRecoveryError(error: unknown): error is RecoveryError {420 return error instanceof RecoveryError;421}422423/** True when an error came from a passkey ceremony (enroll / confirm). */424export function isAuthError(error: unknown): error is AuthError {425 return error instanceof AuthError;426}427428/** True when an error came from a recoverable passkey backup or restore ceremony. */429export function isRecoverablePasskeyError(error: unknown): error is RecoverablePasskeyError {430 return error instanceof RecoverablePasskeyError;431}432433/** True when recovery requires explicit confirmation before replacing a local account. */434export function isAccountReplacementError(error: unknown): error is AccountReplacementError {435 return error instanceof AccountReplacementError;436}