clone

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

feat/141-demo-review-fixes
lofi / package / runtime / data-sink.ts
data-sink.ts 17.8K
1/**2 * Package-owned data-sink state: the user-declared sync location.3 *4 * A data sink names where this account's data syncs — a Jazz appId plus a5 * server URL — as **user-selected data, not developer configuration**. First6 * boot needs no sink and stays local-only; a deployment may still compile in7 * a managed Jazz app, which acts as the *default* sink. Declaring a sink at8 * runtime (typically by enrolling a `lofisync1.` app-connect ticket issued by9 * a self-hosted node) overrides the default for this device.10 *11 * The ticket URL is a bearer credential, so the declaration never touches12 * storage in cleartext: it persists as a sealed envelope (see `envelope.ts`)13 * under a silent device-bound key. That protects the record at rest — disk14 * images, backups, storage exfiltration — while opening with zero ceremony;15 * it does not defend against same-origin script, which can drive the silent16 * open itself. Boot unseals the record once into module memory17 * ({@link restoreDeclaredSink}), and every synchronous reader answers from18 * that state.19 *20 * @module21 */2223import type { DevicePublicKey } from "./pop.ts";24import {25 defaultDeviceKeyStore,26 deviceKeyProtector,27 deviceKeyResolver,28 type DeviceKeyStore,29 EnvelopeError,30 openJsonEnvelope,31 parseSealedEnvelope,32 sealJsonEnvelope,33} from "./envelope.ts";3435declare const __LOFI_JAZZ_APP_ID__: string;36declare const __LOFI_JAZZ_SERVER_URL__: string;3738/**39 * The app id that anchors device-local state before (or without) a managed40 * app: storage keys, the account secret store, and the local database41 * namespace all key off the anchor so they survive a sink being declared42 * later.43 */44export const LOCAL_APP_ID = "00000000-0000-0000-0000-00000000f153";4546/** The build-time Jazz app id, or the empty string when none was configured. */47export const configuredAppId: string = typeof __LOFI_JAZZ_APP_ID__ === "string"48 ? __LOFI_JAZZ_APP_ID__49 : "";5051/** The build-time Jazz server URL, or the empty string when none was configured. */52export const configuredServerUrl: string = typeof __LOFI_JAZZ_SERVER_URL__ === "string"53 ? __LOFI_JAZZ_SERVER_URL__54 : "";5556/**57 * The device-local anchor app id: the build-time app id when one exists,58 * otherwise {@link LOCAL_APP_ID}. Every localStorage key and the secret store59 * key off this value, so a runtime-declared sink never orphans state written60 * before the declaration.61 */62export const anchorAppId: string = configuredAppId || LOCAL_APP_ID;6364/** A user-declared sync location: where this account's data syncs. */65export type DataSinkDeclaration = {66 /** The Jazz app id of the target store. */67 appId: string;68 /** The http(s) sync server URL, used verbatim (a ticket URL keeps its secret path). */69 serverUrl: string;70 /**71 * The enrolled ticket's capability: `sync` is transport only; `provision`72 * additionally administers the store through the node's gate (the admin73 * secret never transits the client). Absent means `sync`.74 */75 scope?: "sync" | "provision";76 /** Optional user-facing label, e.g. the ticket's device label. */77 label?: string;78 /**79 * Opaque forward-compat carrier: the node's iroh endpoint ticket from a80 * `lofisync1.` app ticket. Unused by the browser runtime today.81 */82 node?: string;83 /**84 * Present when the derived ticket was bound to this device's keypair at85 * enrollment: connecting then requires the proof-of-possession exchange,86 * and the ticket id is what the device signs over.87 */88 pop?: { ticketId: string };89};9091/** Raised when a sink declaration or sync ticket is rejected. */92export class DataSinkError extends Error {93 /** Stable error class name for diagnostics and error boundaries. */94 override readonly name = "DataSinkError";95 /** Stable category for user-facing enrollment flows. */96 readonly code:97 | "invalid-ticket"98 | "invalid-server-url"99 | "app-id-mismatch"100 | "sink-already-declared";101 /** Creates a sink rejection with a stable category. */102 constructor(103 code: "invalid-ticket" | "invalid-server-url" | "app-id-mismatch" | "sink-already-declared",104 message: string,105 ) {106 super(message);107 this.code = code;108 }109}110111const sinkKey = `lofi:data-sink:${anchorAppId}`;112const sinkPurpose = `lofi:data-sink:${anchorAppId}`;113const sinkDeviceKeyId = `data-sink:${anchorAppId}`;114115// The declaration in effect for this document, populated by116// restoreDeclaredSink at boot and by successful declarations afterwards.117// Synchronous readers answer from here; storage holds only the envelope.118let cachedSink: DataSinkDeclaration | null = null;119120function validateDeclaration(value: unknown): DataSinkDeclaration | null {121 if (value === null || typeof value !== "object") return null;122 const sink = value as Partial<DataSinkDeclaration>;123 if (124 typeof sink.appId !== "string" || !sink.appId ||125 typeof sink.serverUrl !== "string" || !sink.serverUrl126 ) {127 return null;128 }129 const pop = (sink as { pop?: { ticketId?: unknown } }).pop;130 return {131 appId: sink.appId,132 serverUrl: sink.serverUrl,133 ...(sink.scope === "provision" || sink.scope === "sync" ? { scope: sink.scope } : {}),134 ...(typeof sink.label === "string" ? { label: sink.label } : {}),135 ...(typeof sink.node === "string" ? { node: sink.node } : {}),136 ...(pop && typeof pop.ticketId === "string" && pop.ticketId137 ? { pop: { ticketId: pop.ticketId } }138 : {}),139 };140}141142async function persistSealed(143 declaration: DataSinkDeclaration,144 keyStore: DeviceKeyStore,145): Promise<void> {146 if (typeof localStorage === "undefined") return;147 try {148 const sealed = await sealJsonEnvelope(sinkPurpose, declaration, [149 await deviceKeyProtector(keyStore, sinkDeviceKeyId),150 ]);151 localStorage.setItem(sinkKey, JSON.stringify({ v: 2, sealed }));152 } catch {153 // A private-mode storage failure must not break enrollment; the sink then154 // lasts for this document only via the in-memory declaration.155 }156}157158/**159 * How {@link restoreDeclaredSink} resolved the persisted record: `none` (no160 * record), `restored` (envelope opened), `migrated` (a pre-envelope cleartext161 * record was read and resealed), or `unopenable` (an envelope exists but no162 * available key opens it — the record is left intact and the device behaves163 * as local-only until the sink is re-declared).164 */165export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable";166167// The most recent restore's outcome, kept so status surfaces can distinguish168// "no sync location" from "a sync location exists but this device cannot open169// it". Cleared when the record is removed or successfully re-declared.170let lastRestoreOutcome: SinkRestoreOutcome = "none";171172/**173 * How the most recent {@link restoreDeclaredSink} resolved. An `unopenable`174 * answer means a declaration is persisted but no available key opens it — the175 * device runs local-only until the sink is cleared and re-enrolled, and a176 * status surface should say so rather than showing plain local-only.177 */178export function readSinkRestoreOutcome(): SinkRestoreOutcome {179 return lastRestoreOutcome;180}181182/**183 * Unseals the persisted declaration into memory. Boot awaits this before any184 * sync decision (see `boot.ts`); tests and non-boot embedders call it185 * directly. Safe to call repeatedly — it re-reads storage each time.186 */187export async function restoreDeclaredSink(188 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),189): Promise<SinkRestoreOutcome> {190 lastRestoreOutcome = await resolveRestore(keyStore);191 return lastRestoreOutcome;192}193194async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutcome> {195 cachedSink = null;196 if (typeof localStorage === "undefined") return "none";197 let raw: string | null = null;198 try {199 raw = localStorage.getItem(sinkKey);200 } catch {201 return "none";202 }203 if (!raw) return "none";204 let parsed: unknown;205 try {206 parsed = JSON.parse(raw);207 } catch {208 return "none";209 }210 const record = parsed as { v?: number; sink?: unknown; sealed?: unknown } | null;211 if (record?.v === 1) {212 // A record from before the envelope: adopt it, then reseal so the213 // cleartext bearer URL leaves storage on the first boot that can.214 const sink = validateDeclaration(record.sink);215 if (!sink) return "none";216 cachedSink = sink;217 await persistSealed(sink, keyStore);218 return "migrated";219 }220 if (record?.v === 2) {221 const sealed = parseSealedEnvelope(record.sealed);222 if (!sealed) return "unopenable";223 try {224 const payload = await openJsonEnvelope(sinkPurpose, sealed, deviceKeyResolver(keyStore));225 const sink = validateDeclaration(payload);226 if (!sink) return "unopenable";227 cachedSink = sink;228 return "restored";229 } catch (error) {230 if (error instanceof EnvelopeError) return "unopenable";231 throw error;232 }233 }234 return "none";235}236237/**238 * Reads the declared sink, or `null` when this device has not declared one.239 * Answers from the state restored at boot; callers outside the booted app240 * (tests, embedders) await {@link restoreDeclaredSink} first.241 */242export function readDeclaredSink(): DataSinkDeclaration | null {243 return cachedSink;244}245246function assertHttpServerUrl(serverUrl: string): void {247 let parsed: URL;248 try {249 parsed = new URL(serverUrl);250 } catch {251 throw new DataSinkError("invalid-server-url", `sink server URL is not a URL: ${serverUrl}`);252 }253 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {254 // The browser client derives its own WebSocket endpoint from an http(s)255 // base; a ws:// URL here would bypass that derivation and lose the256 // ticket's base path.257 throw new DataSinkError(258 "invalid-server-url",259 `sink server URL must be http(s), received ${parsed.protocol}`,260 );261 }262}263264/**265 * Declares the sync location for this device. Validates the URL, refuses an266 * app id that contradicts a compiled-in managed app, and refuses to replace a267 * different declared sink (one store, one active sink — clear the existing268 * declaration first; switching stores is deliberately not a silent overwrite).269 * Re-declaring the same store updates the label. The declaration persists as270 * a sealed envelope; in a context that cannot store it (private mode) it271 * lasts for this document only. Declaration alone changes no runtime state;272 * electing sync (see `session.ts`) is what connects.273 */274export async function declareDataSink(275 declaration: DataSinkDeclaration,276 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),277): Promise<DataSinkDeclaration> {278 if (!declaration.appId.trim()) {279 throw new DataSinkError("invalid-ticket", "sink app id must not be empty");280 }281 assertHttpServerUrl(declaration.serverUrl);282 if (configuredAppId && declaration.appId !== configuredAppId) {283 throw new DataSinkError(284 "app-id-mismatch",285 "this deployment is pinned to its own managed app; the sink's app id does not match",286 );287 }288 const current = readDeclaredSink();289 if (290 current && (current.appId !== declaration.appId || current.serverUrl !== declaration.serverUrl)291 ) {292 throw new DataSinkError(293 "sink-already-declared",294 "a different sink is already declared on this device; clear it before declaring another",295 );296 }297 await persistSealed(declaration, keyStore);298 cachedSink = declaration;299 // The record now seals under an available key; an earlier unopenable300 // restore no longer describes it.301 lastRestoreOutcome = "restored";302 return declaration;303}304305/** Removes the declared sink. Existing local data and elections are untouched. */306export function clearDeclaredSink(): void {307 cachedSink = null;308 lastRestoreOutcome = "none";309 if (typeof localStorage === "undefined") return;310 try {311 localStorage.removeItem(sinkKey);312 } catch {313 // A private-mode storage failure leaves the previous declaration in place.314 }315}316317/**318 * A parsed `lofisync1.` app-connect ticket — the credential a self-hosted319 * node issues so an app syncs against it. The format contract lives with the320 * node (lofi-node `docs/app-ticket.md`, conformance fixtures vendored at321 * `package/testdata/app-ticket-fixtures.json`); this parser mirrors its322 * validation: version 1, an http(s) URL whose path is323 * `/t/<43-char base64url secret>`, and a `scope` that is absent (meaning324 * `sync`), `sync`, or `provision` — an unknown scope rejects the ticket325 * rather than silently granting less than it claims.326 */327export type SyncTicket = {328 v: 1;329 appId: string;330 url: string;331 scope?: "sync" | "provision";332 label?: string;333 node?: string;334};335336const TICKET_PREFIX = "lofisync1.";337const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;338const TICKET_CONNECT_PATH = /^\/t\/[A-Za-z0-9_-]{43}\/c\/[A-Za-z0-9_-]{43}$/;339340/**341 * Whether a server URL carries an app-connect ticket path (`/t/<secret>`) or342 * its PoP-authenticated connect-token form (`/t/<secret>/c/<token>`) and343 * therefore fronts a lofi-node gate, which is what exposes the metadata-only344 * store-status endpoint. First-party Jazz servers and open-mode node URLs do345 * not match.346 */347export function isTicketServerUrl(serverUrl: string): boolean {348 try {349 const path = new URL(serverUrl).pathname;350 return TICKET_PATH.test(path) || TICKET_CONNECT_PATH.test(path);351 } catch {352 return false;353 }354}355356/**357 * Parses a pasted or scanned app-connect ticket. Returns `null` on any358 * malformed input — paste paths never throw. The ticket URL is a bearer359 * credential: hold it only as long as enrollment needs it.360 */361export function parseSyncTicket(text: string): SyncTicket | null {362 const trimmed = text.trim();363 if (!trimmed.startsWith(TICKET_PREFIX)) return null;364 try {365 const base64 = trimmed.slice(TICKET_PREFIX.length)366 .replaceAll("-", "+")367 .replaceAll("_", "/");368 const parsed = JSON.parse(atob(base64)) as SyncTicket;369 if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {370 return null;371 }372 if (373 parsed.scope !== undefined && parsed.scope !== "sync" && parsed.scope !== "provision"374 ) {375 return null;376 }377 const url = new URL(parsed.url);378 if (!/^https?:$/.test(url.protocol) || !TICKET_PATH.test(url.pathname)) return null;379 return parsed;380 } catch {381 return null;382 }383}384385/**386 * Converts a ticket into a sink declaration and records it. A possession387 * binding from the scope-down exchange (see {@link splitTicketForEnrollment})388 * is carried into the declaration when present. Throws {@link DataSinkError}389 * with `invalid-ticket` for malformed tickets and the declaration errors390 * otherwise. Electing sync afterwards (see `enrollSyncTicket` in391 * `session.ts`) is what actually connects.392 */393export async function declareSinkFromTicket(394 text: string,395 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),396 pop?: { ticketId: string } | null,397): Promise<DataSinkDeclaration> {398 const ticket = parseSyncTicket(text);399 if (!ticket) {400 throw new DataSinkError("invalid-ticket", "not a valid lofisync1 app-connect ticket");401 }402 return await declareDataSink({403 appId: ticket.appId,404 serverUrl: ticket.url,405 ...(ticket.scope ? { scope: ticket.scope } : {}),406 ...(ticket.label ? { label: ticket.label } : {}),407 ...(ticket.node ? { node: ticket.node } : {}),408 ...(pop ? { pop } : {}),409 }, keyStore);410}411412/** True when an error is a data-sink or ticket problem the user can fix and retry. */413export function isDataSinkError(error: unknown): error is DataSinkError {414 return error instanceof DataSinkError;415}416417/**418 * How a pasted ticket resolves for enrollment: what to declare as the sink,419 * and the provision bearer URL to hold in memory when the capability was420 * split off (see `provision.ts` for its custody).421 */422export type TicketSplit = {423 /** The ticket to declare as this device's sink. */424 sinkTicket: string;425 /** The provision bearer URL when the exchange split it off, else `null`. */426 provisionUrl: string | null;427 /** The possession binding when the node accepted a device key, else `null`. */428 pop: { ticketId: string } | null;429};430431/**432 * Splits a `provision`-scoped ticket before anything persists: the node's433 * scope-down exchange (`POST <ticket-url>/derive-sync-ticket`) mints a434 * derived sync ticket to declare as the sink, and the provision URL is435 * returned separately for in-memory custody. Any exchange failure — a node436 * predating the endpoint, a network error, an unusable response — falls back437 * to enrolling the ticket exactly as pasted. Sync-scoped and malformed438 * tickets pass through unchanged (declaration is where malformed input439 * rejects).440 */441export async function splitTicketForEnrollment(442 ticket: string,443 fetcher: typeof fetch = fetch,444 devicePublicKey?: DevicePublicKey,445): Promise<TicketSplit> {446 const parsed = parseSyncTicket(ticket);447 if (parsed?.scope !== "provision") return { sinkTicket: ticket, provisionUrl: null, pop: null };448 let derived: string | null = null;449 let derivedId: string | null = null;450 let bound = false;451 try {452 const response = await fetcher(`${parsed.url}/derive-sync-ticket`, {453 method: "POST",454 headers: { "content-type": "application/json" },455 // Nodes predating the device-key field ignore it and answer without456 // `pop`; enrollment then falls back to bearer custody.457 body: JSON.stringify(devicePublicKey ? { devicePublicKey } : {}),458 });459 if (response.ok) {460 const body = await response.json() as { ticket?: unknown; id?: unknown; pop?: unknown };461 if (typeof body.ticket === "string") derived = body.ticket;462 if (typeof body.id === "string") derivedId = body.id;463 bound = body.pop === true;464 }465 } catch {466 // Fall back to the ticket as pasted.467 }468 const derivedParsed = derived === null ? null : parseSyncTicket(derived);469 if (470 derived !== null && derivedParsed !== null && derivedParsed.appId === parsed.appId &&471 (derivedParsed.scope ?? "sync") === "sync"472 ) {473 return {474 sinkTicket: derived,475 provisionUrl: parsed.url,476 pop: bound && derivedId !== null ? { ticketId: derivedId } : null,477 };478 }479 return { sinkTicket: ticket, provisionUrl: null, pop: null };480}