clone

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

feat/184-task-notice-migration
lofi / package / runtime / sync-owner.ts
sync-owner.ts 5.9K
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 */1920import { anchorAppId } from "./data-sink.ts";2122const ownerKey = `lofi:sync-owner:${anchorAppId}`;2324/** The persisted sync-owner pin for this device. */25export 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};3435/**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 */39export 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}4546/**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 */52export type SyncOwnerVerdict =53 | { state: "unadjudicated" }54 | { state: "unclaimed" }55 | { state: "self" }56 | { state: "foreign"; owner_user_id: string | null };5758let lastVerdict: SyncOwnerVerdict = { state: "unadjudicated" };5960/** Reads the persisted owner record, or `null` when sync is unclaimed. */61export 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}8384/** Persists the owner record. A private-mode storage failure is non-fatal. */85export 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}9899/** Removes the owner record — the stop-sync half of the pin's lifecycle. */100export 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}109110/** Adjudicates a fingerprint against the persisted owner record. */111export 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}120121/** The most recent adjudication this document, for synchronous session reads. */122export function readSyncOwnerVerdict(): SyncOwnerVerdict {123 return lastVerdict;124}125126/**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 */135export 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;146147 /** 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}156157/** True when an error is the sync-owner refusal (see {@link SyncOwnerError}). */158export function isSyncOwnerError(error: unknown): error is SyncOwnerError {159 return error instanceof SyncOwnerError;160}