clone

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

fix(runtime): sync-state integrity — owner pin, enrollment preflight, guarded stop-sync, reload budget (#180)

FelineStateMachine committed 303cceb
Per-device sync state (election flag, sealed sink, namespace record) was
scoped to the app alone, with nothing binding it to the account or to the
transport the runtime actually configured. Four fixes close the gaps:

- Sync-owner pin (new sync-owner.ts): electing sync records the electing
  account's secret fingerprint (the namespace derivation, already
  non-secret). A boot that finds a different account in hand suppresses
  transport instead of writing into the owner's store, surfacing the
  mismatch through session.syncOwnerMismatch and the syncOwner runtime
  diagnostic; electing under the mismatch throws SyncOwnerError. Stop-sync
  releases the pin; an explicit restore ceremony adopts it.

- Enrollment preflight: enrollSyncTicket now awaits the node's bounded
  store-status answer before electing. no_schema and ticket_rejected roll
  the sink declaration back, hold no provision capability, and throw
  SyncEnrollmentError; a merely unreachable store enrolls with the warning
  recorded in runtime diagnostics.

- stopSyncBackup only disconnects a transport that was configured
  (runtimeCanReconnect), so a stale-election tab resolves cleanly instead
  of throwing from the vendor client.

- Framework reloads (sync election, principal replacement) draw from a
  per-tab budget (new reload-guard.ts) that a settled boot resets; an
  unbroken reload-without-ready sequence stops with a reload-loop startup
  failure instead of cycling.

