feat(runtime): runtime-declared data sink with ticket enrollment (#107 part 2)
- commit
- e22ba7b903d503f664ff5a2f1c2a61cded3db106
- parent
- cc3e4cbd356dbe3ea5b1e33727478481f08cc24a
- parent
- a4783249cf96ec7124759fb917d1c39e59ad2828
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | docs/ | 54 | ++++++- |
| M | package/ | 13 | +- |
| M | package/ | 1 | + |
| M | package/ | 14 | +- |
| M | package/ | 82 | +++++++-- |
| A | package/ | 270 | +++++++++++++++++++++++++++++ |
| A | package/ | 276 | ++++++++++++++++++++++++++++++ |
| M | package/ | 9 | +- |
| M | package/ | 12 | + |
| M | package/ | 10 | +- |
| M | package/ | 58 | ++++++- |
| M | package/ | 4 | +- |
| A | package/ | 45 | +++++ |
@@ -1,8 +1,12 @@
11 # Sync and recovery22 3-Sync is optional infrastructure layered onto an account that already works locally. Configuring a4-Jazz app makes sync available; it does not automatically upload every user's local data. Each user5-elects to back up and sync from the generated `AccountGate` UI.3+Sync is optional infrastructure layered onto an account that already works locally. A sync location4+makes sync available; it does not automatically upload every user's local data. Each user elects to5+back up and sync from the generated `AccountGate` UI.6+7+There are two ways a device gets a sync location: the deployment can compile one in (a managed Jazz8+app — the _default sink_), or the user can declare one at runtime by enrolling a ticket from a9+self-hosted node. Both feed the same election flow.610 711 ## Provision a managed Jazz app812 @@ -33,6 +37,50 @@ the build projects only the complete public pair and scans the client output for
3337 Run `deno task doctor` after provisioning. A partial public pair is invalid: set both public names3438 or remove both to return to local-only mode.3539 40+## Bring your own sync location41+42+The sync destination can be **user-selected data instead of developer configuration**. A self-hosted43+node (such as [lofi-node](https://github.com/FelineStateMachine/lofi-node)) issues an app-connect44+ticket — a `lofisync1.…` string carrying the store's app id and a gate URL — and the user pastes it45+into the app:46+47+```ts48+import { enrollSyncTicket, isDataSinkError } from "@nzip/lofi";49+50+try {51+ const session = await enrollSyncTicket(pastedTicket);52+ // session.sink → { source: "declared", host: "192.168.1.10:4802", label: "phone" }53+} catch (error) {54+ if (isDataSinkError(error)) showEnrollmentProblem(error.code, error.message);55+ else throw error;56+}57+```58+59+Enrollment declares the ticket as this device's _data sink_ and elects sync in one step: the local60+data pushes up under the same account identity, exactly as electing against a compiled managed app61+does. `parseSyncTicket` validates a pasted string without enrolling it (returns `null` on any62+malformed input), and `readDeclaredSink` / `clearDeclaredSink` manage the declaration directly. A63+non-ticket location can be declared with `declareDataSink({ appId, serverUrl })`.64+65+Semantics to rely on:66+67+- **First boot is unchanged.** Without a compiled managed app or a declared sink the device is68+ local-only, and nothing leaves it.69+- **A declared sink overrides the compiled default** for this device. A deployment pinned to its own70+ managed app refuses tickets for a different app id, so a hosted product cannot be silently71+ re-pointed.72+- **One store, one active sink.** Declaring a different sink over an existing one is refused; clear73+ the declaration first. Local data and elections survive clearing.74+- **The ticket URL is a bearer credential.** It is used verbatim as the sync server (its secret path75+ is what authorizes transport), stored in a device-local record, and never exposed through the76+ session snapshot — `session.sink` carries only the source, host, and label.77+- **Tickets carry a scope.** A plain (or `scope: "sync"`) ticket is transport only; a78+ `scope: "provision"` ticket additionally administers the store through the node's gate — the basis79+ for opt-in store provisioning. A ticket with an unrecognized scope is rejected outright rather80+ than silently granted less than it claims.81+- If the node revokes the ticket, requests fail with 401 and live sockets close; treat the stored82+ sink as dead and surface re-enrollment rather than retrying silently.83+3684 ## The user's account journey3785 3886 ```mermaid@@ -1,4 +1,4 @@
1-import { appId } from "../runtime/config.ts";1+import { activeAppId } from "../runtime/config.ts";22 import { getRuntime } from "../runtime/runtime.ts";33 import { AccessError } from "./errors.ts";44 @@ -7,12 +7,17 @@ export type SharingIdentity = string & { readonly __lofiSharingIdentity: true };
77 88 const prefix = "lofi1";99 10-/** Encodes a raw Jazz principal as a versioned identity scoped to the current app. */10+/**11+ * Encodes a raw Jazz principal as a versioned identity scoped to the active12+ * sync location's app. Identities are exchanged between users of the same13+ * store, so they carry the store's app id — the declared sink's when one is14+ * enrolled, the compiled managed app's otherwise.15+ */1116 export function encodeSharingIdentity(userId: string): SharingIdentity {1217 if (!userId || /[\s:]/.test(userId)) {1318 throw new AccessError("invalid-identity", "Jazz returned an invalid sharing identity.");1419 }15- return `${prefix}:${appId}:${userId}` as SharingIdentity;20+ return `${prefix}:${activeAppId()}:${userId}` as SharingIdentity;1621 }1722 1823 /**@@ -24,7 +29,7 @@ export function encodeSharingIdentity(userId: string): SharingIdentity {
2429 export function decodeSharingIdentity(identity: string): string {2530 const [version, identityAppId, userId, extra] = identity.trim().split(":");2631 if (27- version !== prefix || identityAppId !== appId || !userId || /\s/.test(userId) ||32+ version !== prefix || identityAppId !== activeAppId() || !userId || /\s/.test(userId) ||2833 extra !== undefined2934 ) {3035 throw new AccessError(@@ -27,6 +27,7 @@ export const runtimeFiles = [
2727 "auth.ts",2828 "boot.ts",2929 "config.ts",30+ "data-sink.ts",3031 "device-capabilities.ts",3132 "diagnostics.ts",3233 "durability.ts",@@ -1,7 +1,6 @@
11 import type { JSX, VNode } from "preact";22 import { useEffect, useState } from "preact/hooks";33 // Package-owned optional diagnostics UI.4-import { serverUrl } from "../runtime/config.ts";54 import { useDeviceCapabilities } from "./use-device-capabilities.ts";65 import { settleUiMutation } from "../runtime/ui-mutation.ts";76 import { getPwaState, type PwaState, subscribePwaState } from "../runtime/pwa.ts";@@ -68,7 +67,12 @@ export default function DeviceStatus(): VNode {
6867 6968 if (!report) return <p class="device-status">Checking device capabilities…</p>;7069 71- const synced = Boolean(serverUrl);70+ const synced = Boolean(session?.syncAvailable);71+ const sinkState = session?.sink72+ ? session.sink.source === "declared"73+ ? `declared — ${session.sink.label ?? session.sink.host}`74+ : "configured (build default)"75+ : "not configured";7276 const syncState = session?.syncing7377 ? "syncing to your account"7478 : session?.syncAvailable@@ -126,12 +130,12 @@ export default function DeviceStatus(): VNode {
126130 </p>127131 <dl>128132 <Row label="Sync" value={syncState} />129- <Row label="Managed server" value={synced ? "configured" : "not configured"} />133+ <Row label="Sync location" value={sinkState} />130134 </dl>131135 {!synced && (132136 <p>133- Set <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in{" "}134- <code>.env</code>, then rebuild, to replicate writes to your account.137+ Set <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in <code>.env</code>{" "}138+ and rebuild, or enroll a sync ticket at runtime, to replicate writes to your account.135139 </p>136140 )}137141 </div>@@ -2,20 +2,59 @@
22 33 import type { DbConfig, IncompatibleBrowserBrokerConfigurationHandler } from "jazz-tools";44 import { getLofiApp } from "./app.ts";5+import { anchorAppId, configuredServerUrl, readDeclaredSink } from "./data-sink.ts";56 6-declare const __LOFI_JAZZ_APP_ID__: string;7-declare const __LOFI_JAZZ_SERVER_URL__: string;7+/**8+ * The device-local anchor app id: storage keys, the account secret store, and9+ * the local database namespace key off this value, so state written before a10+ * sink is declared survives the declaration. See `data-sink.ts` for how it is11+ * derived.12+ */13+export const appId = anchorAppId;14+15+/** The sync location currently in effect and where it came from. */16+export type ActiveSink = {17+ /** `declared` is a runtime-declared sink; `default` is the compiled managed app. */18+ source: "declared" | "default";19+ appId: string;20+ serverUrl: string;21+ label?: string;22+};23+24+/**25+ * Resolves the sync location in effect: a runtime-declared sink wins, the26+ * compiled managed app (when the deployment has one) is the default, and27+ * `null` means this device is local-only until the user declares one.28+ */29+export function activeSink(): ActiveSink | null {30+ const declared = readDeclaredSink();31+ if (declared) {32+ return {33+ source: "declared",34+ appId: declared.appId,35+ serverUrl: declared.serverUrl,36+ ...(declared.label ? { label: declared.label } : {}),37+ };38+ }39+ return configuredServerUrl40+ ? { source: "default", appId: anchorAppId, serverUrl: configuredServerUrl }41+ : null;42+}43+44+/** The server URL of the sync location in effect, if any. */45+export function activeServerUrl(): string | undefined {46+ return activeSink()?.serverUrl;47+}848 9-const LOCAL_APP_ID = "00000000-0000-0000-0000-00000000f153";10-const configuredAppId = typeof __LOFI_JAZZ_APP_ID__ === "string" ? __LOFI_JAZZ_APP_ID__ : "";11-const configuredServerUrl = typeof __LOFI_JAZZ_SERVER_URL__ === "string"12- ? __LOFI_JAZZ_SERVER_URL__13- : "";14-export const appId = configuredAppId || LOCAL_APP_ID;15-export const serverUrl = configuredServerUrl || undefined;49+/** The Jazz app id of the sync location in effect, falling back to the anchor. */50+export function activeAppId(): string {51+ return activeSink()?.appId ?? anchorAppId;52+}1653 17-/** Whether this deployment has a managed Jazz app, so sync/backup is possible. */18-export const syncAvailable = Boolean(serverUrl);54+/** Whether a sync location exists (declared or compiled), so sync/backup is possible. */55+export function syncAvailable(): boolean {56+ return activeSink() !== null;57+}1958 2059 const syncElectionKey = `lofi:sync-elected:${appId}`;2160 @@ -25,7 +64,7 @@ const syncElectionKey = `lofi:sync-elected:${appId}`;
2564 * opts in (see `session.ts`), so nothing leaves the device by default.2665 */2766 export function syncElected(): boolean {28- if (!syncAvailable || typeof localStorage === "undefined") return false;67+ if (!syncAvailable() || typeof localStorage === "undefined") return false;2968 try {3069 return localStorage.getItem(syncElectionKey) === "1";3170 } catch {@@ -33,9 +72,9 @@ export function syncElected(): boolean {
3372 }3473 }3574 36-/** Records (or clears) the sync election. Ignored when no Jazz app is configured. */75+/** Records (or clears) the sync election. Ignored when no sync location exists. */3776 export function setSyncElected(elected: boolean): void {38- if (!syncAvailable || typeof localStorage === "undefined") return;77+ if (!syncAvailable() || typeof localStorage === "undefined") return;3978 try {4079 if (elected) localStorage.setItem(syncElectionKey, "1");4180 else localStorage.removeItem(syncElectionKey);@@ -44,9 +83,9 @@ export function setSyncElected(elected: boolean): void {
4483 }4584 }4685 47-/** Whether writes actually replicate right now: a Jazz app is configured *and* elected. */86+/** Whether writes actually replicate right now: a sync location exists *and* is elected. */4887 export function syncing(): boolean {49- return syncAvailable && syncElected();88+ return syncAvailable() && syncElected();5089 }5190 5291 export function databaseConfig(@@ -58,15 +97,20 @@ export function databaseConfig(
5897 () => {},5998 ): DbConfig {6099 const app = getLofiApp();100+ const sink = activeSink();101+ // The local tier stays on the anchor so first-boot data is always found; the102+ // managed tier belongs to the active sink's store, so a declared sink gets103+ // its own database namespace and connection identity.104+ const connectionAppId = mode === "managed" ? (sink?.appId ?? appId) : appId;61105 return {62- appId,106+ appId: connectionAppId,63107 // The same local-first secret opens the same account whether or not a server64108 // is attached, so electing to sync later preserves all existing data.65- ...(connect && serverUrl ? { serverUrl } : {}),109+ ...(connect && sink ? { serverUrl: sink.serverUrl } : {}),66110 secret,67111 driver: {68112 type: "persistent",69- dbName: `${app.databaseName}-${appId}-${accountNamespace}-${mode}`,113+ dbName: `${app.databaseName}-${connectionAppId}-${accountNamespace}-${mode}`,70114 },71115 onIncompatibleBrowserBrokerConfiguration,72116 };@@ -0,0 +1,270 @@
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+ * One durable record holds the declaration, written atomically and keyed by12+ * the device-local anchor app id, and this module is its only reader and13+ * writer. The record is device-local state, like the namespace election; the14+ * recovery envelope carrying the sink behind the passkey is separate,15+ * follow-on work.16+ *17+ * @module18+ */19+20+declare const __LOFI_JAZZ_APP_ID__: string;21+declare const __LOFI_JAZZ_SERVER_URL__: string;22+23+/**24+ * The app id that anchors device-local state before (or without) a managed25+ * app: storage keys, the account secret store, and the local database26+ * namespace all key off the anchor so they survive a sink being declared27+ * later.28+ */29+export const LOCAL_APP_ID = "00000000-0000-0000-0000-00000000f153";30+31+/** The build-time Jazz app id, or the empty string when none was configured. */32+export const configuredAppId: string = typeof __LOFI_JAZZ_APP_ID__ === "string"33+ ? __LOFI_JAZZ_APP_ID__34+ : "";35+36+/** The build-time Jazz server URL, or the empty string when none was configured. */37+export const configuredServerUrl: string = typeof __LOFI_JAZZ_SERVER_URL__ === "string"38+ ? __LOFI_JAZZ_SERVER_URL__39+ : "";40+41+/**42+ * The device-local anchor app id: the build-time app id when one exists,43+ * otherwise {@link LOCAL_APP_ID}. Every localStorage key and the secret store44+ * key off this value, so a runtime-declared sink never orphans state written45+ * before the declaration.46+ */47+export const anchorAppId: string = configuredAppId || LOCAL_APP_ID;48+49+/** A user-declared sync location: where this account's data syncs. */50+export type DataSinkDeclaration = {51+ /** The Jazz app id of the target store. */52+ appId: string;53+ /** The http(s) sync server URL, used verbatim (a ticket URL keeps its secret path). */54+ serverUrl: string;55+ /**56+ * The enrolled ticket's capability: `sync` is transport only; `provision`57+ * additionally administers the store through the node's gate (the admin58+ * secret never transits the client). Absent means `sync`.59+ */60+ scope?: "sync" | "provision";61+ /** Optional user-facing label, e.g. the ticket's device label. */62+ label?: string;63+ /**64+ * Opaque forward-compat carrier: the node's iroh endpoint ticket from a65+ * `lofisync1.` app ticket. Unused by the browser runtime today.66+ */67+ node?: string;68+};69+70+/** Raised when a sink declaration or sync ticket is rejected. */71+export class DataSinkError extends Error {72+ /** Stable error class name for diagnostics and error boundaries. */73+ override readonly name = "DataSinkError";74+ /** Stable category for user-facing enrollment flows. */75+ readonly code:76+ | "invalid-ticket"77+ | "invalid-server-url"78+ | "app-id-mismatch"79+ | "sink-already-declared";80+ /** Creates a sink rejection with a stable category. */81+ constructor(82+ code: "invalid-ticket" | "invalid-server-url" | "app-id-mismatch" | "sink-already-declared",83+ message: string,84+ ) {85+ super(message);86+ this.code = code;87+ }88+}89+90+type SinkRecord = { v: 1; sink: DataSinkDeclaration };91+92+const sinkKey = `lofi:data-sink:${anchorAppId}`;93+94+function parseRecord(raw: string | null): DataSinkDeclaration | null {95+ if (!raw) return null;96+ try {97+ const record = JSON.parse(raw) as Partial<SinkRecord> | null;98+ const sink = record?.v === 1 ? record.sink : undefined;99+ if (100+ sink && typeof sink.appId === "string" && sink.appId &&101+ typeof sink.serverUrl === "string" && sink.serverUrl102+ ) {103+ return {104+ appId: sink.appId,105+ serverUrl: sink.serverUrl,106+ ...(sink.scope === "provision" || sink.scope === "sync" ? { scope: sink.scope } : {}),107+ ...(typeof sink.label === "string" ? { label: sink.label } : {}),108+ ...(typeof sink.node === "string" ? { node: sink.node } : {}),109+ };110+ }111+ } catch {112+ // An unreadable record is treated as absent below.113+ }114+ return null;115+}116+117+/** Reads the declared sink, or `null` when this device has not declared one. */118+export function readDeclaredSink(): DataSinkDeclaration | null {119+ if (typeof localStorage === "undefined") return null;120+ try {121+ return parseRecord(localStorage.getItem(sinkKey));122+ } catch {123+ return null;124+ }125+}126+127+function assertHttpServerUrl(serverUrl: string): void {128+ let parsed: URL;129+ try {130+ parsed = new URL(serverUrl);131+ } catch {132+ throw new DataSinkError("invalid-server-url", `sink server URL is not a URL: ${serverUrl}`);133+ }134+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {135+ // The browser client derives its own WebSocket endpoint from an http(s)136+ // base; a ws:// URL here would bypass that derivation and lose the137+ // ticket's base path.138+ throw new DataSinkError(139+ "invalid-server-url",140+ `sink server URL must be http(s), received ${parsed.protocol}`,141+ );142+ }143+}144+145+/**146+ * Declares the sync location for this device. Validates the URL, refuses an147+ * app id that contradicts a compiled-in managed app, and refuses to replace a148+ * different declared sink (one store, one active sink — clear the existing149+ * declaration first; switching stores is deliberately not a silent overwrite).150+ * Re-declaring the same store updates the label. Declaration alone changes no151+ * runtime state; electing sync (see `session.ts`) is what connects.152+ */153+export function declareDataSink(declaration: DataSinkDeclaration): DataSinkDeclaration {154+ if (!declaration.appId.trim()) {155+ throw new DataSinkError("invalid-ticket", "sink app id must not be empty");156+ }157+ assertHttpServerUrl(declaration.serverUrl);158+ if (configuredAppId && declaration.appId !== configuredAppId) {159+ throw new DataSinkError(160+ "app-id-mismatch",161+ "this deployment is pinned to its own managed app; the sink's app id does not match",162+ );163+ }164+ const current = readDeclaredSink();165+ if (166+ current && (current.appId !== declaration.appId || current.serverUrl !== declaration.serverUrl)167+ ) {168+ throw new DataSinkError(169+ "sink-already-declared",170+ "a different sink is already declared on this device; clear it before declaring another",171+ );172+ }173+ if (typeof localStorage !== "undefined") {174+ try {175+ const record: SinkRecord = { v: 1, sink: declaration };176+ localStorage.setItem(sinkKey, JSON.stringify(record));177+ } catch {178+ // A private-mode storage failure must not break enrollment; the sink179+ // then lasts for this document only via the default resolution below180+ // returning null — callers observe the absence through readDeclaredSink.181+ }182+ }183+ return declaration;184+}185+186+/** Removes the declared sink. Existing local data and elections are untouched. */187+export function clearDeclaredSink(): void {188+ if (typeof localStorage === "undefined") return;189+ try {190+ localStorage.removeItem(sinkKey);191+ } catch {192+ // A private-mode storage failure leaves the previous declaration in place.193+ }194+}195+196+/**197+ * A parsed `lofisync1.` app-connect ticket — the credential a self-hosted198+ * node issues so an app syncs against it. The format contract lives with the199+ * node (lofi-node `docs/app-ticket.md`, conformance fixtures vendored at200+ * `package/testdata/app-ticket-fixtures.json`); this parser mirrors its201+ * validation: version 1, an http(s) URL whose path is202+ * `/t/<43-char base64url secret>`, and a `scope` that is absent (meaning203+ * `sync`), `sync`, or `provision` — an unknown scope rejects the ticket204+ * rather than silently granting less than it claims.205+ */206+export type SyncTicket = {207+ v: 1;208+ appId: string;209+ url: string;210+ scope?: "sync" | "provision";211+ label?: string;212+ node?: string;213+};214+215+const TICKET_PREFIX = "lofisync1.";216+const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;217+218+/**219+ * Parses a pasted or scanned app-connect ticket. Returns `null` on any220+ * malformed input — paste paths never throw. The ticket URL is a bearer221+ * credential: hold it only as long as enrollment needs it.222+ */223+export function parseSyncTicket(text: string): SyncTicket | null {224+ const trimmed = text.trim();225+ if (!trimmed.startsWith(TICKET_PREFIX)) return null;226+ try {227+ const base64 = trimmed.slice(TICKET_PREFIX.length)228+ .replaceAll("-", "+")229+ .replaceAll("_", "/");230+ const parsed = JSON.parse(atob(base64)) as SyncTicket;231+ if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {232+ return null;233+ }234+ if (235+ parsed.scope !== undefined && parsed.scope !== "sync" && parsed.scope !== "provision"236+ ) {237+ return null;238+ }239+ const url = new URL(parsed.url);240+ if (!/^https?:$/.test(url.protocol) || !TICKET_PATH.test(url.pathname)) return null;241+ return parsed;242+ } catch {243+ return null;244+ }245+}246+247+/**248+ * Converts a ticket into a sink declaration and records it. Throws249+ * {@link DataSinkError} with `invalid-ticket` for malformed tickets and the250+ * declaration errors otherwise. Electing sync afterwards (see251+ * `enrollSyncTicket` in `session.ts`) is what actually connects.252+ */253+export function declareSinkFromTicket(text: string): DataSinkDeclaration {254+ const ticket = parseSyncTicket(text);255+ if (!ticket) {256+ throw new DataSinkError("invalid-ticket", "not a valid lofisync1 app-connect ticket");257+ }258+ return declareDataSink({259+ appId: ticket.appId,260+ serverUrl: ticket.url,261+ ...(ticket.scope ? { scope: ticket.scope } : {}),262+ ...(ticket.label ? { label: ticket.label } : {}),263+ ...(ticket.node ? { node: ticket.node } : {}),264+ });265+}266+267+/** True when an error is a data-sink or ticket problem the user can fix and retry. */268+export function isDataSinkError(error: unknown): error is DataSinkError {269+ return error instanceof DataSinkError;270+}@@ -0,0 +1,276 @@
1+// Package contract tests for the runtime-declared data sink: record2+// round-trip, ticket parsing (the lofi-node `lofisync1.` format contract),3+// declaration guards, and how the active sync location resolves into the4+// session election and database configuration.5+import {6+ anchorAppId,7+ clearDeclaredSink,8+ declareDataSink,9+ declareSinkFromTicket,10+ isDataSinkError,11+ parseSyncTicket,12+ readDeclaredSink,13+} from "./data-sink.ts";14+import {15+ activeAppId,16+ activeServerUrl,17+ activeSink,18+ appId,19+ databaseConfig,20+ setSyncElected,21+ syncAvailable,22+ syncElected,23+ syncing,24+} from "./config.ts";25+import { defineLofiApp } from "./app.ts";26+import { assert } from "./test-assert.ts";27+28+defineLofiApp({29+ name: "lofi-test",30+ databaseName: "lofi-test",31+ schema: {},32+ storage: "durable",33+ credentialOrigins: [],34+ sync: { adapter: "jazz" },35+});36+37+const test = (globalThis as unknown as {38+ Deno: { test(name: string, body: () => void | Promise<void>): void };39+}).Deno.test;40+41+const sinkKey = `lofi:data-sink:${anchorAppId}`;42+const electionKey = `lofi:sync-elected:${appId}`;43+44+function withCleanState(body: () => void): () => void {45+ return () => {46+ localStorage.removeItem(sinkKey);47+ localStorage.removeItem(electionKey);48+ try {49+ body();50+ } finally {51+ localStorage.removeItem(sinkKey);52+ localStorage.removeItem(electionKey);53+ }54+ };55+}56+57+// A ticket encoded exactly as lofi-node's encodeAppTicket does — this fixture58+// doubles as the cross-repo format contract.59+const ticketSecret = "s".repeat(43);60+const ticketAppId = "cfe52e44-7a59-4232-8dbb-bf53f27aeed6";61+function encodeTicket(payload: unknown): string {62+ const json = JSON.stringify(payload);63+ return "lofisync1." +64+ btoa(json).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");65+}66+const validTicket = encodeTicket({67+ v: 1,68+ appId: ticketAppId,69+ url: `http://192.168.1.10:4802/t/${ticketSecret}`,70+ label: "phone",71+});72+73+test(74+ "a declared sink round-trips through the versioned record",75+ withCleanState(() => {76+ assert(readDeclaredSink() === null, "a clean device must have no declared sink");77+ declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802", label: "home" });78+ const sink = readDeclaredSink();79+ assert(80+ sink !== null && sink.appId === ticketAppId &&81+ sink.serverUrl === "https://node.example:4802" && sink.label === "home",82+ `declared sink read back as ${JSON.stringify(sink)}`,83+ );84+ clearDeclaredSink();85+ assert(readDeclaredSink() === null, "clearing must remove the declaration");86+ }),87+);88+89+test(90+ "an unreadable or unversioned sink record reads as absent",91+ withCleanState(() => {92+ localStorage.setItem(sinkKey, "not json");93+ assert(readDeclaredSink() === null, "malformed record must read as absent");94+ localStorage.setItem(sinkKey, JSON.stringify({ v: 99, sink: { appId: "x", serverUrl: "y" } }));95+ assert(readDeclaredSink() === null, "unknown record version must read as absent");96+ }),97+);98+99+test(100+ "sink declarations validate the server URL",101+ withCleanState(() => {102+ for (const serverUrl of ["ws://node.example/t/x", "not a url", "ftp://node.example"]) {103+ try {104+ declareDataSink({ appId: ticketAppId, serverUrl });105+ throw new Error(`declaration accepted invalid server URL: ${serverUrl}`);106+ } catch (error) {107+ assert(108+ isDataSinkError(error) && error.code === "invalid-server-url",109+ `expected invalid-server-url for ${serverUrl}, received ${String(error)}`,110+ );111+ }112+ }113+ assert(readDeclaredSink() === null, "no rejected declaration may persist");114+ }),115+);116+117+test(118+ "declaring over a different sink is refused; the same store updates its label",119+ withCleanState(() => {120+ declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });121+ try {122+ declareDataSink({ appId: ticketAppId, serverUrl: "https://other.example:4802" });123+ throw new Error("a different sink must not silently replace the declared one");124+ } catch (error) {125+ assert(126+ isDataSinkError(error) && error.code === "sink-already-declared",127+ `expected sink-already-declared, received ${String(error)}`,128+ );129+ }130+ declareDataSink({131+ appId: ticketAppId,132+ serverUrl: "https://node.example:4802",133+ label: "renamed",134+ });135+ assert(136+ readDeclaredSink()?.label === "renamed",137+ "re-declaring the same store must update the label",138+ );139+ }),140+);141+142+// The machine-readable format contract shared with lofi-node143+// (docs/fixtures/app-ticket-fixtures.json there; vendored copy here). Either144+// side changing the format updates the fixtures, and this test is what fails145+// on drift.146+test("the vendored lofi-node ticket fixtures parse exactly as the contract expects", async () => {147+ const fixtures = JSON.parse(148+ await Deno.readTextFile(149+ new URL("../testdata/app-ticket-fixtures.json", import.meta.url),150+ ),151+ ) as {152+ valid: { name: string; ticket: string; expect: Record<string, string> }[];153+ invalid: { name: string; ticket: string }[];154+ };155+ for (const { name, ticket, expect } of fixtures.valid) {156+ const parsed = parseSyncTicket(ticket);157+ assert(parsed !== null, `valid fixture rejected: ${name}`);158+ assert(159+ parsed.appId === expect.appId && parsed.url === expect.url &&160+ (parsed.scope ?? "sync") === expect.scope,161+ `fixture "${name}" parsed as ${JSON.stringify(parsed)}, expected ${JSON.stringify(expect)}`,162+ );163+ }164+ for (const { name, ticket } of fixtures.invalid) {165+ assert(parseSyncTicket(ticket) === null, `invalid fixture accepted: ${name}`);166+ }167+});168+169+test("ticket parsing accepts the lofi-node format and rejects everything else", () => {170+ const parsed = parseSyncTicket(` ${validTicket} `);171+ assert(172+ parsed !== null && parsed.appId === ticketAppId && parsed.label === "phone" &&173+ parsed.url.endsWith(`/t/${ticketSecret}`),174+ `valid ticket parsed as ${JSON.stringify(parsed)}`,175+ );176+ const provision = parseSyncTicket(177+ encodeTicket({178+ v: 1,179+ appId: ticketAppId,180+ url: `http://h/t/${ticketSecret}`,181+ scope: "provision",182+ }),183+ );184+ assert(provision?.scope === "provision", "provision scope must parse");185+ const rejected = [186+ "lofisync2.abc",187+ "endpointAAAA",188+ encodeTicket({ v: 2, appId: ticketAppId, url: `http://h/t/${ticketSecret}` }),189+ encodeTicket({ v: 1, appId: ticketAppId, url: `ws://h/t/${ticketSecret}` }),190+ encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/t/short" }),191+ encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/apps/x" }),192+ encodeTicket({ v: 1, url: `http://h/t/${ticketSecret}` }),193+ encodeTicket({ v: 1, appId: ticketAppId, url: `http://h/t/${ticketSecret}`, scope: "root" }),194+ "lofisync1.%%%not-base64%%%",195+ ];196+ for (const ticket of rejected) {197+ assert(parseSyncTicket(ticket) === null, `malformed ticket accepted: ${ticket.slice(0, 40)}`);198+ }199+});200+201+test(202+ "enrolling a ticket declares its URL verbatim as the sink",203+ withCleanState(() => {204+ const declared = declareSinkFromTicket(validTicket);205+ assert(206+ declared.serverUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,207+ "the ticket URL must be used verbatim — the secret path is the credential",208+ );209+ const sink = readDeclaredSink();210+ assert(sink?.appId === ticketAppId && sink.label === "phone", "ticket fields must carry over");211+ assert(sink.scope === undefined, "a scopeless ticket declares no explicit scope");212+ try {213+ declareSinkFromTicket("lofisync1.nope");214+ throw new Error("a malformed ticket must not enroll");215+ } catch (error) {216+ assert(217+ isDataSinkError(error) && error.code === "invalid-ticket",218+ `expected invalid-ticket, received ${String(error)}`,219+ );220+ }221+ }),222+);223+224+test(225+ "the active sink resolves declared over default and gates the election",226+ withCleanState(() => {227+ // This test build compiles no managed app, so the default sink is absent.228+ assert(activeSink() === null && !syncAvailable(), "local build must start with no sink");229+ assert(activeAppId() === appId, "without a sink the active app id is the anchor");230+ setSyncElected(true);231+ assert(!syncElected(), "election must be a no-op without a sync location");232+233+ declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });234+ const sink = activeSink();235+ assert(236+ sink?.source === "declared" && sink.appId === ticketAppId && syncAvailable(),237+ `declared sink resolved as ${JSON.stringify(sink)}`,238+ );239+ assert(activeAppId() === ticketAppId, "the active app id must be the sink's");240+ assert(activeServerUrl() === "https://node.example:4802", "the active URL must be the sink's");241+242+ setSyncElected(true);243+ assert(syncElected() && syncing(), "election must stand once a sink is declared");244+ setSyncElected(false);245+ assert(!syncing(), "clearing the election must stop replication");246+ }),247+);248+249+test(250+ "databaseConfig keeps the local tier on the anchor and gives managed the sink",251+ withCleanState(() => {252+ declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });253+ setSyncElected(true);254+255+ const local = databaseConfig("secret", "ns", "local", false);256+ assert(local.appId === appId, "the local tier must stay on the anchor app id");257+ assert(!("serverUrl" in local), "the local tier must not carry a server URL");258+ const localDriver = local.driver as { dbName: string };259+ assert(260+ localDriver.dbName === `lofi-test-${appId}-ns-local`,261+ `local dbName was ${localDriver.dbName} — first-boot data must remain findable`,262+ );263+264+ const managed = databaseConfig("secret", "ns");265+ assert(managed.appId === ticketAppId, "the managed tier must use the sink's app id");266+ assert(267+ (managed as { serverUrl?: string }).serverUrl === "https://node.example:4802",268+ "a connecting managed config must carry the sink URL",269+ );270+ const managedDriver = managed.driver as { dbName: string };271+ assert(272+ managedDriver.dbName === `lofi-test-${ticketAppId}-ns-managed`,273+ `managed dbName was ${managedDriver.dbName} — each store gets its own namespace`,274+ );275+ }),276+);@@ -1,11 +1,14 @@
1-import { serverUrl, syncing } from "./config.ts";1+import { activeServerUrl, syncing } from "./config.ts";22 // Package-owned browser lifecycle.33 import { createForegroundRecovery, type ForegroundRecoveryState } from "./foreground-recovery.ts";44 import { getRuntime } from "./runtime.ts";55 import { isTransportPausedByInspector } from "./transport-gate.ts";66 7+// Evaluated at document load: declaring a sink reloads the document on the8+// SharedWorker path (see `session.ts`), so recovery activates on the boot that9+// first has a sync location.710 const recovery = typeof document === "undefined" ? null : createForegroundRecovery({8- enabled: Boolean(serverUrl),11+ enabled: Boolean(activeServerUrl()),912 pageTarget: globalThis,1013 visibilityTarget: document,1114 isVisible: () => document.visibilityState === "visible",@@ -21,7 +24,7 @@ const recovery = typeof document === "undefined" ? null : createForegroundRecove
2124 2225 export function getForegroundRecoveryState(): ForegroundRecoveryState {2326 return recovery?.getState() ?? {24- mode: serverUrl ? "managed" : "local-only",27+ mode: activeServerUrl() ? "managed" : "local-only",2528 status: "idle",2629 attempts: 0,2730 lastReason: "none",@@ -96,6 +96,7 @@ export {
9696 createBackupPasskey,9797 createRecoverablePasskeyBackup,9898 enableSyncBackup,99+ enrollSyncTicket,99100 isAccountReplacementError,100101 isAuthError,101102 isRecoverablePasskeyError,@@ -107,8 +108,19 @@ export {
107108 restoreFromRecoveryPhrase,108109 revealRecoveryPhrase,109110 type Session,111+ type SessionSink,110112 stopSyncBackup,111113 } from "./session.ts";114+export {115+ clearDeclaredSink,116+ type DataSinkDeclaration,117+ DataSinkError,118+ declareDataSink,119+ isDataSinkError,120+ parseSyncTicket,121+ readDeclaredSink,122+ type SyncTicket,123+} from "./data-sink.ts";112124 export { RecoveryError } from "./recovery.ts";113125 export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";114126 export {@@ -1,4 +1,4 @@
1-import { serverUrl, syncing } from "./config.ts";1+import { activeServerUrl, syncing } from "./config.ts";22 // Package-owned development probe.33 import { readDeviceCapabilityReport } from "./device-capabilities.ts";44 import { type InspectorAdapter, type InspectorSnapshot, mountInspector } from "./inspector.ts";@@ -55,8 +55,8 @@ async function readSnapshot(): Promise<InspectorSnapshot> {
5555 startupFailure: diagnostics.startupFailure?.code ?? "none",5656 },5757 sync: {58- mode: serverUrl ? "managed configured" : "local-only",59- transport: serverUrl58+ mode: activeServerUrl() ? "managed configured" : "local-only",59+ transport: activeServerUrl()6060 ? isTransportPausedByInspector() ? "paused by inspector" : "live detail unavailable"6161 : "not configured",6262 pendingLocalWrites: diagnostics.pendingLocalWrites,@@ -72,7 +72,7 @@ async function readSnapshot(): Promise<InspectorSnapshot> {
7272 },7373 lifecycle: {7474 ...lifecycle,75- transportDetail: serverUrl ? "live detail unavailable" : "not configured",75+ transportDetail: activeServerUrl() ? "live detail unavailable" : "not configured",7676 },7777 multiTab: {7878 role: "unavailable",@@ -97,7 +97,7 @@ const bridge: LofiDevelopmentBridge = {
9797 };9898 },9999 async setTransportPaused(paused) {100- if (!serverUrl) {100+ if (!activeServerUrl()) {101101 throw new Error("Cloud transport pause is unavailable in local-only mode");102102 }103103 const runtime = await getRuntime();@@ -19,7 +19,15 @@
1919 2020 import { BrowserAuthSecretStore } from "jazz-tools";2121 import { getLofiApp } from "./app.ts";22-import { appId, setSyncElected, syncAvailable, syncElected, syncing } from "./config.ts";22+import {23+ activeSink,24+ appId,25+ setSyncElected,26+ syncAvailable,27+ syncElected,28+ syncing,29+} from "./config.ts";30+import { declareSinkFromTicket } from "./data-sink.ts";2331 import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";2432 import { authenticateDeviceCredential, AuthError, enrollDeviceCredential } from "./auth.ts";2533 import {@@ -39,12 +47,24 @@ import {
3947 } from "./runtime.ts";4048 import { electManagedNamespace, readNamespaceState } from "./namespace-state.ts";4149 50+/** A non-secret description of the sync location in effect. */51+export type SessionSink = {52+ /** `declared` is a runtime-declared sink; `default` is the compiled managed app. */53+ source: "declared" | "default";54+ /** The sink host (never the full URL — a ticket URL is a bearer credential). */55+ host: string;56+ /** The user-facing label from enrollment, when one was given. */57+ label?: string;58+};59+4260 /** A snapshot of the account: what is possible and what the user has chosen. */4361 export type Session = {4462 /** Stable, app-scoped Jazz sharing identity for the active account. */4563 user_id: string | null;46- /** Whether a managed Jazz app is configured, so backup + sync is possible at all. */64+ /** Whether a sync location exists (declared or compiled), so backup + sync is possible. */4765 syncAvailable: boolean;66+ /** The sync location in effect, or `null` while the device is local-only. */67+ sink: SessionSink | null;4868 /** Whether the user has elected to back up and sync this account on this device. */4969 backedUp: boolean;5070 /** Whether writes replicate right now (available *and* elected). */@@ -131,11 +151,24 @@ function setPasskeyRecoverable(value: boolean): void {
131151 }132152 }133153 154+function sessionSink(): SessionSink | null {155+ const sink = activeSink();156+ if (!sink) return null;157+ let host: string;158+ try {159+ host = new URL(sink.serverUrl).host;160+ } catch {161+ host = "unknown";162+ }163+ return { source: sink.source, host, ...(sink.label ? { label: sink.label } : {}) };164+}165+134166 /** Reads the current session. Synchronous — it never prompts or touches the network. */135167 export function readSession(): Session {136168 return {137169 user_id: getRuntimePrincipal(),138- syncAvailable,170+ syncAvailable: syncAvailable(),171+ sink: sessionSink(),139172 backedUp: syncElected(),140173 syncing: syncing(),141174 phraseGuarded: phraseGuarded(),@@ -244,10 +277,10 @@ export async function revealRecoveryPhrase(): Promise<string> {
244277 * no Jazz app is configured.245278 */246279 export async function enableSyncBackup(): Promise<Session> {247- // The documented no-op: without a managed Jazz app there is nothing to sync280+ // The documented no-op: without a sync location there is nothing to sync248281 // to, and electing the managed namespace anyway would hide the local rows249282 // behind an empty database on every later boot.250- if (!syncAvailable) return readSession();283+ if (!syncAvailable()) return readSession();251284 const alreadyManaged = readNamespaceState().mode === "managed";252285 setSyncElected(true);253286 if (alreadyManaged && runtimeCanReconnect()) {@@ -263,6 +296,19 @@ export async function enableSyncBackup(): Promise<Session> {
263296 return readSession();264297 }265298 299+/**300+ * Enrolls a `lofisync1.` app-connect ticket as this device's sync location and301+ * elects to back up and sync in one step: the ticket's URL becomes the sync302+ * server, the local data pushes up under the same identity, and the session303+ * reflects the declared sink. Throws `DataSinkError` (see `data-sink.ts`) for304+ * malformed tickets, ws URLs, an app id that contradicts a compiled-in managed305+ * app, or a different sink already declared on this device.306+ */307+export async function enrollSyncTicket(ticket: string): Promise<Session> {308+ declareSinkFromTicket(ticket);309+ return await enableSyncBackup();310+}311+266312 /**267313 * Stops replicating this account to the server and returns to local-only. The268314 * local data and the account are untouched — this only detaches the network, so@@ -318,7 +364,7 @@ async function replaceAccountSecret(
318364 // Commit election only after the restored secret is durably saved: a319365 // replacement that fails earlier leaves the device booting its intact320366 // local account instead of an empty managed namespace.321- if (!syncAvailable) return;367+ if (!syncAvailable()) return;322368 setSyncElected(true);323369 electManagedNamespace({ migrateLocalRows: false });324370 },@@ -25,8 +25,8 @@ const electionKeys = [
2525 `lofi:sync-elected:${appId}`,2626 ];2727 28-test("enabling sync without a managed Jazz app is the documented no-op", async () => {29- assert(!syncAvailable, "this contract test requires the unconfigured local build");28+test("enabling sync without a sync location is the documented no-op", async () => {29+ assert(!syncAvailable(), "this contract test requires the unconfigured local build");3030 for (const key of electionKeys) localStorage.removeItem(key);3131 try {3232 const session = await enableSyncBackup();@@ -0,0 +1,45 @@
1+{2+ "comment": "Cross-repo contract fixtures for the lofisync1 app-ticket format. The lofi side's parser must accept `valid` entries with the listed expectations and reject every `invalid` entry. Secrets here are fixed placeholders, never issued by any node.",3+ "valid": [4+ {5+ "name": "sync ticket, no scope field (default sync)",6+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMSIsInVybCI6Imh0dHA6Ly8xOTIuMTY4LjEuMTA6NDgwMi90L1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1MiLCJsYWJlbCI6InBob25lIn0",7+ "expect": {8+ "appId": "11111111-1111-4111-8111-111111111111",9+ "scope": "sync",10+ "url": "http://192.168.1.10:4802/t/SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS"11+ }12+ },13+ {14+ "name": "provision ticket with node field",15+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInVybCI6Imh0dHBzOi8vbm9kZS5leGFtcGxlLmNvbS90L1BQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFAiLCJzY29wZSI6InByb3Zpc2lvbiIsImxhYmVsIjoibGFwdG9wLWFkbWluIiwibm9kZSI6ImVuZHBvaW50ZXhhbXBsZWV4YW1wbGVleGFtcGxlZXhhbXBsZWV4YW1wbGVleGFtcGxlIn0",16+ "expect": {17+ "appId": "22222222-2222-4222-8222-222222222222",18+ "scope": "provision",19+ "url": "https://node.example.com/t/PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP"20+ }21+ }22+ ],23+ "invalid": [24+ {25+ "name": "wrong prefix",26+ "ticket": "endpointabcdefabcdefabcdefabcdefabcdef"27+ },28+ {29+ "name": "empty payload",30+ "ticket": "lofisync1."31+ },32+ {33+ "name": "ws url",34+ "ticket": "lofisync1.eyJ2IjoxLCcorrupted"35+ },36+ {37+ "name": "bad secret path",38+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IngiLCJ1cmwiOiJodHRwOi8vaC93cm9uZyJ9"39+ },40+ {41+ "name": "unknown scope",42+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IngiLCJ1cmwiOiJodHRwOi8vaC90L1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1MiLCJzY29wZSI6InJvb3QifQ=="43+ }44+ ]45+}