The sync destination becomes user-selected data: a device-local, versioned sink record (appId + serverUrl) resolved ahead of the compiled managed app, which is demoted to the default sink. Enrolling a lofisync1 app-connect ticket declares the sink and drives the existing election and local-to-managed row migration unchanged. Device-local state stays keyed by the anchor app id so pre-sink data survives enrollment; the managed tier takes the sink's app id and URL. Part 2 of #107.
- commit
- 9a8799a65d7c623b2f33545beb4862c004d8cd9e
- parent
- cc3e4cbd356dbe3ea5b1e33727478481f08cc24a
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 50 | ++++++- |
| M | package/ | 13 | +- |
| M | package/ | 1 | + |
| M | package/ | 14 | +- |
| M | package/ | 82 | ++++++++-- |
| A | package/ | 252 | ++++++++++++++++++++++++++++++ |
| A | package/ | 238 | ++++++++++++++++++++++++++++ |
| M | package/ | 9 | +- |
| M | package/ | 12 | + |
| M | package/ | 10 | +- |
| M | package/ | 58 | ++++++- |
| M | package/ | 4 | +- |
@@ -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,46 @@ 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+- If the node revokes the ticket, requests fail with 401 and live sockets close; treat the stored78+ sink as dead and surface re-enrollment rather than retrying silently.79+3680 ## The user's account journey3781 3882 ```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,252 @@
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+ /** Optional user-facing label, e.g. the ticket's device label. */56+ label?: string;57+ /**58+ * Opaque forward-compat carrier: the node's iroh endpoint ticket from a59+ * `lofisync1.` app ticket. Unused by the browser runtime today.60+ */61+ node?: string;62+};63+64+/** Raised when a sink declaration or sync ticket is rejected. */65+export class DataSinkError extends Error {66+ /** Stable error class name for diagnostics and error boundaries. */67+ override readonly name = "DataSinkError";68+ /** Stable category for user-facing enrollment flows. */69+ readonly code:70+ | "invalid-ticket"71+ | "invalid-server-url"72+ | "app-id-mismatch"73+ | "sink-already-declared";74+ /** Creates a sink rejection with a stable category. */75+ constructor(76+ code: "invalid-ticket" | "invalid-server-url" | "app-id-mismatch" | "sink-already-declared",77+ message: string,78+ ) {79+ super(message);80+ this.code = code;81+ }82+}83+84+type SinkRecord = { v: 1; sink: DataSinkDeclaration };85+86+const sinkKey = `lofi:data-sink:${anchorAppId}`;87+88+function parseRecord(raw: string | null): DataSinkDeclaration | null {89+ if (!raw) return null;90+ try {91+ const record = JSON.parse(raw) as Partial<SinkRecord> | null;92+ const sink = record?.v === 1 ? record.sink : undefined;93+ if (94+ sink && typeof sink.appId === "string" && sink.appId &&95+ typeof sink.serverUrl === "string" && sink.serverUrl96+ ) {97+ return {98+ appId: sink.appId,99+ serverUrl: sink.serverUrl,100+ ...(typeof sink.label === "string" ? { label: sink.label } : {}),101+ ...(typeof sink.node === "string" ? { node: sink.node } : {}),102+ };103+ }104+ } catch {105+ // An unreadable record is treated as absent below.106+ }107+ return null;108+}109+110+/** Reads the declared sink, or `null` when this device has not declared one. */111+export function readDeclaredSink(): DataSinkDeclaration | null {112+ if (typeof localStorage === "undefined") return null;113+ try {114+ return parseRecord(localStorage.getItem(sinkKey));115+ } catch {116+ return null;117+ }118+}119+120+function assertHttpServerUrl(serverUrl: string): void {121+ let parsed: URL;122+ try {123+ parsed = new URL(serverUrl);124+ } catch {125+ throw new DataSinkError("invalid-server-url", `sink server URL is not a URL: ${serverUrl}`);126+ }127+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {128+ // The browser client derives its own WebSocket endpoint from an http(s)129+ // base; a ws:// URL here would bypass that derivation and lose the130+ // ticket's base path.131+ throw new DataSinkError(132+ "invalid-server-url",133+ `sink server URL must be http(s), received ${parsed.protocol}`,134+ );135+ }136+}137+138+/**139+ * Declares the sync location for this device. Validates the URL, refuses an140+ * app id that contradicts a compiled-in managed app, and refuses to replace a141+ * different declared sink (one store, one active sink — clear the existing142+ * declaration first; switching stores is deliberately not a silent overwrite).143+ * Re-declaring the same store updates the label. Declaration alone changes no144+ * runtime state; electing sync (see `session.ts`) is what connects.145+ */146+export function declareDataSink(declaration: DataSinkDeclaration): DataSinkDeclaration {147+ if (!declaration.appId.trim()) {148+ throw new DataSinkError("invalid-ticket", "sink app id must not be empty");149+ }150+ assertHttpServerUrl(declaration.serverUrl);151+ if (configuredAppId && declaration.appId !== configuredAppId) {152+ throw new DataSinkError(153+ "app-id-mismatch",154+ "this deployment is pinned to its own managed app; the sink's app id does not match",155+ );156+ }157+ const current = readDeclaredSink();158+ if (159+ current && (current.appId !== declaration.appId || current.serverUrl !== declaration.serverUrl)160+ ) {161+ throw new DataSinkError(162+ "sink-already-declared",163+ "a different sink is already declared on this device; clear it before declaring another",164+ );165+ }166+ if (typeof localStorage !== "undefined") {167+ try {168+ const record: SinkRecord = { v: 1, sink: declaration };169+ localStorage.setItem(sinkKey, JSON.stringify(record));170+ } catch {171+ // A private-mode storage failure must not break enrollment; the sink172+ // then lasts for this document only via the default resolution below173+ // returning null — callers observe the absence through readDeclaredSink.174+ }175+ }176+ return declaration;177+}178+179+/** Removes the declared sink. Existing local data and elections are untouched. */180+export function clearDeclaredSink(): void {181+ if (typeof localStorage === "undefined") return;182+ try {183+ localStorage.removeItem(sinkKey);184+ } catch {185+ // A private-mode storage failure leaves the previous declaration in place.186+ }187+}188+189+/**190+ * A parsed `lofisync1.` app-connect ticket — the credential a self-hosted191+ * node issues so an app syncs against it. The format contract lives with the192+ * node (lofi-node `docs/app-ticket.md`); this parser mirrors its validation:193+ * version 1, an http(s) URL whose path is `/t/<43-char base64url secret>`.194+ */195+export type SyncTicket = {196+ v: 1;197+ appId: string;198+ url: string;199+ label?: string;200+ node?: string;201+};202+203+const TICKET_PREFIX = "lofisync1.";204+const TICKET_PATH = /^\/t\/[A-Za-z0-9_-]{43}$/;205+206+/**207+ * Parses a pasted or scanned app-connect ticket. Returns `null` on any208+ * malformed input — paste paths never throw. The ticket URL is a bearer209+ * credential: hold it only as long as enrollment needs it.210+ */211+export function parseSyncTicket(text: string): SyncTicket | null {212+ const trimmed = text.trim();213+ if (!trimmed.startsWith(TICKET_PREFIX)) return null;214+ try {215+ const base64 = trimmed.slice(TICKET_PREFIX.length)216+ .replaceAll("-", "+")217+ .replaceAll("_", "/");218+ const parsed = JSON.parse(atob(base64)) as SyncTicket;219+ if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {220+ return null;221+ }222+ const url = new URL(parsed.url);223+ if (!/^https?:$/.test(url.protocol) || !TICKET_PATH.test(url.pathname)) return null;224+ return parsed;225+ } catch {226+ return null;227+ }228+}229+230+/**231+ * Converts a ticket into a sink declaration and records it. Throws232+ * {@link DataSinkError} with `invalid-ticket` for malformed tickets and the233+ * declaration errors otherwise. Electing sync afterwards (see234+ * `enrollSyncTicket` in `session.ts`) is what actually connects.235+ */236+export function declareSinkFromTicket(text: string): DataSinkDeclaration {237+ const ticket = parseSyncTicket(text);238+ if (!ticket) {239+ throw new DataSinkError("invalid-ticket", "not a valid lofisync1 app-connect ticket");240+ }241+ return declareDataSink({242+ appId: ticket.appId,243+ serverUrl: ticket.url,244+ ...(ticket.label ? { label: ticket.label } : {}),245+ ...(ticket.node ? { node: ticket.node } : {}),246+ });247+}248+249+/** True when an error is a data-sink or ticket problem the user can fix and retry. */250+export function isDataSinkError(error: unknown): error is DataSinkError {251+ return error instanceof DataSinkError;252+}@@ -0,0 +1,238 @@
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+test("ticket parsing accepts the lofi-node format and rejects everything else", () => {143+ const parsed = parseSyncTicket(` ${validTicket} `);144+ assert(145+ parsed !== null && parsed.appId === ticketAppId && parsed.label === "phone" &&146+ parsed.url.endsWith(`/t/${ticketSecret}`),147+ `valid ticket parsed as ${JSON.stringify(parsed)}`,148+ );149+ const rejected = [150+ "lofisync2.abc",151+ "endpointAAAA",152+ encodeTicket({ v: 2, appId: ticketAppId, url: `http://h/t/${ticketSecret}` }),153+ encodeTicket({ v: 1, appId: ticketAppId, url: `ws://h/t/${ticketSecret}` }),154+ encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/t/short" }),155+ encodeTicket({ v: 1, appId: ticketAppId, url: "http://h/apps/x" }),156+ encodeTicket({ v: 1, url: `http://h/t/${ticketSecret}` }),157+ "lofisync1.%%%not-base64%%%",158+ ];159+ for (const ticket of rejected) {160+ assert(parseSyncTicket(ticket) === null, `malformed ticket accepted: ${ticket.slice(0, 40)}`);161+ }162+});163+164+test(165+ "enrolling a ticket declares its URL verbatim as the sink",166+ withCleanState(() => {167+ const declared = declareSinkFromTicket(validTicket);168+ assert(169+ declared.serverUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,170+ "the ticket URL must be used verbatim — the secret path is the credential",171+ );172+ const sink = readDeclaredSink();173+ assert(sink?.appId === ticketAppId && sink.label === "phone", "ticket fields must carry over");174+ try {175+ declareSinkFromTicket("lofisync1.nope");176+ throw new Error("a malformed ticket must not enroll");177+ } catch (error) {178+ assert(179+ isDataSinkError(error) && error.code === "invalid-ticket",180+ `expected invalid-ticket, received ${String(error)}`,181+ );182+ }183+ }),184+);185+186+test(187+ "the active sink resolves declared over default and gates the election",188+ withCleanState(() => {189+ // This test build compiles no managed app, so the default sink is absent.190+ assert(activeSink() === null && !syncAvailable(), "local build must start with no sink");191+ assert(activeAppId() === appId, "without a sink the active app id is the anchor");192+ setSyncElected(true);193+ assert(!syncElected(), "election must be a no-op without a sync location");194+195+ declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });196+ const sink = activeSink();197+ assert(198+ sink?.source === "declared" && sink.appId === ticketAppId && syncAvailable(),199+ `declared sink resolved as ${JSON.stringify(sink)}`,200+ );201+ assert(activeAppId() === ticketAppId, "the active app id must be the sink's");202+ assert(activeServerUrl() === "https://node.example:4802", "the active URL must be the sink's");203+204+ setSyncElected(true);205+ assert(syncElected() && syncing(), "election must stand once a sink is declared");206+ setSyncElected(false);207+ assert(!syncing(), "clearing the election must stop replication");208+ }),209+);210+211+test(212+ "databaseConfig keeps the local tier on the anchor and gives managed the sink",213+ withCleanState(() => {214+ declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });215+ setSyncElected(true);216+217+ const local = databaseConfig("secret", "ns", "local", false);218+ assert(local.appId === appId, "the local tier must stay on the anchor app id");219+ assert(!("serverUrl" in local), "the local tier must not carry a server URL");220+ const localDriver = local.driver as { dbName: string };221+ assert(222+ localDriver.dbName === `lofi-test-${appId}-ns-local`,223+ `local dbName was ${localDriver.dbName} — first-boot data must remain findable`,224+ );225+226+ const managed = databaseConfig("secret", "ns");227+ assert(managed.appId === ticketAppId, "the managed tier must use the sink's app id");228+ assert(229+ (managed as { serverUrl?: string }).serverUrl === "https://node.example:4802",230+ "a connecting managed config must carry the sink URL",231+ );232+ const managedDriver = managed.driver as { dbName: string };233+ assert(234+ managedDriver.dbName === `lofi-test-${ticketAppId}-ns-managed`,235+ `managed dbName was ${managedDriver.dbName} — each store gets its own namespace`,236+ );237+ }),238+);@@ -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();