Unit tests cover the owner record and adjudication, the reload budget, the
stop-sync repro, and the full enrollment preflight matrix through injected
seams; docs describe the new semantics in sync-and-recovery,
troubleshooting, store-provisioning, and auth-identity.
commit
303cceb2662346acb171f623ae7f317d68e33d87
parent
ea40241c997fcf0bd99602d43ed46930e59ab4de
author
FelineStateMachine <dami.etoile@gmail.com>
15 files changed, +1007 -47
Mdocs/auth-identity.md7+-
Mdocs/store-provisioning.md5+-
Mdocs/sync-and-recovery.md13+-
Mdocs/troubleshooting.md28+++
Mpackage/astro/manifest.ts2+
Mpackage/runtime/diagnostics.ts16++
Mpackage/runtime/mod.ts10+-
Apackage/runtime/reload-guard.ts63+++++++
Apackage/runtime/reload-guard_test.ts54++++++
Mpackage/runtime/runtime.ts95++++++++--
Mpackage/runtime/session.ts212+++++++++++++++++++++--
Mpackage/runtime/session_test.ts270++++++++++++++++++++++++++++++-
Mpackage/runtime/startup-recovery.ts10+
Apackage/runtime/sync-owner.ts160++++++++++++++++++
Apackage/runtime/sync-owner_test.ts109++++++++++++
M docs/auth-identity.md +6 -1
M docs/store-provisioning.md +4 -1
M docs/sync-and-recovery.md +12 -1
@@ -47,13 +47,14 @@ ticket — a `lofisync1.…` string carrying the store's app id and a gate URL
4747 into the app:4848 4949 ```ts50-import { enrollSyncTicket, isDataSinkError } from "@nzip/lofi";50+import { enrollSyncTicket, isDataSinkError, isSyncEnrollmentError } from "@nzip/lofi";5151 5252 try {5353 const session = await enrollSyncTicket(pastedTicket);5454 // session.sink → { source: "declared", host: "192.168.1.10:4802", label: "phone" }5555 } catch (error) {5656 if (isDataSinkError(error)) showEnrollmentProblem(error.code, error.message);57+ else if (isSyncEnrollmentError(error)) showEnrollmentProblem(error.code, error.message);5758 else throw error;5859 }5960 ```
@@ -74,6 +75,16 @@ Semantics to rely on:
7475 re-pointed.7576 - **One store, one active sink.** Declaring a different sink over an existing one is refused; clear7677 the declaration first. Local data and elections survive clearing.78+- **The store answers before enrollment is kept.** Enrollment runs a bounded metadata preflight79+ against the node: a store with no schema deployed for this app, or a rejected ticket, rolls the80+ declaration back and throws `SyncEnrollmentError` — the device stays exactly as it was. A store81+ that is merely unreachable enrolls anyway, with the warning recorded as `storeStatus` in runtime82+ diagnostics, because a flaky network must not block a legitimate ticket.83+- **The election is pinned to the account that made it.** Electing sync records the electing84+ account's fingerprint; a boot that finds a different account in hand opens local-only with85+ transport suppressed, and the session reports `syncOwnerMismatch` rather than letting a second86+ identity write into the owner's store. Electing under the mismatch throws `SyncOwnerError`.87+ Stopping sync releases the pin; an explicit restore ceremony adopts it for the restored account.7788 - **What the server can see is documented, not implied.** The [threat model](threat-model.md) states7889 the server's view, the user's holdings, and the ticket/XSS custody story in one place.7990 - **The ticket URL is a bearer credential.** It is used verbatim as the sync server (its secret path
M docs/troubleshooting.md +28 -0
@@ -137,6 +137,34 @@ display it. `no_schema` means the store has never been provisioned for this app
137137 never provisions or repairs a store on its own, and for non-ticket sinks the row stays138138 `not checked`.139139 140+Ticket enrollment runs the same check up front and refuses to keep an enrollment whose store answers141+`no_schema` or rejects the ticket (`SyncEnrollmentError`), so this boot-time symptom is reached142+through a store that regressed after enrollment, not through a fresh enrollment.143+144+## Enrolling a ticket fails with "no schema deployed"145+146+The node accepted the ticket, but its store has never been provisioned for this app, so nothing147+could sync; enrollment was rolled back and the device is unchanged. Provision the store — see148+[Store provisioning](store-provisioning.md) — then enroll again. A sync-only ticket cannot149+provision; the store's owner deploys the schema from a provision-scoped capability first.150+151+## Sync says it belongs to another account152+153+Sync on a device is pinned to the account that elected it. When the account in hand differs — a154+restored identity over a device that was already syncing, or a reset browser store that minted a155+fresh local account — the runtime boots local-only with transport suppressed, the session reports156+`syncOwnerMismatch`, and the `DeviceStatus` panel names the owning account when it is known. Nothing157+is merged and nothing is deleted. Either stop syncing (which releases the pin, so the current158+account may elect afterwards) or restore the owning account, then enable sync again.159+160+## The app stopped reloading automatically161+162+Framework-driven reloads (sync election, account replacement) are budgeted per tab: a sequence of163+reloads that never reaches a working runtime stops instead of cycling, and the failure surfaces as a164+`reload-loop` runtime startup failure in the recovery UI and the device report. A reload cycle means165+persisted state contradicts itself, often after a partially cleared browser store. Check the device166+report's account and sync state, resolve what it names, then reload manually.167+140168 ## The installed app opened empty after Add to Home Screen141169 142170 WebKit gives an installed web app its own storage container: cookies are copied at install time, but
M package/astro/manifest.ts +2 -0
M package/runtime/diagnostics.ts +16 -0
@@ -47,6 +47,8 @@ export type RuntimeDiagnostics = {
4747 startupFailure: RuntimeStartupFailure | null;4848 /** Whether the configured store answered; feeds connected/unreachable UI. */4949 storeStatus: RuntimeStoreStatus;50+ /** This boot's sync-owner verdict; `mismatch` means transport was suppressed. */51+ syncOwner: SyncOwnerDiagnostic;5052 /** The schema gate's verdict; `data-ahead` means this device is read-only. */5153 schemaCompat: SchemaCompatState;5254 /** Storage clients opened since page load, runtime recreations included. */
@@ -96,6 +98,19 @@ export type RuntimeDiagnostics = {
9698 sharedFieldAlerts: readonly SharedFieldAlert[];9799 };98100 101+/**102+ * The boot-time sync-owner adjudication. `unchecked` means the runtime was103+ * not connecting (nothing to adjudicate); `self` means the account in hand104+ * owns the election; `mismatch` means sync on this device was elected by a105+ * different account, so the runtime booted with transport suppressed rather106+ * than writing into the owner's store. The remediation is explicit: stop107+ * sync (releasing the election) or restore the owning account.108+ */109+export type SyncOwnerDiagnostic =110+ | { state: "unchecked" }111+ | { state: "self" }112+ | { state: "mismatch"; owner_user_id: string | null };113+99114 /** One detected shared-field key anomaly. */100115 export type SharedFieldAlert = {101116 /**
@@ -119,6 +134,7 @@ export function createDiagnostics(): RuntimeDiagnostics {
119134 storageState: "persistent-requested",120135 startupFailure: null,121136 storeStatus: { state: "unchecked", reason: "sync-not-connected" },137+ syncOwner: { state: "unchecked" },122138 schemaCompat: { state: "unchecked", reason: "inactive" },123139 clientsCreated: 0,124140 activeClients: 0,
M package/runtime/mod.ts +9 -1
A package/runtime/reload-guard.ts +63 -0
@@ -0,0 +1,63 @@
1+/**2+ * Package-owned reload budget for framework-driven page reloads.3+ *4+ * The runtime reloads the document at two well-defined boundaries (sync5+ * election and principal replacement on SharedWorker browsers). A device6+ * whose persisted state contradicts itself can turn those boundaries into a7+ * reload cycle. The budget counts framework-driven reloads in session8+ * storage — per tab, surviving each reload — and a settled boot clears it,9+ * so only an unbroken reload-without-ready sequence ever reaches the limit.10+ *11+ * @module12+ */13+14+import { anchorAppId } from "./data-sink.ts";15+16+/** The storage shape the budget needs; tests inject a plain map-backed one. */17+export type ReloadCounterStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;18+19+const reloadCountKey = `lofi:reload-count:${anchorAppId}`;20+21+// Two consecutive framework reloads without a settled boot are legitimate22+// (election, then e.g. an immediate principal replacement); the third is a23+// cycle.24+const maxReloadAttempts = 3;25+26+function defaultStorage(): ReloadCounterStorage | null {27+ try {28+ return typeof sessionStorage === "undefined" ? null : sessionStorage;29+ } catch {30+ return null;31+ }32+}33+34+/**35+ * Records one framework-driven reload attempt and answers whether it may36+ * proceed. Without session storage the budget degrades open — every attempt37+ * is allowed — because there is then also no cross-reload state to loop on.38+ */39+export function noteReloadAttempt(40+ storage: ReloadCounterStorage | null = defaultStorage(),41+): { allowed: boolean; count: number } {42+ if (storage === null) return { allowed: true, count: 0 };43+ try {44+ const previous = Number(storage.getItem(reloadCountKey) ?? "0");45+ const count = (Number.isFinite(previous) && previous >= 0 ? previous : 0) + 1;46+ storage.setItem(reloadCountKey, String(count));47+ return { allowed: count < maxReloadAttempts, count };48+ } catch {49+ return { allowed: true, count: 0 };50+ }51+}52+53+/** Clears the budget — called when a boot settles into a working runtime. */54+export function resetReloadAttempts(55+ storage: ReloadCounterStorage | null = defaultStorage(),56+): void {57+ if (storage === null) return;58+ try {59+ storage.removeItem(reloadCountKey);60+ } catch {61+ // A storage failure only delays the reset until the next settled boot.62+ }63+}
A package/runtime/reload-guard_test.ts +54 -0
@@ -0,0 +1,54 @@
1+// Package contract tests for the framework reload budget: counting, the2+// refusal threshold, the settled-boot reset, and storage-less degradation.3+import {4+ noteReloadAttempt,5+ type ReloadCounterStorage,6+ resetReloadAttempts,7+} from "./reload-guard.ts";8+import { anchorAppId } from "./data-sink.ts";9+import { assert } from "./test-assert.ts";10+11+const test = (globalThis as unknown as {12+ Deno: { test(name: string, body: () => void | Promise<void>): void };13+}).Deno.test;14+15+function memoryStorage(): ReloadCounterStorage {16+ const map = new Map<string, string>();17+ return {18+ getItem: (key) => map.get(key) ?? null,19+ setItem: (key, value) => void map.set(key, value),20+ removeItem: (key) => void map.delete(key),21+ };22+}23+24+test("two consecutive reloads are allowed and the third is refused", () => {25+ const storage = memoryStorage();26+ const first = noteReloadAttempt(storage);27+ assert(first.allowed && first.count === 1, "the first framework reload proceeds");28+ const second = noteReloadAttempt(storage);29+ assert(second.allowed && second.count === 2, "the second framework reload proceeds");30+ const third = noteReloadAttempt(storage);31+ assert(!third.allowed && third.count === 3, "an unbroken third reload is a cycle");32+});33+34+test("a settled boot resets the budget", () => {35+ const storage = memoryStorage();36+ noteReloadAttempt(storage);37+ noteReloadAttempt(storage);38+ resetReloadAttempts(storage);39+ const next = noteReloadAttempt(storage);40+ assert(next.allowed && next.count === 1, "the budget restarts after a settled boot");41+});42+43+test("a garbage counter value restarts the count instead of poisoning it", () => {44+ const storage = memoryStorage();45+ storage.setItem(`lofi:reload-count:${anchorAppId}`, "not a number");46+ const attempt = noteReloadAttempt(storage);47+ assert(attempt.allowed && attempt.count === 1, "garbage must count as zero prior attempts");48+});49+50+test("without session storage the budget degrades open", () => {51+ const attempt = noteReloadAttempt(null);52+ assert(attempt.allowed, "storage-less environments must not be blocked");53+ resetReloadAttempts(null);54+});
M package/runtime/runtime.ts +76 -19
@@ -39,10 +39,19 @@ import {
3939 } from "./resource-lifecycle.ts";4040 import {4141 createBrokerIncompatibilityHandler,42+ reloadLoopFailure,4243 runRuntimeStartup,44+ RuntimeStartupError,4345 type RuntimeStartupFailure,4446 } from "./startup-recovery.ts";4547 import { completeLocalRowMigration, readNamespaceState } from "./namespace-state.ts";48+import { noteReloadAttempt, resetReloadAttempts } from "./reload-guard.ts";49+import {50+ adjudicateSyncOwner,51+ readSyncOwner,52+ recordSyncOwner,53+ secretFingerprint,54+} from "./sync-owner.ts";4655 import { type NestedAppRoot, nestedAppTables } from "../schema/nested.ts";4756 4857 /** The shared, lazily opened Jazz client and its application-facing adapters. */
@@ -112,11 +121,10 @@ async function resolveAccountSecret(): Promise<string> {
112121 return await withAuthSecretLock(appId, () => BrowserAuthSecretStore.getOrCreateSecret({ appId }));113122 }114123 124+// The namespace name doubles as the account's sync-owner fingerprint; both125+// must stay byte-identical, so the derivation lives in sync-owner.ts.115126 async function accountNamespace(secret: string): Promise<string> {116- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));117- return [...new Uint8Array(digest).slice(0, 8)]118- .map((byte) => byte.toString(16).padStart(2, "0"))119- .join("");127+ return await secretFingerprint(secret);120128 }121129 122130 type RuntimeTable = {
@@ -252,16 +260,8 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
252260 state.diagnostics.storageState = "persistent-requested";253261 state.diagnostics.startupFailure = null;254262 state.diagnostics.storeStatus = { state: "unchecked", reason: "sync-not-connected" };263+ state.diagnostics.syncOwner = { state: "unchecked" };255264 notifyDiagnostics(state);256- // The store preflight rides alongside database creation, never in front of257- // it: local-first boot must not wait on the network. resolveStoreStatus maps258- // every failure and timeout to a diagnostic value, so this only records259- // state — a schema-less or drifted store surfaces here at boot instead of as260- // a hanging first write, and is never repaired from here.261- void resolveStoreStatus({ connect, sink: activeSink() }).then((status) => {262- state.diagnostics.storeStatus = status;263- notifyDiagnostics(state);264- });265265 return await runRuntimeStartup(runtimeMode, async () => {266266 assertDurableBrowser();267267 // On a cold first visit the engine binary is the wait; download it with
@@ -278,6 +278,31 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
278278 clearSharedFieldKeys();279279 await installSharedFieldIdentityFromSecret(secret);280280 const namespace = await accountNamespace(secret);281+ // The election is pinned to the account that made it: a different account282+ // on the same device (a reset secret store, a partial restore) boots with283+ // transport suppressed instead of writing into the owner's store. The284+ // namespace name is the fingerprint, so the verdict closes before any285+ // client exists.286+ let effectiveConnect = connect;287+ if (connect) {288+ const verdict = adjudicateSyncOwner(namespace);289+ if (verdict.state === "foreign") {290+ effectiveConnect = false;291+ state.diagnostics.syncOwner = { state: "mismatch", owner_user_id: verdict.owner_user_id };292+ } else {293+ state.diagnostics.syncOwner = { state: "self" };294+ }295+ notifyDiagnostics(state);296+ }297+ // The store preflight rides alongside database creation, never in front298+ // of it: local-first boot must not wait on the network. resolveStoreStatus299+ // maps every failure and timeout to a diagnostic value, so this only300+ // records state — a schema-less or drifted store surfaces here at boot301+ // instead of as a hanging first write, and is never repaired from here.302+ void resolveStoreStatus({ connect: effectiveConnect, sink: activeSink() }).then((status) => {303+ state.diagnostics.storeStatus = status;304+ notifyDiagnostics(state);305+ });281306 // A possession-bound sink proves the device key before connecting: the282307 // exchange mints a connect token the sync client carries as a path283308 // segment. A failed exchange (node restart, revocation, key loss) boots
@@ -291,7 +316,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
291316 // like a revoked ticket and recovers on reload.292317 let serverUrlOverride: string | undefined;293318 const popSink = activeSink();294- if (connect && popSink?.pop) {319+ if (effectiveConnect && popSink?.pop) {295320 const keyPair = await getOrCreatePopKeyPair(popSink.appId);296321 serverUrlOverride = await completePopExchange({297322 serverUrl: popSink.serverUrl,
@@ -305,7 +330,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
305330 secret,306331 namespace,307332 runtimeMode,308- connect,333+ effectiveConnect,309334 createBrokerIncompatibilityHandler(runtimeMode, record),310335 serverUrlOverride,311336 ),
@@ -322,8 +347,17 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
322347 await migrateLocalRows(state, db, secret, namespace);323348 }324349 state.principal = db.getAuthState().session?.user_id ?? null;325- state.serverConfigured = connect;326- startSharedFieldRuntime(state, db, connect);350+ state.serverConfigured = effectiveConnect;351+ startSharedFieldRuntime(state, db, effectiveConnect);352+ // Display-only backfill: the owner record adjudicates by fingerprint, but353+ // once the owning account's principal is known it names the owner in354+ // mismatch surfaces on other accounts.355+ if (effectiveConnect && state.principal !== null) {356+ const owner = readSyncOwner();357+ if (owner !== null && owner.fingerprint === namespace && owner.user_id === null) {358+ recordSyncOwner({ fingerprint: namespace, user_id: state.principal });359+ }360+ }327361 state.diagnostics.storageState = "persistent-driver-open";328362 state.diagnostics.startupFailure = null;329363 state.diagnostics.clientsCreated += 1;
@@ -332,6 +366,9 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
332366 // local schema version forward (it never stamps while data is ahead).333367 schemaCompatGate.markRuntimeWritable();334368 bootProgressTracker.mark("ready");369+ // A settled boot ends any framework reload sequence; the budget restarts370+ // from zero for the next one.371+ resetReloadAttempts();335372 notifyDiagnostics(state);336373 return db;337374 }, record);
@@ -507,12 +544,29 @@ export function recreateRuntime(): Promise<LofiRuntime> {
507544 return tracked;508545 }509546 547+// Refuses a framework-driven reload once the per-tab budget is spent: the548+// refusal lands in startup diagnostics (so the recovery surface renders it)549+// and throws, so the caller's flow fails loudly instead of cycling.550+function assertReloadBudget(): void {551+ if (noteReloadAttempt().allowed) return;552+ const state = slot();553+ const failure = reloadLoopFailure(554+ readNamespaceState().mode === "managed" ? "managed" : "local",555+ );556+ recordStartupFailure(state, failure);557+ throw new RuntimeStartupError(failure);558+}559+510560 /**511561 * Fully tears down a browser persistent worker and reloads the document. Jazz512562 * alpha.53 cannot attach a second OPFS worker reliably in the same document513563 * after shutdown; navigation is the supported clean-runtime boundary.564+ * Framework reloads are budgeted per tab: a sequence of reloads that never565+ * reaches a settled boot is refused with a `reload-loop` startup failure566+ * instead of cycling.514567 */515568 export async function reloadBrowserRuntime(): Promise<never> {569+ assertReloadBudget();516570 await shutdownRuntime();517571 globalThis.location.reload();518572 return await new Promise<never>(() => {});
@@ -533,7 +587,7 @@ async function releaseRuntime(state: RuntimeSlot): Promise<void> {
533587 */534588 export function replaceRuntimePrincipal(535589 secret: string,536- options: { onSecretSaved?: () => void } = {},590+ options: { onSecretSaved?: () => void | Promise<void> } = {},537591 ): Promise<LofiRuntime> {538592 const state = slot();539593 const previous = recreationPromise;
@@ -544,9 +598,12 @@ export function replaceRuntimePrincipal(
544598 await previous?.catch(() => undefined);545599 const saveSecret = async () => {546600 await withAuthSecretLock(appId, () => BrowserAuthSecretStore.saveSecret(secret, { appId }));547- options.onSecretSaved?.();601+ await options.onSecretSaved?.();548602 };549603 if (typeof window !== "undefined" && typeof SharedWorker !== "undefined") {604+ // The budget check precedes every mutation: a refused reload leaves the605+ // current principal and runtime fully intact.606+ assertReloadBudget();550607 await releaseRuntime(state);551608 await saveSecret();552609 globalThis.location.reload();
M package/runtime/session.ts +191 -21
@@ -28,7 +28,26 @@ import {
2828 syncElected,2929 syncing,3030 } from "./config.ts";31-import { declareSinkFromTicket, parseSyncTicket, splitTicketForEnrollment } from "./data-sink.ts";31+import {32+ clearDeclaredSink,33+ type DataSinkDeclaration,34+ declareDataSink,35+ declareSinkFromTicket,36+ parseSyncTicket,37+ readDeclaredSink,38+ splitTicketForEnrollment,39+} from "./data-sink.ts";40+import type { DeviceKeyStore } from "./envelope.ts";41+import { resolveStoreStatus } from "./store-status.ts";42+import type { TicketStoreStatus } from "../schema/store.ts";43+import {44+ adjudicateSyncOwner,45+ clearSyncOwner,46+ readSyncOwnerVerdict,47+ recordSyncOwner,48+ secretFingerprint,49+ SyncOwnerError,50+} from "./sync-owner.ts";3251 import { type DevicePublicKey, exportDevicePublicKey, getOrCreatePopKeyPair } from "./pop.ts";3352 import { holdProvisionCapability } from "./provision.ts";3453 import { fromRecoveryPhrase, RecoveryError, toRecoveryPhrase } from "./recovery.ts";
@@ -47,6 +66,7 @@ import {
4766 replaceRuntimePrincipal,4867 runtimeCanReconnect,4968 runtimeRecreatedEvent,69+ updateRuntimeDiagnostics,5070 } from "./runtime.ts";5171 import { electManagedNamespace, readNamespaceState } from "./namespace-state.ts";5272
@@ -76,6 +96,12 @@ export type Session = {
7696 backedUp: boolean;7797 /** Whether writes replicate right now (available *and* elected). */7898 syncing: boolean;99+ /**100+ * Whether this boot found sync elected by a different account than the one101+ * in hand. Transport is then suppressed; the remediation is to stop sync102+ * (releasing the election) or restore the owning account.103+ */104+ syncOwnerMismatch: boolean;79105 /**80106 * Whether a passkey guards the recovery phrase on this device — revealing it81107 * then requires a user-verifying ceremony. This confirms the person is present;
@@ -178,6 +204,7 @@ export function readSession(): Session {
178204 sink: sessionSink(),179205 backedUp: syncElected(),180206 syncing: syncing(),207+ syncOwnerMismatch: readSyncOwnerVerdict().state === "foreign",181208 phraseGuarded: phraseGuarded(),182209 passkeyRecoverable: passkeyRecoverable(),183210 };
@@ -292,12 +319,18 @@ export async function revealRecoveryPhrase(): Promise<string> {
292319 * to reopen the runtime on the elected namespace; the returned promise never293320 * settles there, and the app resumes after reload. Do not put follow-up UI294321 * behind the `await` — read the post-reload session state instead.322+ *323+ * Throws {@link SyncOwnerError} when sync on this device was elected by a324+ * different account: stop sync (releasing the election) or restore the owning325+ * account first. Electing under an unclaimed election records this account as326+ * the owner.295327 */296328 export async function enableSyncBackup(): Promise<Session> {297329 // The documented no-op: without a sync location there is nothing to sync298330 // to, and electing the managed namespace anyway would hide the local rows299331 // behind an empty database on every later boot.300332 if (!syncAvailable()) return readSession();333+ await assertAndRecordSyncOwnerForElection();301334 const alreadyManaged = readNamespaceState().mode === "managed";302335 setSyncElected(true);303336 if (alreadyManaged && runtimeCanReconnect()) {
@@ -313,12 +346,143 @@ export async function enableSyncBackup(): Promise<Session> {
313346 return readSession();314347 }315348 349+// The election half of the sync-owner pin: refuse to elect under an account350+// that is not the recorded owner, and claim ownership when none is recorded.351+// The secret resolver is injectable so tests adjudicate without a secret352+// store. Exported for those tests; the entry does not re-export it.353+export async function assertAndRecordSyncOwnerForElection(354+ resolveSecret: () => Promise<string> = currentSecret,355+): Promise<void> {356+ const fingerprint = await secretFingerprint(await resolveSecret());357+ const verdict = adjudicateSyncOwner(fingerprint);358+ if (verdict.state === "foreign") throw new SyncOwnerError(verdict.owner_user_id);359+ if (verdict.state === "unclaimed") {360+ // The principal may not be known yet (first election precedes the managed361+ // boot); the fingerprint adjudicates, and boot backfills the name.362+ recordSyncOwner({ fingerprint, user_id: getRuntimePrincipal() });363+ }364+}365+366+/**367+ * Stable categories for a refused ticket enrollment, relayed from the node's368+ * store preflight: `no_schema` — the store holds no deployed schema for this369+ * app, so nothing could sync; `ticket_rejected` — the node no longer accepts370+ * the ticket (revoked, or the node was reset). Both are definite answers from371+ * the node; an unreachable store is deliberately not a refusal category.372+ */373+export type SyncEnrollmentFailureCode = "no_schema" | "ticket_rejected";374+375+const enrollmentMessages: Record<SyncEnrollmentFailureCode, (scope?: string) => string> = {376+ no_schema: (scope) =>377+ "The node accepted the ticket, but its store has no schema deployed for this app, so " +378+ "nothing can sync. Enrollment was not kept. " +379+ (scope === "provision"380+ ? "Provision the store, then enroll again."381+ : "Provision the store from a provision-scoped ticket, then enroll this device again."),382+ ticket_rejected: () =>383+ "The node rejected this ticket — it was revoked or the node was reset. Enrollment was " +384+ "not kept. Get a fresh ticket from the node and enroll again.",385+};386+387+/**388+ * Thrown by {@link enrollSyncTicket} when the node's store preflight refuses389+ * the enrollment: the sink declaration is rolled back, no provision390+ * capability is held, and sync is not elected, so the device stays exactly as391+ * it was before the attempt. The message is user-presentable and names the392+ * remediation for its {@link SyncEnrollmentFailureCode}. A merely unreachable393+ * store never throws this — it enrolls with the warning recorded in runtime394+ * diagnostics (`storeStatus`) instead, because a flaky network must not block395+ * a legitimate ticket.396+ */397+export class SyncEnrollmentError extends Error {398+ /** Stable error class name for diagnostics and UI boundaries. */399+ override readonly name = "SyncEnrollmentError";400+ /** The preflight answer that refused the enrollment. */401+ readonly code: SyncEnrollmentFailureCode;402+403+ /** Creates the refusal with a remediation message aware of the ticket's scope. */404+ constructor(code: SyncEnrollmentFailureCode, scope?: "sync" | "provision") {405+ super(enrollmentMessages[code](scope));406+ this.code = code;407+ }408+}409+410+/** True when an error is a refused ticket enrollment (see {@link SyncEnrollmentError}). */411+export function isSyncEnrollmentError(error: unknown): error is SyncEnrollmentError {412+ return error instanceof SyncEnrollmentError;413+}414+316415 /** Options for {@link enrollSyncTicket}. */317416 export type EnrollSyncTicketOptions = {318417 /** Fetch implementation for the scope-down exchange (tests inject one). */319418 fetcher?: typeof fetch;320419 };321420 421+// Injectable dependencies for the enrollment flow; production callers go422+// through enrollSyncTicket, tests drive this directly.423+type TicketEnrollmentDeps = {424+ fetcher?: typeof fetch;425+ keyStore?: DeviceKeyStore;426+ preflight?: (ticketUrl: string) => Promise<TicketStoreStatus>;427+ timeoutMs?: number;428+ elect?: () => Promise<Session>;429+};430+431+// The enrollment flow behind enrollSyncTicket, with its network, key-custody,432+// and election dependencies injectable. Exported for tests; the entry does433+// not re-export it.434+export async function performTicketEnrollment(435+ ticket: string,436+ deps: TicketEnrollmentDeps = {},437+): Promise<Session> {438+ // Only a provision-scoped ticket reaches the scope-down exchange, so only439+ // then is there a binding to offer.440+ let devicePublicKey: DevicePublicKey | undefined;441+ const parsed = parseSyncTicket(ticket);442+ if (parsed?.scope === "provision") {443+ try {444+ devicePublicKey = await exportDevicePublicKey(await getOrCreatePopKeyPair(parsed.appId));445+ } catch {446+ // No usable key custody in this context; the exchange still derives a447+ // ticket, held as a bearer credential exactly as before.448+ }449+ }450+ const split = await splitTicketForEnrollment(ticket, deps.fetcher, devicePublicKey);451+ const previous = readDeclaredSink();452+ const declared = await declareSinkFromTicket(split.sinkTicket, deps.keyStore, split.pop);453+ // The preflight decides whether enrollment is kept: a store that answers454+ // with a definite refusal rolls the declaration back before anything else455+ // (election, provision custody) observes it. An unreachable store is not a456+ // refusal — flaky networks must not block enrollment — so it enrolls with457+ // the warning recorded where status surfaces read it.458+ const status = await resolveStoreStatus({459+ connect: true,460+ sink: { serverUrl: declared.serverUrl },461+ ...(deps.preflight ? { preflight: deps.preflight } : {}),462+ ...(deps.timeoutMs !== undefined ? { timeoutMs: deps.timeoutMs } : {}),463+ });464+ if (status.state === "no_schema" || status.state === "ticket_rejected") {465+ if (previous === null) clearDeclaredSink();466+ else await restorePreviousSink(previous, deps.keyStore);467+ throw new SyncEnrollmentError(status.state, declared.scope);468+ }469+ updateRuntimeDiagnostics((diagnostics) => {470+ diagnostics.storeStatus = status;471+ });472+ if (split.provisionUrl !== null) holdProvisionCapability(split.provisionUrl);473+ return await (deps.elect ?? enableSyncBackup)();474+}475+476+async function restorePreviousSink(477+ previous: DataSinkDeclaration,478+ keyStore?: DeviceKeyStore,479+): Promise<void> {480+ // declareDataSink refuses to replace a *different* sink; enrollment over an481+ // existing declaration can only have re-declared the same store (or thrown482+ // before reaching the preflight), so restoring the snapshot always passes.483+ await declareDataSink(previous, keyStore);484+}485+322486 /**323487 * Enrolls a `lofisync1.` app-connect ticket as this device's sync location and324488 * elects to back up and sync in one step: the ticket's URL becomes the sync
@@ -337,6 +501,14 @@ export type EnrollSyncTicketOptions = {
337501 * being a pure bearer string. Against a node without the exchange, the ticket338502 * enrolls as pasted, exactly as before.339503 *504+ * Before electing, the node's store answers a bounded metadata preflight and505+ * that answer decides whether enrollment is kept: a store with no schema for506+ * this app or a rejected ticket rolls the declaration back and throws507+ * {@link SyncEnrollmentError} — the device stays exactly as it was. A store508+ * that is merely unreachable enrolls anyway, with the warning recorded in509+ * runtime diagnostics (`storeStatus`). {@link SyncOwnerError} propagates from510+ * the election when sync on this device belongs to a different account.511+ *340512 * Enrollment ends by electing sync, so the {@link enableSyncBackup} reload341513 * caveat applies: on `SharedWorker`-capable browsers the page reloads and the342514 * returned promise never settles.
@@ -345,33 +517,27 @@ export async function enrollSyncTicket(
345517 ticket: string,346518 options: EnrollSyncTicketOptions = {},347519 ): Promise<Session> {348- // Only a provision-scoped ticket reaches the scope-down exchange, so only349- // then is there a binding to offer.350- let devicePublicKey: DevicePublicKey | undefined;351- const parsed = parseSyncTicket(ticket);352- if (parsed?.scope === "provision") {353- try {354- devicePublicKey = await exportDevicePublicKey(await getOrCreatePopKeyPair(parsed.appId));355- } catch {356- // No usable key custody in this context; the exchange still derives a357- // ticket, held as a bearer credential exactly as before.358- }359- }360- const split = await splitTicketForEnrollment(ticket, options.fetcher, devicePublicKey);361- await declareSinkFromTicket(split.sinkTicket, undefined, split.pop);362- if (split.provisionUrl !== null) holdProvisionCapability(split.provisionUrl);363- return await enableSyncBackup();520+ return await performTicketEnrollment(ticket, { fetcher: options.fetcher });364521 }365522 366523 /**367524 * Stops replicating this account to the server and returns to local-only. The368525 * local data and the account are untouched — this only detaches the network, so369- * electing to sync again resumes against the same account.526+ * electing to sync again resumes against the same account. Also releases the527+ * sync-owner pin, so a different account on this device may elect afterwards.528+ * Safe to call whatever state the transport is in: a runtime that was created529+ * without a configured server has nothing to detach and none is forced open.370530 */371531 export async function stopSyncBackup(): Promise<Session> {372- const runtime = await getRuntime();373532 setSyncElected(false);374- await runtime.db.disconnect();533+ clearSyncOwner();534+ // Only a transport that was actually configured can disconnect: a runtime535+ // created local-only (a stale tab, an unopenable sink) has nothing to536+ // detach, and the vendor client throws when asked to. The election above is537+ // already cleared either way, which is the state stop-sync promises.538+ if (runtimeCanReconnect()) {539+ await (await getRuntime()).db.disconnect();540+ }375541 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));376542 return readSession();377543 }
@@ -414,13 +580,17 @@ async function replaceAccountSecret(
414580 }415581 onConfirmed?.();416582 await replaceRuntimePrincipal(secret, {417- onSecretSaved: () => {583+ onSecretSaved: async () => {418584 // Commit election only after the restored secret is durably saved: a419585 // replacement that fails earlier leaves the device booting its intact420586 // local account instead of an empty managed namespace.421587 if (!syncAvailable()) return;422588 setSyncElected(true);423589 electManagedNamespace({ migrateLocalRows: false });590+ // An explicit restore ceremony adopts sync ownership: the restored591+ // account is who the user chose to sync as. The principal's name is592+ // backfilled by the first managed boot.593+ recordSyncOwner({ fingerprint: await secretFingerprint(secret), user_id: null });424594 },425595 });426596 globalThis.dispatchEvent(new Event(runtimeRecreatedEvent));
M package/runtime/session_test.ts +267 -3
@@ -1,7 +1,33 @@
1-// Package contract tests for account-session election behavior.2-import { createBackupPasskey, enableSyncBackup } from "./session.ts";1+// Package contract tests for account-session election behavior: the no-op2+// guard, phrase-guard failure handling, the stop-sync transport guard, the3+// enrollment preflight policy, and the sync-owner election guard.4+import {5+ assertAndRecordSyncOwnerForElection,6+ createBackupPasskey,7+ enableSyncBackup,8+ isSyncEnrollmentError,9+ performTicketEnrollment,10+ stopSyncBackup,11+} from "./session.ts";312 import { readNamespaceState } from "./namespace-state.ts";4-import { appId, syncAvailable } from "./config.ts";13+import { appId, setSyncElected, syncAvailable, syncElected } from "./config.ts";14+import {15+ anchorAppId,16+ clearDeclaredSink,17+ declareSinkFromTicket,18+ readDeclaredSink,19+} from "./data-sink.ts";20+import { memoryDeviceKeyStore } from "./envelope.ts";21+import { lockProvisionCapability, provisionCapabilityStatus } from "./provision.ts";22+import { getRuntimeDiagnostics, runtimeRecreatedEvent } from "./runtime.ts";23+import type { TicketStoreStatus } from "../schema/store.ts";24+import {25+ clearSyncOwner,26+ isSyncOwnerError,27+ readSyncOwner,28+ recordSyncOwner,29+ secretFingerprint,30+} from "./sync-owner.ts";531 import { defineLofiApp } from "./app.ts";632 import { assert } from "./test-assert.ts";733
@@ -48,6 +74,244 @@ test("enabling sync without a sync location is the documented no-op", async () =
4874 }4975 });5076 77+const sinkKey = `lofi:data-sink:${anchorAppId}`;78+const ownerKey = `lofi:sync-owner:${anchorAppId}`;79+80+function withCleanSyncState(body: () => void | Promise<void>): () => Promise<void> {81+ return async () => {82+ const keys = [...electionKeys, sinkKey, ownerKey];83+ const reset = () => {84+ for (const key of keys) localStorage.removeItem(key);85+ clearDeclaredSink();86+ clearSyncOwner();87+ lockProvisionCapability();88+ };89+ reset();90+ try {91+ await body();92+ } finally {93+ reset();94+ }95+ };96+}97+98+// A ticket encoded exactly as lofi-node's encodeAppTicket does (the format99+// contract is pinned in data-sink_test.ts; this fixture only needs a valid100+// instance).101+const ticketSecret = "s".repeat(43);102+const ticketAppId = "cfe52e44-7a59-4232-8dbb-bf53f27aeed6";103+function encodeTicket(payload: unknown): string {104+ const json = JSON.stringify(payload);105+ return "lofisync1." +106+ btoa(json).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");107+}108+const syncTicket = encodeTicket({109+ v: 1,110+ appId: ticketAppId,111+ url: `http://192.168.1.10:4802/t/${ticketSecret}`,112+ label: "phone",113+});114+const provisionTicket = encodeTicket({115+ v: 1,116+ appId: ticketAppId,117+ url: `http://192.168.1.10:4802/t/${ticketSecret}`,118+ scope: "provision",119+ label: "phone",120+});121+const derivedTicket = encodeTicket({122+ v: 1,123+ appId: ticketAppId,124+ url: `http://192.168.1.10:4802/t/${"d".repeat(43)}`,125+ scope: "sync",126+ label: "phone (sync)",127+});128+129+const answering = (state: TicketStoreStatus["state"]) => (): Promise<TicketStoreStatus> =>130+ Promise.resolve(131+ state === "deployed"132+ ? { state, appId: ticketAppId, headHash: "abc123" }133+ : { state } as TicketStoreStatus,134+ );135+136+test(137+ "stopping sync without a configured transport resolves and clears the election",138+ withCleanSyncState(async () => {139+ // The stale-tab shape: election and owner persisted, but this document's140+ // runtime never configured a server, so there is no transport to detach.141+ await declareSinkFromTicket(syncTicket, memoryDeviceKeyStore());142+ setSyncElected(true);143+ recordSyncOwner({ fingerprint: await secretFingerprint("secret-a"), user_id: "user-1" });144+ let recreations = 0;145+ const onRecreated = () => void (recreations += 1);146+ globalThis.addEventListener(runtimeRecreatedEvent, onRecreated);147+ try {148+ const session = await stopSyncBackup();149+ assert(!session.backedUp, "stop-sync must clear the election");150+ assert(!syncElected(), "the election flag must be cleared");151+ assert(readSyncOwner() === null, "stop-sync must release the sync-owner pin");152+ assert(recreations === 1, "stop-sync must announce the state change");153+ } finally {154+ globalThis.removeEventListener(runtimeRecreatedEvent, onRecreated);155+ }156+ }),157+);158+159+test(160+ "a no_schema preflight refuses enrollment and rolls the sink back",161+ withCleanSyncState(async () => {162+ let elected = 0;163+ let thrown: unknown;164+ try {165+ await performTicketEnrollment(syncTicket, {166+ keyStore: memoryDeviceKeyStore(),167+ preflight: answering("no_schema"),168+ elect: () => {169+ elected += 1;170+ return Promise.reject(new Error("elect must not run"));171+ },172+ });173+ } catch (error) {174+ thrown = error;175+ }176+ assert(177+ isSyncEnrollmentError(thrown) && thrown.code === "no_schema",178+ "the refusal must be the typed enrollment error",179+ );180+ assert(readDeclaredSink() === null, "the sink declaration must be rolled back");181+ assert(elected === 0, "sync must not be elected on a refused enrollment");182+ assert(!syncElected(), "no election flag may be written");183+ }),184+);185+186+test(187+ "a ticket_rejected preflight refuses a provision enrollment and holds nothing",188+ withCleanSyncState(async () => {189+ const fetcher: typeof fetch = () =>190+ Promise.resolve(191+ new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),192+ );193+ let thrown: unknown;194+ try {195+ await performTicketEnrollment(provisionTicket, {196+ fetcher,197+ keyStore: memoryDeviceKeyStore(),198+ preflight: answering("ticket_rejected"),199+ elect: () => Promise.reject(new Error("elect must not run")),200+ });201+ } catch (error) {202+ thrown = error;203+ }204+ assert(205+ isSyncEnrollmentError(thrown) && thrown.code === "ticket_rejected",206+ "the refusal must be the typed enrollment error",207+ );208+ assert(readDeclaredSink() === null, "the derived sink must be rolled back");209+ assert(210+ !provisionCapabilityStatus().held,211+ "the provision capability must not be held after a refused enrollment",212+ );213+ }),214+);215+216+test(217+ "a refused re-enrollment restores the previously declared sink",218+ withCleanSyncState(async () => {219+ const keyStore = memoryDeviceKeyStore();220+ await declareSinkFromTicket(syncTicket, keyStore);221+ let thrown: unknown;222+ try {223+ await performTicketEnrollment(syncTicket, {224+ keyStore,225+ preflight: answering("no_schema"),226+ elect: () => Promise.reject(new Error("elect must not run")),227+ });228+ } catch (error) {229+ thrown = error;230+ }231+ assert(isSyncEnrollmentError(thrown), "the refusal must still throw");232+ const sink = readDeclaredSink();233+ assert(234+ sink !== null && sink.serverUrl === `http://192.168.1.10:4802/t/${ticketSecret}`,235+ "the previous declaration must survive the refused re-enrollment",236+ );237+ }),238+);239+240+test(241+ "an unreachable store enrolls anyway and records the warning",242+ withCleanSyncState(async () => {243+ let elected = 0;244+ await performTicketEnrollment(syncTicket, {245+ keyStore: memoryDeviceKeyStore(),246+ preflight: answering("store_unavailable"),247+ elect: () => {248+ elected += 1;249+ return Promise.resolve(undefined as never);250+ },251+ });252+ assert(elected === 1, "a merely unreachable store must not block enrollment");253+ assert(readDeclaredSink() !== null, "the sink declaration must be kept");254+ assert(255+ getRuntimeDiagnostics().storeStatus.state === "store_unavailable",256+ "the warning must land in runtime diagnostics for status surfaces",257+ );258+ }),259+);260+261+test(262+ "a deployed store enrolls a provision ticket and holds the capability",263+ withCleanSyncState(async () => {264+ const fetcher: typeof fetch = () =>265+ Promise.resolve(266+ new Response(JSON.stringify({ v: 1, id: "abc", ticket: derivedTicket }), { status: 200 }),267+ );268+ let elected = 0;269+ await performTicketEnrollment(provisionTicket, {270+ fetcher,271+ keyStore: memoryDeviceKeyStore(),272+ preflight: answering("deployed"),273+ elect: () => {274+ elected += 1;275+ return Promise.resolve(undefined as never);276+ },277+ });278+ assert(elected === 1, "a deployed store must elect sync");279+ const sink = readDeclaredSink();280+ assert(281+ sink !== null && sink.serverUrl === `http://192.168.1.10:4802/t/${"d".repeat(43)}`,282+ "the derived sync ticket must be the declared sink",283+ );284+ assert(285+ provisionCapabilityStatus().held,286+ "the provision capability must be held after a kept enrollment",287+ );288+ }),289+);290+291+test(292+ "electing under a foreign owner is refused; an unclaimed election records the owner",293+ withCleanSyncState(async () => {294+ recordSyncOwner({ fingerprint: await secretFingerprint("owner-secret"), user_id: "user-1" });295+ let thrown: unknown;296+ try {297+ await assertAndRecordSyncOwnerForElection(() => Promise.resolve("other-secret"));298+ } catch (error) {299+ thrown = error;300+ }301+ assert(302+ isSyncOwnerError(thrown) && thrown.owner_user_id === "user-1",303+ "a foreign account must be refused, naming the owner",304+ );305+ clearSyncOwner();306+ await assertAndRecordSyncOwnerForElection(() => Promise.resolve("other-secret"));307+ const record = readSyncOwner();308+ assert(309+ record !== null && record.fingerprint === await secretFingerprint("other-secret"),310+ "an unclaimed election must record the electing account as owner",311+ );312+ }),313+);314+51315 // In Deno there is no `location`, so enrollment fails with `origin-rejected`52316 // before any WebAuthn surface is touched — exactly the failure class whose53317 // guard handling these tests pin down.
M package/runtime/startup-recovery.ts +10 -0
@@ -9,6 +9,7 @@ import { DurableStorageUnsupportedError } from "./device-capabilities.ts";
99 export type RuntimeStartupFailureCode =1010 | "broker-incompatible"1111 | "configuration-error"12+ | "reload-loop"1213 | "storage-startup-failed"1314 | "unsupported-capabilities";1415
@@ -24,6 +25,8 @@ const startupMessages: Record<RuntimeStartupFailureCode, string> = {
2425 "Another tab for this app is running an incompatible version. Close every other app tab, then reload this tab.",2526 "configuration-error":2627 "The Lofi runtime configuration is invalid. Run deno task doctor, fix the first blocker, then reload.",28+ "reload-loop":29+ "The app kept reloading without reaching a working runtime, so automatic reloads stopped. Check the device report's account and sync state, then reload manually.",2730 "storage-startup-failed":2831 "Durable storage could not start. Run deno task doctor, fix the first blocker, then reload.",2932 "unsupported-capabilities":
@@ -52,6 +55,13 @@ export function isRuntimeStartupError(error: unknown): error is RuntimeStartupEr
5255 return error instanceof RuntimeStartupError;5356 }5457 58+/** Builds the stable failure record for a refused framework-driven reload. */59+export function reloadLoopFailure(60+ runtimeMode: RuntimeStartupFailure["runtimeMode"],61+): RuntimeStartupFailure {62+ return { code: "reload-loop", runtimeMode, message: startupMessages["reload-loop"] };63+}64+5565 /** Maps vendor, capability, configuration, and fallback failures onto stable Lofi categories. */5666 export function classifyRuntimeStartupFailure(5767 error: unknown,
A package/runtime/sync-owner.ts +160 -0
@@ -0,0 +1,160 @@
1+/**2+ * Package-owned sync-owner pin: the per-device record that binds the sync3+ * election to the account that made it.4+ *5+ * Every other sync key on a device (election flag, sealed sink, namespace6+ * record) is scoped to the app alone, so replacing the account secret would7+ * otherwise leave a different identity syncing into the previous owner's8+ * store. The owner record closes that hole: electing sync records the9+ * electing account's fingerprint, and a boot whose account does not match10+ * refuses to connect and surfaces the mismatch instead of merging stores.11+ *12+ * The fingerprint is the same SHA-256/8-byte derivation that names the13+ * account's storage namespace — already treated as non-secret — so the14+ * verdict is computable before any client is created and the guard closes15+ * with no window where a foreign account is briefly connected.16+ *17+ * @module18+ */19+20+import { anchorAppId } from "./data-sink.ts";21+22+const ownerKey = `lofi:sync-owner:${anchorAppId}`;23+24+/** The persisted sync-owner pin for this device. */25+export type SyncOwnerRecord = {26+ /** The owning account's secret fingerprint (namespace derivation). */27+ fingerprint: string;28+ /**29+ * The owner's sync principal for display surfaces, or `null` until the30+ * first successful managed boot backfills it. Never used for adjudication.31+ */32+ user_id: string | null;33+};34+35+/**36+ * The non-secret account fingerprint: SHA-256 of the secret, first 8 bytes,37+ * lowercase hex. Byte-identical to the account's storage-namespace name.38+ */39+export async function secretFingerprint(secret: string): Promise<string> {40+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));41+ return [...new Uint8Array(digest).slice(0, 8)]42+ .map((byte) => byte.toString(16).padStart(2, "0"))43+ .join("");44+}45+46+/**47+ * The owner verdict for the account in hand. `unadjudicated` is the state48+ * before any adjudication this document; `unclaimed` means no owner record49+ * exists; `self` means the account is the owner; `foreign` means sync on this50+ * device belongs to a different account.51+ */52+export type SyncOwnerVerdict =53+ | { state: "unadjudicated" }54+ | { state: "unclaimed" }55+ | { state: "self" }56+ | { state: "foreign"; owner_user_id: string | null };57+58+let lastVerdict: SyncOwnerVerdict = { state: "unadjudicated" };59+60+/** Reads the persisted owner record, or `null` when sync is unclaimed. */61+export function readSyncOwner(): SyncOwnerRecord | null {62+ if (typeof localStorage === "undefined") return null;63+ let raw: string | null = null;64+ try {65+ raw = localStorage.getItem(ownerKey);66+ } catch {67+ return null;68+ }69+ if (!raw) return null;70+ try {71+ const record = JSON.parse(raw) as { v?: number; fingerprint?: unknown; user_id?: unknown };72+ if (record?.v !== 1 || typeof record.fingerprint !== "string" || !record.fingerprint) {73+ return null;74+ }75+ return {76+ fingerprint: record.fingerprint,77+ user_id: typeof record.user_id === "string" ? record.user_id : null,78+ };79+ } catch {80+ return null;81+ }82+}83+84+/** Persists the owner record. A private-mode storage failure is non-fatal. */85+export function recordSyncOwner(record: SyncOwnerRecord): void {86+ lastVerdict = { state: "self" };87+ if (typeof localStorage === "undefined") return;88+ try {89+ localStorage.setItem(90+ ownerKey,91+ JSON.stringify({ v: 1, fingerprint: record.fingerprint, user_id: record.user_id }),92+ );93+ } catch {94+ // The pin then lasts for this document only; the election it accompanies95+ // is equally document-scoped in that context.96+ }97+}98+99+/** Removes the owner record — the stop-sync half of the pin's lifecycle. */100+export function clearSyncOwner(): void {101+ lastVerdict = { state: "unadjudicated" };102+ if (typeof localStorage === "undefined") return;103+ try {104+ localStorage.removeItem(ownerKey);105+ } catch {106+ // A private-mode storage failure leaves the previous record in place.107+ }108+}109+110+/** Adjudicates a fingerprint against the persisted owner record. */111+export function adjudicateSyncOwner(fingerprint: string): SyncOwnerVerdict {112+ const record = readSyncOwner();113+ lastVerdict = record === null114+ ? { state: "unclaimed" }115+ : record.fingerprint === fingerprint116+ ? { state: "self" }117+ : { state: "foreign", owner_user_id: record.user_id };118+ return lastVerdict;119+}120+121+/** The most recent adjudication this document, for synchronous session reads. */122+export function readSyncOwnerVerdict(): SyncOwnerVerdict {123+ return lastVerdict;124+}125+126+/**127+ * Thrown by `enableSyncBackup` (and through it `enrollSyncTicket`) when sync128+ * on this device was elected by a different account than the one in hand. The129+ * message is user-presentable and names the remediation: stop sync (which130+ * releases the pin) or restore the owning account, then elect again. The same131+ * mismatch found at boot never throws — the runtime opens with transport132+ * suppressed and reports it through the session's `syncOwnerMismatch` flag133+ * and the `syncOwner` runtime diagnostic instead, so local work continues.134+ */135+export class SyncOwnerError extends Error {136+ /** Stable error class name for diagnostics and UI boundaries. */137+ override readonly name = "SyncOwnerError";138+ /** Stable category for user-facing branches. */139+ readonly code = "owner-mismatch";140+ /**141+ * The owning account's sync principal for display surfaces, or `null` when142+ * the owner elected before its first managed boot named it. Matches the143+ * `user_id` vocabulary the session relays.144+ */145+ readonly owner_user_id: string | null;146+147+ /** Creates the non-secret refusal, naming the owning account when known. */148+ constructor(owner_user_id: string | null) {149+ super(150+ "Sync on this device was set up by a different account. Stop syncing to release it, " +151+ "or restore the owning account, then enable sync again.",152+ );153+ this.owner_user_id = owner_user_id;154+ }155+}156+157+/** True when an error is the sync-owner refusal (see {@link SyncOwnerError}). */158+export function isSyncOwnerError(error: unknown): error is SyncOwnerError {159+ return error instanceof SyncOwnerError;160+}
A package/runtime/sync-owner_test.ts +109 -0
@@ -0,0 +1,109 @@
1+// Package contract tests for the sync-owner pin: fingerprint derivation,2+// record round-trip, and the adjudication verdicts the election guard and3+// session snapshot read.4+import { anchorAppId } from "./data-sink.ts";5+import {6+ adjudicateSyncOwner,7+ clearSyncOwner,8+ isSyncOwnerError,9+ readSyncOwner,10+ readSyncOwnerVerdict,11+ recordSyncOwner,12+ secretFingerprint,13+ SyncOwnerError,14+} from "./sync-owner.ts";15+import { assert } from "./test-assert.ts";16+17+const test = (globalThis as unknown as {18+ Deno: { test(name: string, body: () => void | Promise<void>): void };19+}).Deno.test;20+21+const ownerKey = `lofi:sync-owner:${anchorAppId}`;22+23+function withCleanState(body: () => void | Promise<void>): () => Promise<void> {24+ return async () => {25+ localStorage.removeItem(ownerKey);26+ clearSyncOwner();27+ try {28+ await body();29+ } finally {30+ localStorage.removeItem(ownerKey);31+ clearSyncOwner();32+ }33+ };34+}35+36+test("the fingerprint is deterministic, non-empty, and secret-sensitive", async () => {37+ const first = await secretFingerprint("secret-a");38+ assert(first === await secretFingerprint("secret-a"), "one secret must map to one fingerprint");39+ assert(/^[0-9a-f]{16}$/.test(first), "the fingerprint is 8 bytes of lowercase hex");40+ assert(41+ first !== await secretFingerprint("secret-b"),42+ "different secrets must map to different fingerprints",43+ );44+});45+46+test(47+ "the owner record round-trips and clears",48+ withCleanState(() => {49+ assert(readSyncOwner() === null, "a clean device has no owner record");50+ recordSyncOwner({ fingerprint: "aabbccddeeff0011", user_id: "user-1" });51+ const record = readSyncOwner();52+ assert(53+ record !== null && record.fingerprint === "aabbccddeeff0011" && record.user_id === "user-1",54+ "the persisted record must read back verbatim",55+ );56+ clearSyncOwner();57+ assert(readSyncOwner() === null, "clearing must remove the record");58+ }),59+);60+61+test(62+ "a malformed persisted record reads as unclaimed",63+ withCleanState(() => {64+ localStorage.setItem(ownerKey, "not json");65+ assert(readSyncOwner() === null, "garbage must not adjudicate");66+ localStorage.setItem(ownerKey, JSON.stringify({ v: 1, fingerprint: "" }));67+ assert(readSyncOwner() === null, "an empty fingerprint must not adjudicate");68+ localStorage.setItem(ownerKey, JSON.stringify({ v: 99, fingerprint: "aabbccddeeff0011" }));69+ assert(readSyncOwner() === null, "an unknown record version must not adjudicate");70+ }),71+);72+73+test(74+ "adjudication answers unclaimed, self, and foreign, and the verdict is cached",75+ withCleanState(() => {76+ assert(77+ readSyncOwnerVerdict().state === "unadjudicated",78+ "before any adjudication the verdict is unadjudicated",79+ );80+ assert(adjudicateSyncOwner("aabbccddeeff0011").state === "unclaimed", "no record → unclaimed");81+ recordSyncOwner({ fingerprint: "aabbccddeeff0011", user_id: "user-1" });82+ assert(83+ adjudicateSyncOwner("aabbccddeeff0011").state === "self",84+ "the recorded fingerprint adjudicates as self",85+ );86+ const foreign = adjudicateSyncOwner("1122334455667788");87+ assert(88+ foreign.state === "foreign" && foreign.owner_user_id === "user-1",89+ "a different fingerprint adjudicates as foreign, naming the owner",90+ );91+ assert(92+ readSyncOwnerVerdict().state === "foreign",93+ "the cached verdict must reflect the latest adjudication",94+ );95+ clearSyncOwner();96+ assert(97+ readSyncOwnerVerdict().state === "unadjudicated",98+ "clearing the pin resets the cached verdict",99+ );100+ }),101+);102+103+test("the owner error carries its code and the owning account", () => {104+ const error = new SyncOwnerError("user-1");105+ assert(isSyncOwnerError(error), "the guard must recognize its own error");106+ assert(error.code === "owner-mismatch", "the error carries its stable code");107+ assert(error.owner_user_id === "user-1", "the error names the owning account");108+ assert(!isSyncOwnerError(new Error("other")), "foreign errors are not owner errors");109+});