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. The recovery envelope carrying the sink behind the passkey is19 * separate, follow-on work.20 *21 * @module22 */2324import {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};8485/** Raised when a sink declaration or sync ticket is rejected. */86export class DataSinkError extends Error {87 /** Stable error class name for diagnostics and error boundaries. */88 override readonly name = "DataSinkError";89 /** Stable category for user-facing enrollment flows. */90 readonly code:91 | "invalid-ticket"92 | "invalid-server-url"93 | "app-id-mismatch"94 | "sink-already-declared";95 /** Creates a sink rejection with a stable category. */96 constructor(97 code: "invalid-ticket" | "invalid-server-url" | "app-id-mismatch" | "sink-already-declared",98 message: string,99 ) {100 super(message);101 this.code = code;102 }103}104105const sinkKey = `lofi:data-sink:${anchorAppId}`;106const sinkPurpose = `lofi:data-sink:${anchorAppId}`;107const sinkDeviceKeyId = `data-sink:${anchorAppId}`;108109// The declaration in effect for this document, populated by110// restoreDeclaredSink at boot and by successful declarations afterwards.111// Synchronous readers answer from here; storage holds only the envelope.112let cachedSink: DataSinkDeclaration | null = null;113114function validateDeclaration(value: unknown): DataSinkDeclaration | null {115 if (value === null || typeof value !== "object") return null;116 const sink = value as Partial<DataSinkDeclaration>;117 if (118 typeof sink.appId !== "string" || !sink.appId ||119 typeof sink.serverUrl !== "string" || !sink.serverUrl120 ) {121 return null;122 }123 return {124 appId: sink.appId,125 serverUrl: sink.serverUrl,126 ...(sink.scope === "provision" || sink.scope === "sync" ? { scope: sink.scope } : {}),127 ...(typeof sink.label === "string" ? { label: sink.label } : {}),128 ...(typeof sink.node === "string" ? { node: sink.node } : {}),129 };130}131132async function persistSealed(133 declaration: DataSinkDeclaration,134 keyStore: DeviceKeyStore,135): Promise<void> {136 if (typeof localStorage === "undefined") return;137 try {138 const sealed = await sealJsonEnvelope(sinkPurpose, declaration, [139 await deviceKeyProtector(keyStore, sinkDeviceKeyId),140 ]);141 localStorage.setItem(sinkKey, JSON.stringify({ v: 2, sealed }));142 } catch {143 // A private-mode storage failure must not break enrollment; the sink then144 // lasts for this document only via the in-memory declaration.145 }146}147148/**149 * How {@link restoreDeclaredSink} resolved the persisted record: `none` (no150 * record), `restored` (envelope opened), `migrated` (a pre-envelope cleartext151 * record was read and resealed), or `unopenable` (an envelope exists but no152 * available key opens it — the record is left intact and the device behaves153 * as local-only until the sink is re-declared).154 */155export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable";156157/**158 * Unseals the persisted declaration into memory. Boot awaits this before any159 * sync decision (see `boot.ts`); tests and non-boot embedders call it160 * directly. Safe to call repeatedly — it re-reads storage each time.161 */162export async function restoreDeclaredSink(163 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),164): Promise<SinkRestoreOutcome> {165 cachedSink = null;166 if (typeof localStorage === "undefined") return "none";167 let raw: string | null = null;168 try {169 raw = localStorage.getItem(sinkKey);170 } catch {171 return "none";172 }173 if (!raw) return "none";174 let parsed: unknown;175 try {176 parsed = JSON.parse(raw);177 } catch {178 return "none";179 }180 const record = parsed as { v?: number; sink?: unknown; sealed?: unknown } | null;181 if (record?.v === 1) {182 // A record from before the envelope: adopt it, then reseal so the183 // cleartext bearer URL leaves storage on the first boot that can.184 const sink = validateDeclaration(record.sink);185 if (!sink) return "none";186 cachedSink = sink;187 await persistSealed(sink, keyStore);188 return "migrated";189 }190 if (record?.v === 2) {191 const sealed = parseSealedEnvelope(record.sealed);192 if (!sealed) return "unopenable";193 try {194 const payload = await openJsonEnvelope(sinkPurpose, sealed, deviceKeyResolver(keyStore));195 const sink = validateDeclaration(payload);196 if (!sink) return "unopenable";197 cachedSink = sink;198 return "restored";199 } catch (error) {200 if (error instanceof EnvelopeError) return "unopenable";201 throw error;202 }203 }204 return "none";205}206207/**208 * Reads the declared sink, or `null` when this device has not declared one.209 * Answers from the state restored at boot; callers outside the booted app210 * (tests, embedders) await {@link restoreDeclaredSink} first.211 */212export function readDeclaredSink(): DataSinkDeclaration | null {213 return cachedSink;214}215216function assertHttpServerUrl(serverUrl: string): void {217 let parsed: URL;218 try {219 parsed = new URL(serverUrl);220 } catch {221 throw new DataSinkError("invalid-server-url", `sink server URL is not a URL: ${serverUrl}`);222 }223 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {224 // The browser client derives its own WebSocket endpoint from an http(s)225 // base; a ws:// URL here would bypass that derivation and lose the226 // ticket's base path.227 throw new DataSinkError(228 "invalid-server-url",229 `sink server URL must be http(s), received ${parsed.protocol}`,230 );231 }232}233234/**235 * Declares the sync location for this device. Validates the URL, refuses an236 * app id that contradicts a compiled-in managed app, and refuses to replace a237 * different declared sink (one store, one active sink — clear the existing238 * declaration first; switching stores is deliberately not a silent overwrite).239 * Re-declaring the same store updates the label. The declaration persists as240 * a sealed envelope; in a context that cannot store it (private mode) it241 * lasts for this document only. Declaration alone changes no runtime state;242 * electing sync (see `session.ts`) is what connects.243 */244export async function declareDataSink(245 declaration: DataSinkDeclaration,246 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),247): Promise<DataSinkDeclaration> {248 if (!declaration.appId.trim()) {249 throw new DataSinkError("invalid-ticket", "sink app id must not be empty");250 }251 assertHttpServerUrl(declaration.serverUrl);252 if (configuredAppId && declaration.appId !== configuredAppId) {253 throw new DataSinkError(254 "app-id-mismatch",255 "this deployment is pinned to its own managed app; the sink's app id does not match",256 );257 }258 const current = readDeclaredSink();259 if (260 current && (current.appId !== declaration.appId || current.serverUrl !== declaration.serverUrl)261 ) {262 throw new DataSinkError(263 "sink-already-declared",264 "a different sink is already declared on this device; clear it before declaring another",265 );266 }267 await persistSealed(declaration, keyStore);268 cachedSink = declaration;269 return declaration;270}271272/** Removes the declared sink. Existing local data and elections are untouched. */273export function clearDeclaredSink(): void {274 cachedSink = null;275 if (typeof localStorage === "undefined") return;276 try {277 localStorage.removeItem(sinkKey);278 } catch {279 // A private-mode storage failure leaves the previous declaration in place.280 }281}282283/**284 * A parsed `lofisync1.` app-connect ticket — the credential a self-hosted285 * node issues so an app syncs against it. The format contract lives with the286 * node (lofi-node `docs/app-ticket.md`, conformance fixtures vendored at287 * `package/testdata/app-ticket-fixtures.json`); this parser mirrors its288 * validation: version 1, an http(s) URL whose path is289 * `/t/<43-char base64url secret>`, and a `scope` that is absent (meaning290 * `sync`), `sync`, or `provision` — an unknown scope rejects the ticket291 * rather than silently granting less than it claims.292 */293export type SyncTicket = {294 v: 1;295 appId: string;296 url: string;297 scope?: "sync" | "provision";298 label?: string;299 node?: string;300};301302const TICKET_PREFIX = "lofisync1.";303const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;304305/**306 * Parses a pasted or scanned app-connect ticket. Returns `null` on any307 * malformed input — paste paths never throw. The ticket URL is a bearer308 * credential: hold it only as long as enrollment needs it.309 */310export function parseSyncTicket(text: string): SyncTicket | null {311 const trimmed = text.trim();312 if (!trimmed.startsWith(TICKET_PREFIX)) return null;313 try {314 const base64 = trimmed.slice(TICKET_PREFIX.length)315 .replaceAll("-", "+")316 .replaceAll("_", "/");317 const parsed = JSON.parse(atob(base64)) as SyncTicket;318 if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {319 return null;320 }321 if (322 parsed.scope !== undefined && parsed.scope !== "sync" && parsed.scope !== "provision"323 ) {324 return null;325 }326 const url = new URL(parsed.url);327 if (!/^https?:$/.test(url.protocol) || !TICKET_PATH.test(url.pathname)) return null;328 return parsed;329 } catch {330 return null;331 }332}333334/**335 * Converts a ticket into a sink declaration and records it. Throws336 * {@link DataSinkError} with `invalid-ticket` for malformed tickets and the337 * declaration errors otherwise. Electing sync afterwards (see338 * `enrollSyncTicket` in `session.ts`) is what actually connects.339 */340export async function declareSinkFromTicket(341 text: string,342 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),343): Promise<DataSinkDeclaration> {344 const ticket = parseSyncTicket(text);345 if (!ticket) {346 throw new DataSinkError("invalid-ticket", "not a valid lofisync1 app-connect ticket");347 }348 return await declareDataSink({349 appId: ticket.appId,350 serverUrl: ticket.url,351 ...(ticket.scope ? { scope: ticket.scope } : {}),352 ...(ticket.label ? { label: ticket.label } : {}),353 ...(ticket.node ? { node: ticket.node } : {}),354 }, keyStore);355}356357/** True when an error is a data-sink or ticket problem the user can fix and retry. */358export function isDataSinkError(error: unknown): error is DataSinkError {359 return error instanceof DataSinkError;360}361362/**363 * How a pasted ticket resolves for enrollment: what to declare as the sink,364 * and the provision bearer URL to hold in memory when the capability was365 * split off (see `provision.ts` for its custody).366 */367export type TicketSplit = {368 /** The ticket to declare as this device's sink. */369 sinkTicket: string;370 /** The provision bearer URL when the exchange split it off, else `null`. */371 provisionUrl: string | null;372};373374/**375 * Splits a `provision`-scoped ticket before anything persists: the node's376 * scope-down exchange (`POST <ticket-url>/derive-sync-ticket`) mints a377 * derived sync ticket to declare as the sink, and the provision URL is378 * returned separately for in-memory custody. Any exchange failure — a node379 * predating the endpoint, a network error, an unusable response — falls back380 * to enrolling the ticket exactly as pasted. Sync-scoped and malformed381 * tickets pass through unchanged (declaration is where malformed input382 * rejects).383 */384export async function splitTicketForEnrollment(385 ticket: string,386 fetcher: typeof fetch = fetch,387): Promise<TicketSplit> {388 const parsed = parseSyncTicket(ticket);389 if (parsed?.scope !== "provision") return { sinkTicket: ticket, provisionUrl: null };390 let derived: string | null = null;391 try {392 const response = await fetcher(`${parsed.url}/derive-sync-ticket`, {393 method: "POST",394 headers: { "content-type": "application/json" },395 body: JSON.stringify({}),396 });397 if (response.ok) {398 const body = await response.json() as { ticket?: unknown };399 if (typeof body.ticket === "string") derived = body.ticket;400 }401 } catch {402 // Fall back to the ticket as pasted.403 }404 const derivedParsed = derived === null ? null : parseSyncTicket(derived);405 if (406 derived !== null && derivedParsed !== null && derivedParsed.appId === parsed.appId &&407 (derivedParsed.scope ?? "sync") === "sync"408 ) {409 return { sinkTicket: derived, provisionUrl: parsed.url };410 }411 return { sinkTicket: ticket, provisionUrl: null };412}