clone

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

main
lofi / package / runtime / data-sink.ts
data-sink.ts 19.1K
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// Astro islands can bundle this module more than once in the same document.116// Keep the restored declaration on globalThis so every island observes the117// boot script's restore and a declaration made by any sibling island. Storage118// still holds only the sealed envelope.119type DataSinkSlot = {120 cachedSink: DataSinkDeclaration | null;121 lastRestoreOutcome: SinkRestoreOutcome;122 restorePromise: Promise<SinkRestoreOutcome> | null;123};124125const dataSinkSlotName = "__LOFI_ALPHA53_DATA_SINK__";126const browserGlobal = globalThis as typeof globalThis & { [dataSinkSlotName]?: DataSinkSlot };127128function dataSinkSlot(): DataSinkSlot {129 browserGlobal[dataSinkSlotName] ??= {130 cachedSink: null,131 lastRestoreOutcome: "none",132 restorePromise: null,133 };134 return browserGlobal[dataSinkSlotName];135}136137function validateDeclaration(value: unknown): DataSinkDeclaration | null {138 if (value === null || typeof value !== "object") return null;139 const sink = value as Partial<DataSinkDeclaration>;140 if (141 typeof sink.appId !== "string" || !sink.appId ||142 typeof sink.serverUrl !== "string" || !sink.serverUrl143 ) {144 return null;145 }146 const pop = (sink as { pop?: { ticketId?: unknown } }).pop;147 return {148 appId: sink.appId,149 serverUrl: sink.serverUrl,150 ...(sink.scope === "provision" || sink.scope === "sync" ? { scope: sink.scope } : {}),151 ...(typeof sink.label === "string" ? { label: sink.label } : {}),152 ...(typeof sink.node === "string" ? { node: sink.node } : {}),153 ...(pop && typeof pop.ticketId === "string" && pop.ticketId154 ? { pop: { ticketId: pop.ticketId } }155 : {}),156 };157}158159async function persistSealed(160 declaration: DataSinkDeclaration,161 keyStore: DeviceKeyStore,162): Promise<void> {163 if (typeof localStorage === "undefined") return;164 try {165 const sealed = await sealJsonEnvelope(sinkPurpose, declaration, [166 await deviceKeyProtector(keyStore, sinkDeviceKeyId),167 ]);168 localStorage.setItem(sinkKey, JSON.stringify({ v: 2, sealed }));169 } catch {170 // A private-mode storage failure must not break enrollment; the sink then171 // lasts for this document only via the in-memory declaration.172 }173}174175/**176 * How {@link restoreDeclaredSink} resolved the persisted record: `none` (no177 * record), `restored` (envelope opened), `migrated` (a pre-envelope cleartext178 * record was read and resealed), or `unopenable` (an envelope exists but no179 * available key opens it — the record is left intact and the device behaves180 * as local-only until the sink is re-declared).181 */182export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable";183184// The most recent restore's outcome, kept so status surfaces can distinguish185// "no sync location" from "a sync location exists but this device cannot open186// it". Cleared when the record is removed or successfully re-declared.187/**188 * How the most recent {@link restoreDeclaredSink} resolved. An `unopenable`189 * answer means a declaration is persisted but no available key opens it — the190 * device runs local-only until the sink is cleared and re-enrolled, and a191 * status surface should say so rather than showing plain local-only.192 */193export function readSinkRestoreOutcome(): SinkRestoreOutcome {194 return dataSinkSlot().lastRestoreOutcome;195}196197/**198 * Unseals the persisted declaration into memory. Boot awaits this before any199 * sync decision (see `boot.ts`); tests and non-boot embedders call it200 * directly. Safe to call repeatedly — it re-reads storage each time.201 */202export async function restoreDeclaredSink(203 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),204): Promise<SinkRestoreOutcome> {205 const state = dataSinkSlot();206 state.lastRestoreOutcome = await resolveRestore(keyStore);207 return state.lastRestoreOutcome;208}209210/**211 * Restores the document's sealed sink exactly once before the first runtime212 * opens. App islands and the boot script can start concurrently; sharing this213 * promise prevents an eager island from creating a local-only client while214 * boot is still unsealing a configured sink.215 */216export function ensureDeclaredSinkRestored(217 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),218): Promise<SinkRestoreOutcome> {219 const state = dataSinkSlot();220 state.restorePromise ??= restoreDeclaredSink(keyStore).catch(() => {221 state.cachedSink = null;222 state.lastRestoreOutcome = "none";223 return "none";224 });225 return state.restorePromise;226}227228async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutcome> {229 const state = dataSinkSlot();230 state.cachedSink = null;231 if (typeof localStorage === "undefined") return "none";232 let raw: string | null = null;233 try {234 raw = localStorage.getItem(sinkKey);235 } catch {236 return "none";237 }238 if (!raw) return "none";239 let parsed: unknown;240 try {241 parsed = JSON.parse(raw);242 } catch {243 return "none";244 }245 const record = parsed as { v?: number; sink?: unknown; sealed?: unknown } | null;246 if (record?.v === 1) {247 // A record from before the envelope: adopt it, then reseal so the248 // cleartext bearer URL leaves storage on the first boot that can.249 const sink = validateDeclaration(record.sink);250 if (!sink) return "none";251 state.cachedSink = sink;252 await persistSealed(sink, keyStore);253 return "migrated";254 }255 if (record?.v === 2) {256 const sealed = parseSealedEnvelope(record.sealed);257 if (!sealed) return "unopenable";258 try {259 const payload = await openJsonEnvelope(sinkPurpose, sealed, deviceKeyResolver(keyStore));260 const sink = validateDeclaration(payload);261 if (!sink) return "unopenable";262 state.cachedSink = sink;263 return "restored";264 } catch (error) {265 if (error instanceof EnvelopeError) return "unopenable";266 throw error;267 }268 }269 return "none";270}271272/**273 * Reads the declared sink, or `null` when this device has not declared one.274 * Answers from the state restored at boot; callers outside the booted app275 * (tests, embedders) await {@link restoreDeclaredSink} first.276 */277export function readDeclaredSink(): DataSinkDeclaration | null {278 return dataSinkSlot().cachedSink;279}280281function assertHttpServerUrl(serverUrl: string): void {282 let parsed: URL;283 try {284 parsed = new URL(serverUrl);285 } catch {286 throw new DataSinkError("invalid-server-url", `sink server URL is not a URL: ${serverUrl}`);287 }288 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {289 // The browser client derives its own WebSocket endpoint from an http(s)290 // base; a ws:// URL here would bypass that derivation and lose the291 // ticket's base path.292 throw new DataSinkError(293 "invalid-server-url",294 `sink server URL must be http(s), received ${parsed.protocol}`,295 );296 }297}298299/**300 * Declares the sync location for this device. Validates the URL, refuses an301 * app id that contradicts a compiled-in managed app, and refuses to replace a302 * different declared sink (one store, one active sink — clear the existing303 * declaration first; switching stores is deliberately not a silent overwrite).304 * Re-declaring the same store updates the label. The declaration persists as305 * a sealed envelope; in a context that cannot store it (private mode) it306 * lasts for this document only. Declaration alone changes no runtime state;307 * electing sync (see `session.ts`) is what connects.308 */309export async function declareDataSink(310 declaration: DataSinkDeclaration,311 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),312): Promise<DataSinkDeclaration> {313 if (!declaration.appId.trim()) {314 throw new DataSinkError("invalid-ticket", "sink app id must not be empty");315 }316 assertHttpServerUrl(declaration.serverUrl);317 if (configuredAppId && declaration.appId !== configuredAppId) {318 throw new DataSinkError(319 "app-id-mismatch",320 "this deployment is pinned to its own managed app; the sink's app id does not match",321 );322 }323 const current = readDeclaredSink();324 if (325 current && (current.appId !== declaration.appId || current.serverUrl !== declaration.serverUrl)326 ) {327 throw new DataSinkError(328 "sink-already-declared",329 "a different sink is already declared on this device; clear it before declaring another",330 );331 }332 await persistSealed(declaration, keyStore);333 const state = dataSinkSlot();334 state.cachedSink = declaration;335 // The record now seals under an available key; an earlier unopenable336 // restore no longer describes it.337 state.lastRestoreOutcome = "restored";338 return declaration;339}340341/** Removes the declared sink. Existing local data and elections are untouched. */342export function clearDeclaredSink(): void {343 const state = dataSinkSlot();344 state.cachedSink = null;345 state.lastRestoreOutcome = "none";346 if (typeof localStorage === "undefined") return;347 try {348 localStorage.removeItem(sinkKey);349 } catch {350 // A private-mode storage failure leaves the previous declaration in place.351 }352}353354/**355 * A parsed `lofisync1.` app-connect ticket — the credential a self-hosted356 * node issues so an app syncs against it. The format contract lives with the357 * node (lofi-node `docs/app-ticket.md`, conformance fixtures vendored at358 * `package/testdata/app-ticket-fixtures.json`); this parser mirrors its359 * validation: version 1, an http(s) URL whose path is360 * `/t/<43-char base64url secret>`, and a `scope` that is absent (meaning361 * `sync`), `sync`, or `provision` — an unknown scope rejects the ticket362 * rather than silently granting less than it claims.363 */364export type SyncTicket = {365 v: 1;366 appId: string;367 url: string;368 scope?: "sync" | "provision";369 label?: string;370 node?: string;371};372373const TICKET_PREFIX = "lofisync1.";374const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;375const TICKET_CONNECT_PATH = /^\/t\/[A-Za-z0-9_-]{43}\/c\/[A-Za-z0-9_-]{43}$/;376377/**378 * Whether a server URL carries an app-connect ticket path (`/t/<secret>`) or379 * its PoP-authenticated connect-token form (`/t/<secret>/c/<token>`) and380 * therefore fronts a lofi-node gate, which is what exposes the metadata-only381 * store-status endpoint. First-party Jazz servers and open-mode node URLs do382 * not match.383 */384export function isTicketServerUrl(serverUrl: string): boolean {385 try {386 const path = new URL(serverUrl).pathname;387 return TICKET_PATH.test(path) || TICKET_CONNECT_PATH.test(path);388 } catch {389 return false;390 }391}392393/**394 * Parses a pasted or scanned app-connect ticket. Returns `null` on any395 * malformed input — paste paths never throw. The ticket URL is a bearer396 * credential: hold it only as long as enrollment needs it.397 */398export function parseSyncTicket(text: string): SyncTicket | null {399 const trimmed = text.trim();400 if (!trimmed.startsWith(TICKET_PREFIX)) return null;401 try {402 const base64 = trimmed.slice(TICKET_PREFIX.length)403 .replaceAll("-", "+")404 .replaceAll("_", "/");405 const parsed = JSON.parse(atob(base64)) as SyncTicket;406 if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {407 return null;408 }409 if (410 parsed.scope !== undefined && parsed.scope !== "sync" && parsed.scope !== "provision"411 ) {412 return null;413 }414 const url = new URL(parsed.url);415 if (!/^https?:$/.test(url.protocol) || !TICKET_PATH.test(url.pathname)) return null;416 return parsed;417 } catch {418 return null;419 }420}421422/**423 * Converts a ticket into a sink declaration and records it. A possession424 * binding from the scope-down exchange (see {@link splitTicketForEnrollment})425 * is carried into the declaration when present. Throws {@link DataSinkError}426 * with `invalid-ticket` for malformed tickets and the declaration errors427 * otherwise. Electing sync afterwards (see `enrollSyncTicket` in428 * `session.ts`) is what actually connects.429 */430export async function declareSinkFromTicket(431 text: string,432 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),433 pop?: { ticketId: string } | null,434): Promise<DataSinkDeclaration> {435 const ticket = parseSyncTicket(text);436 if (!ticket) {437 throw new DataSinkError("invalid-ticket", "not a valid lofisync1 app-connect ticket");438 }439 return await declareDataSink({440 appId: ticket.appId,441 serverUrl: ticket.url,442 ...(ticket.scope ? { scope: ticket.scope } : {}),443 ...(ticket.label ? { label: ticket.label } : {}),444 ...(ticket.node ? { node: ticket.node } : {}),445 ...(pop ? { pop } : {}),446 }, keyStore);447}448449/** True when an error is a data-sink or ticket problem the user can fix and retry. */450export function isDataSinkError(error: unknown): error is DataSinkError {451 return error instanceof DataSinkError;452}453454/**455 * How a pasted ticket resolves for enrollment: what to declare as the sink,456 * and the provision bearer URL to hold in memory when the capability was457 * split off (see `provision.ts` for its custody).458 */459export type TicketSplit = {460 /** The ticket to declare as this device's sink. */461 sinkTicket: string;462 /** The provision bearer URL when the exchange split it off, else `null`. */463 provisionUrl: string | null;464 /** The possession binding when the node accepted a device key, else `null`. */465 pop: { ticketId: string } | null;466};467468/**469 * Splits a `provision`-scoped ticket before anything persists: the node's470 * scope-down exchange (`POST <ticket-url>/derive-sync-ticket`) mints a471 * derived sync ticket to declare as the sink, and the provision URL is472 * returned separately for in-memory custody. Any exchange failure — a node473 * predating the endpoint, a network error, an unusable response — falls back474 * to enrolling the ticket exactly as pasted. Sync-scoped and malformed475 * tickets pass through unchanged (declaration is where malformed input476 * rejects).477 */478export async function splitTicketForEnrollment(479 ticket: string,480 fetcher: typeof fetch = fetch,481 devicePublicKey?: DevicePublicKey,482): Promise<TicketSplit> {483 const parsed = parseSyncTicket(ticket);484 if (parsed?.scope !== "provision") return { sinkTicket: ticket, provisionUrl: null, pop: null };485 let derived: string | null = null;486 let derivedId: string | null = null;487 let bound = false;488 try {489 const response = await fetcher(`${parsed.url}/derive-sync-ticket`, {490 method: "POST",491 headers: { "content-type": "application/json" },492 // Nodes predating the device-key field ignore it and answer without493 // `pop`; enrollment then falls back to bearer custody.494 body: JSON.stringify(devicePublicKey ? { devicePublicKey } : {}),495 });496 if (response.ok) {497 const body = await response.json() as { ticket?: unknown; id?: unknown; pop?: unknown };498 if (typeof body.ticket === "string") derived = body.ticket;499 if (typeof body.id === "string") derivedId = body.id;500 bound = body.pop === true;501 }502 } catch {503 // Fall back to the ticket as pasted.504 }505 const derivedParsed = derived === null ? null : parseSyncTicket(derived);506 if (507 derived !== null && derivedParsed !== null && derivedParsed.appId === parsed.appId &&508 (derivedParsed.scope ?? "sync") === "sync"509 ) {510 return {511 sinkTicket: derived,512 provisionUrl: parsed.url,513 pop: bound && derivedId !== null ? { ticketId: derivedId } : null,514 };515 }516 return { sinkTicket: ticket, provisionUrl: null, pop: null };517}