A persisted sync declaration whose device key is gone restored as local-only with no signal, unlike a rejected ticket. The runtime now remembers how the last restore resolved, and DeviceStatus renders the unopenable state with the same recovery guidance: clear the sync location and enroll a fresh ticket.
- commit
- eb6fdba0ccb5424a1ddcea1095cf44c3799b81b5
- parent
- 020549cc84a0888280720a827b9a54320ce06504
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | package/ | 12 | ++++++++++++ |
| M | package/ | 24 | ++++++++++++++++++++++++ |
| M | package/ | 19 | +++++++++++++++++++ |
| M | package/ | 1 | + |
@@ -14,6 +14,7 @@ import { describeSchemaCompat } from "../runtime/schema-compat.ts";
1414 import { describeStorageFork, dismissStorageFork } from "../runtime/storage-fork.ts";1515 import { useStorageFork } from "./use-storage-fork.ts";1616 import { readSession, type Session } from "../runtime/session.ts";17+import { readSinkRestoreOutcome } from "../runtime/data-sink.ts";1718 import { describeStoreStatus } from "../runtime/store-status.ts";1819 import { RuntimeRecovery } from "./RuntimeRecovery.tsx";1920 @@ -71,10 +72,13 @@ export default function DeviceStatus(): VNode {
7172 if (!report) return <p class="device-status">Checking device capabilities…</p>;7273 7374 const synced = Boolean(session?.syncAvailable);75+ const sinkUnopenable = !session?.sink && readSinkRestoreOutcome() === "unopenable";7476 const sinkState = session?.sink7577 ? session.sink.source === "declared"7678 ? `declared — ${session.sink.label ?? session.sink.host}`7779 : "configured (build default)"80+ : sinkUnopenable81+ ? "declared — record unopenable on this device"7882 : "not configured";7983 const syncState = session?.syncing8084 ? "syncing to your account"@@ -146,6 +150,14 @@ export default function DeviceStatus(): VNode {
146150 ticket; local data is intact and pushes up on re-enrollment.147151 </p>148152 )}153+ {sinkUnopenable && (154+ <p>155+ A sync location is declared on this device, but its sealed record cannot be opened — the156+ device key that sealed it is gone, usually a cleared browser store. The device runs157+ local-only until re-enrollment. Clear the sync location and enroll a fresh ticket; local158+ data is intact and pushes up.159+ </p>160+ )}149161 {!synced && (150162 <p>151163 Set <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in <code>.env</code>{" "}@@ -164,6 +164,21 @@ async function persistSealed(
164164 */165165 export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable";166166 167+// The most recent restore's outcome, kept so status surfaces can distinguish168+// "no sync location" from "a sync location exists but this device cannot open169+// it". Cleared when the record is removed or successfully re-declared.170+let lastRestoreOutcome: SinkRestoreOutcome = "none";171+172+/**173+ * How the most recent {@link restoreDeclaredSink} resolved. An `unopenable`174+ * answer means a declaration is persisted but no available key opens it — the175+ * device runs local-only until the sink is cleared and re-enrolled, and a176+ * status surface should say so rather than showing plain local-only.177+ */178+export function readSinkRestoreOutcome(): SinkRestoreOutcome {179+ return lastRestoreOutcome;180+}181+167182 /**168183 * Unseals the persisted declaration into memory. Boot awaits this before any169184 * sync decision (see `boot.ts`); tests and non-boot embedders call it@@ -172,6 +187,11 @@ export type SinkRestoreOutcome = "none" | "restored" | "migrated" | "unopenable"
172187 export async function restoreDeclaredSink(173188 keyStore: DeviceKeyStore = defaultDeviceKeyStore(),174189 ): Promise<SinkRestoreOutcome> {190+ lastRestoreOutcome = await resolveRestore(keyStore);191+ return lastRestoreOutcome;192+}193+194+async function resolveRestore(keyStore: DeviceKeyStore): Promise<SinkRestoreOutcome> {175195 cachedSink = null;176196 if (typeof localStorage === "undefined") return "none";177197 let raw: string | null = null;@@ -276,12 +296,16 @@ export async function declareDataSink(
276296 }277297 await persistSealed(declaration, keyStore);278298 cachedSink = declaration;299+ // The record now seals under an available key; an earlier unopenable300+ // restore no longer describes it.301+ lastRestoreOutcome = "restored";279302 return declaration;280303 }281304 282305 /** Removes the declared sink. Existing local data and elections are untouched. */283306 export function clearDeclaredSink(): void {284307 cachedSink = null;308+ lastRestoreOutcome = "none";285309 if (typeof localStorage === "undefined") return;286310 try {287311 localStorage.removeItem(sinkKey);@@ -11,6 +11,7 @@ import {
1111 isDataSinkError,1212 parseSyncTicket,1313 readDeclaredSink,14+ readSinkRestoreOutcome,1415 restoreDeclaredSink,1516 splitTicketForEnrollment,1617 } from "./data-sink.ts";@@ -152,7 +153,25 @@ test(
152153 );153154 assert(readDeclaredSink() === null, "an unopenable record must read as absent");154155 assert(localStorage.getItem(sinkKey) === stored, "an unopenable record must be left intact");156+ assert(157+ readSinkRestoreOutcome() === "unopenable",158+ "status surfaces must be able to distinguish unopenable from no sink at all",159+ );155160 assert(await restoreDeclaredSink() === "restored", "the right key must still open it");161+ assert(readSinkRestoreOutcome() === "restored", "the reader must track the latest restore");162+ await restoreDeclaredSink(memoryDeviceKeyStore());163+ clearDeclaredSink();164+ assert(165+ readSinkRestoreOutcome() === "none",166+ "clearing the sink must retire the unopenable outcome — re-enrollment starts clean",167+ );168+ await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });169+ await restoreDeclaredSink(memoryDeviceKeyStore());170+ await declareDataSink({ appId: ticketAppId, serverUrl: "https://node.example:4802" });171+ assert(172+ readSinkRestoreOutcome() === "restored",173+ "a successful re-declaration must supersede an unopenable outcome",174+ );156175 }),157176 );158177