The template half of #175, following the runtime half in #180. DeviceStatus names why nothing is syncing, not merely that it is not: the one-line Data sync verdict distinguishes owner mismatch, store refusals (no schema, rejected ticket), an unopenable sink record, and the absence of any sync location, with owner-mismatch copy naming the owning account when known. The no-sync hint now leads with ticket enrollment; the compiled-in env-var path is the stated alternative. TicketEnrollForm relays typed refusals verbatim — SyncEnrollmentError, SyncOwnerError, and DataSinkError carry their own remediation, and the generic paste-again line cannot fix an unprovisioned store or a foreign sync owner. AccountGate re-reads the session when the runtime is recreated, gains the owner-mismatch branch offering exactly the two remediations (stop sync releases the election, restore adopts the owner), and surfaces a definite store problem in the backed-up state. Starter mirror and snapshot regenerated.
- commit
- 0b6a3ec370e662cbe2eb5188c01fb10391b3cf18
- parent
- 5409df2b75da162f38dcd46c7f87929dc2dfe840
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | apps/ | 68 | ++++++++++++++++++++++++++++++- |
| M | package/ | 62 | +++++++++++++++++++++++---- |
| A | package/ | 47 | +++++++++++++++++++++ |
| M | package/ | 24 | ++++++++-- |
| M | package/ | 31 | +++++++++++++- |
| M | package/ | 68 | ++++++++++++++++++++++++++++++- |
| M | package/ | 2 | +- |
@@ -3,17 +3,22 @@ import {
33 confirmPhraseAccess,44 createBackupPasskey,55 createRecoverablePasskeyBackup,6+ describeStoreStatus,67 enableSyncBackup,8+ getRuntimeDiagnostics,79 isAccountReplacementError,810 isAuthError,911 isRecoverablePasskeyError,1012 isRecoveryError,13+ isSyncOwnerError,1114 readAccountSession,1215 restoreFromPasskey,1316 restoreFromRecoveryPhrase,1417 revealRecoveryPhrase,18+ runtimeRecreatedEvent,1519 type Session,1620 stopSyncBackup,21+ subscribeRuntimeDiagnostics,1722 } from "@nzip/lofi";1823 import { encodeSharingIdentity } from "@nzip/lofi/access";1924 import { TicketEnrollForm } from "@nzip/lofi/preact";@@ -54,6 +59,7 @@ function describe(error: unknown): string {
5459 }5560 if (isRecoveryError(error)) return error.message;5661 if (isRecoverablePasskeyError(error) || isAccountReplacementError(error)) return error.message;62+ if (isSyncOwnerError(error)) return error.message;5763 return error instanceof Error ? error.message : String(error);5864 }5965 @@ -74,9 +80,19 @@ export default function AccountGate() {
7480 // so the phrase block can say so honestly rather than imply a confirmation.7581 const [unguarded, setUnguarded] = useState(false);7682 83+ const [diagnostics, setDiagnostics] = useState(getRuntimeDiagnostics());84+7785 useEffect(() => {78- void readAccountSession().then(setSession, (cause) => setError(describe(cause)));86+ const refresh = () =>87+ void readAccountSession().then(setSession, (cause) => setError(describe(cause)));88+ refresh();89+ // Electing sync, stopping it, or restoring an account recreates the90+ // runtime; a snapshot read once on mount would keep rendering the old91+ // account state over the new reality.92+ globalThis.addEventListener(runtimeRecreatedEvent, refresh);93+ return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);7994 }, []);95+ useEffect(() => subscribeRuntimeDiagnostics(() => setDiagnostics(getRuntimeDiagnostics())), []);8096 8197 // Every account action funnels through here: one busy flag, one error line,8298 // and any returned Session becomes the new snapshot.@@ -182,6 +198,41 @@ export default function AccountGate() {
182198 </>183199 );184200 201+ // Sync on this device was elected by a different account: the runtime booted202+ // with transport suppressed so neither store can merge into the other. The203+ // two remediations are exactly the actions offered here — stop syncing204+ // releases the election for the current account, restore adopts the owner.205+ if (session.syncOwnerMismatch) {206+ const owner = diagnostics.syncOwner.state === "mismatch"207+ ? diagnostics.syncOwner.owner_user_id208+ : null;209+ return (210+ <section class="account account-out" aria-labelledby="account-title">211+ <header>212+ <p class="eyebrow">Account</p>213+ <h2 id="account-title">Sync paused — set up by a different account</h2>214+ </header>215+ <p>216+ Sync on this device was set up by{" "}217+ {owner ? <code>{owner}</code> : "a different account"}, so nothing connects — syncing now218+ would merge two accounts’ data. Stop syncing to release this device for the current219+ account, or restore the owning account to resume where it left off.220+ </p>221+ <div class="account-actions">222+ <button223+ type="button"224+ disabled={disabled}225+ onClick={() => run("stop", stopSyncBackup)}226+ >227+ {busy === "stop" ? "Stopping…" : "Stop syncing & release this device"}228+ </button>229+ </div>230+ {restoreBlock}231+ {error && <p class="account-error" role="alert">{error}</p>}232+ </section>233+ );234+ }235+185236 // No sync location yet: offer the connect step, with restore available so a186237 // fresh device recovers its identity before or after choosing where to sync.187238 if (!session.syncAvailable) {@@ -228,6 +279,16 @@ export default function AccountGate() {
228279 </div>229280 );230281 282+ // A definite store problem is worth a line here, not only in the device283+ // report: the account panel is where the user just acted, and each of these284+ // states means writes are not replicating despite sync being on.285+ const storeStatus = diagnostics.storeStatus;286+ const storeProblem = storeStatus.state === "no_schema" ||287+ storeStatus.state === "ticket_rejected" ||288+ storeStatus.state === "store_unavailable"289+ ? describeStoreStatus(storeStatus)290+ : null;291+231292 if (session.backedUp) {232293 return (233294 <section class="account account-in" aria-labelledby="account-title">@@ -246,6 +307,11 @@ export default function AccountGate() {
246307 Share identity: <code data-sharing-identity>{sharingIdentity}</code>247308 </p>248309 )}310+ {storeProblem && (311+ <p class="account-note" role="alert">312+ Sync location: {storeProblem}. See the device report for details.313+ </p>314+ )}249315 <div class="account-actions">250316 {!session.passkeyRecoverable && (251317 <button@@ -15,7 +15,7 @@ import { describeStorageFork, dismissStorageFork } from "../runtime/storage-fork
1515 import { useStorageFork } from "./use-storage-fork.ts";1616 import { readSession, type Session } from "../runtime/session.ts";1717 import { readSinkRestoreOutcome } from "../runtime/data-sink.ts";18-import { describeStoreStatus } from "../runtime/store-status.ts";18+import { describeStoreStatus, type RuntimeStoreStatus } from "../runtime/store-status.ts";1919 import { RuntimeRecovery } from "./RuntimeRecovery.tsx";2020 2121 // One label/value row inside a category's definition list.@@ -30,6 +30,30 @@ function Row({ label, value }: { label: string; value: string }): VNode {
3030 3131 const available = (present: boolean) => (present ? "available" : "missing");3232 33+/**34+ * The one-line Data sync verdict. The blocked dispositions are first-class —35+ * the report must say *why* nothing is syncing, not merely that it is not:36+ * the election belongs to another account, the store refused (no schema, or37+ * the ticket is no longer accepted), the declared sink's sealed record cannot38+ * be opened on this device, or no sync location exists at all. Exported for39+ * tests; the entry does not re-export it.40+ */41+export function describeSyncState(input: {42+ syncing: boolean;43+ syncAvailable: boolean;44+ ownerMismatch: boolean;45+ storeState: RuntimeStoreStatus["state"];46+ sinkUnopenable: boolean;47+}): string {48+ if (input.ownerMismatch) return "paused — sync belongs to another account on this device";49+ if (input.storeState === "no_schema") return "blocked — store has no schema for this app";50+ if (input.storeState === "ticket_rejected") return "blocked — ticket no longer accepted";51+ if (input.sinkUnopenable) return "blocked — sync location record unopenable";52+ if (input.syncing) return "syncing to your account";53+ if (input.syncAvailable) return "available — not yet backed up";54+ return "local-only";55+}56+3357 /**3458 * Renders the Device gate: every subsystem's live status — storage, sync,3559 * auth, and PWA — grouped by the module that owns it, so the panel doubles@@ -72,6 +96,8 @@ export function DeviceStatus(): VNode {
7296 if (!report) return <p class="device-status">Checking device capabilities…</p>;7397 7498 const synced = Boolean(session?.syncAvailable);99+ const ownerMismatch = Boolean(session?.syncOwnerMismatch);100+ const syncOwner = runtimeDiagnostics.syncOwner;75101 const sinkUnopenable = !session?.sink && readSinkRestoreOutcome() === "unopenable";76102 const sinkState = session?.sink77103 ? session.sink.source === "declared"@@ -80,11 +106,13 @@ export function DeviceStatus(): VNode {
80106 : sinkUnopenable81107 ? "declared — record unopenable on this device"82108 : "not configured";83- const syncState = session?.syncing84- ? "syncing to your account"85- : session?.syncAvailable86- ? "available — not yet backed up"87- : "local-only";109+ const syncState = describeSyncState({110+ syncing: Boolean(session?.syncing),111+ syncAvailable: synced,112+ ownerMismatch,113+ storeState: runtimeDiagnostics.storeStatus.state,114+ sinkUnopenable,115+ });88116 const backupState = session?.backedUp89117 ? "recovery phrase"90118 : session?.syncAvailable@@ -140,6 +168,20 @@ export function DeviceStatus(): VNode {
140168 <Row label="Sync location" value={sinkState} />141169 <Row label="Store" value={describeStoreStatus(runtimeDiagnostics.storeStatus)} />142170 </dl>171+ {ownerMismatch && (172+ <p>173+ Sync on this device was set up by{" "}174+ {syncOwner.state === "mismatch" && syncOwner.owner_user_id175+ ? (176+ <>177+ the account <code>{syncOwner.owner_user_id}</code>178+ </>179+ )180+ : "a different account"}. Nothing connects while the accounts differ, so neither store181+ can merge into the other. Stop syncing in the account panel to release this device for182+ the current account, or restore the owning account to resume.183+ </p>184+ )}143185 {runtimeDiagnostics.storeStatus.state === "no_schema" && (144186 <p>{runtimeDiagnostics.storeStatus.message}</p>145187 )}@@ -158,10 +200,12 @@ export function DeviceStatus(): VNode {
158200 data is intact and pushes up.159201 </p>160202 )}161- {!synced && (203+ {!synced && !ownerMismatch && (162204 <p>163- Set <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in <code>.env</code>{" "}164- and rebuild, or enroll a sync ticket at runtime, to replicate writes to your account.205+ Enroll an app-connect ticket from your node to replicate writes to your account — a206+ saved ticket autofills from your password manager. Building with{" "}207+ <code>JAZZ_APP_ID</code> and <code>JAZZ_SERVER_URL</code> in <code>.env</code>{" "}208+ remains the compiled-in alternative.165209 </p>166210 )}167211 </div>@@ -0,0 +1,47 @@
1+import { describeSyncState } from "./DeviceStatus.tsx";2+3+// The report's contract after the sync-state integrity pass: a reader must be4+// able to tell *why* nothing is syncing. Each blocked disposition names its5+// cause, and the precedence puts the most specific cause first — an owner6+// mismatch explains more than a store answer, a store refusal more than a7+// missing sink.8+Deno.test("describeSyncState names each blocked disposition first-class", () => {9+ const base = {10+ syncing: false,11+ syncAvailable: false,12+ ownerMismatch: false,13+ storeState: "unchecked" as const,14+ sinkUnopenable: false,15+ };16+ const cases: [Parameters<typeof describeSyncState>[0], string][] = [17+ [{ ...base, ownerMismatch: true, syncing: true }, "another account"],18+ [{ ...base, storeState: "no_schema", syncing: true }, "no schema"],19+ [{ ...base, storeState: "ticket_rejected", syncing: true }, "ticket no longer accepted"],20+ [{ ...base, sinkUnopenable: true }, "unopenable"],21+ [{ ...base, syncing: true, syncAvailable: true }, "syncing to your account"],22+ [{ ...base, syncAvailable: true }, "not yet backed up"],23+ [base, "local-only"],24+ ];25+ for (const [input, needle] of cases) {26+ const verdict = describeSyncState(input);27+ if (!verdict.includes(needle)) {28+ throw new Error(`disposition lost its cause: ${JSON.stringify(input)} → ${verdict}`);29+ }30+ }31+});32+33+// The owner mismatch outranks every other explanation: transport is suppressed34+// because of it, so store-level answers describe a connection that is not35+// being attempted.36+Deno.test("describeSyncState puts the owner mismatch above store answers", () => {37+ const verdict = describeSyncState({38+ syncing: true,39+ syncAvailable: true,40+ ownerMismatch: true,41+ storeState: "no_schema",42+ sinkUnopenable: false,43+ });44+ if (!verdict.includes("another account")) {45+ throw new Error(`owner mismatch was outranked: ${verdict}`);46+ }47+});@@ -7,7 +7,8 @@ import {
77 type SealOutcome,88 sealProvisionCapability,99 } from "../runtime/provision.ts";10-import { enrollSyncTicket, type Session } from "../runtime/session.ts";10+import { enrollSyncTicket, isSyncEnrollmentError, type Session } from "../runtime/session.ts";11+import { isSyncOwnerError } from "../runtime/sync-owner.ts";1112 1213 /** Dependencies {@link TicketEnrollForm} accepts for testing and composition. */1314 export interface TicketEnrollFormProps {@@ -21,6 +22,22 @@ export interface TicketEnrollFormProps {
2122 readonly seal?: () => Promise<SealOutcome>;2223 }2324 25+/**26+ * Turns an enrollment failure into the sentence the form shows. The typed27+ * refusals carry user-presentable messages that name their remediation — a28+ * refused store preflight ({@link isSyncEnrollmentError}), a sync election29+ * owned by another account ({@link isSyncOwnerError}), and malformed or30+ * conflicting tickets ({@link isDataSinkError}) — so those pass through31+ * verbatim; only an unrecognized failure gets the generic retry line.32+ * Exported for tests; the entry does not re-export it.33+ */34+export function describeEnrollmentProblem(error: unknown): string {35+ if (isSyncEnrollmentError(error) || isSyncOwnerError(error) || isDataSinkError(error)) {36+ return error.message;37+ }38+ return "Enrollment failed; check the ticket and the node, then paste it again.";39+}40+2441 type Phase =2542 | { name: "edit"; problem?: string }2643 | { name: "enrolling" }@@ -101,10 +118,7 @@ export function TicketEnrollForm({
101118 setPhase({ name: "enrolled", sealOffer: provision.held && !provision.sealed });102119 onEnrolled?.(session);103120 } catch (error) {104- const problem = isDataSinkError(error)105- ? error.message106- : "Enrollment failed; check the ticket and the node, then paste it again.";107- setPhase({ name: "edit", problem });121+ setPhase({ name: "edit", problem: describeEnrollmentProblem(error) });108122 }109123 }110124 @@ -1,5 +1,7 @@
11 import { render } from "npm:preact-render-to-string@6.7.0";2-import { TicketEnrollForm } from "./TicketEnrollForm.tsx";2+import { describeEnrollmentProblem, TicketEnrollForm } from "./TicketEnrollForm.tsx";3+import { SyncEnrollmentError } from "../runtime/session.ts";4+import { SyncOwnerError } from "../runtime/sync-owner.ts";35 46 // The form's manager-facing semantics are the contract: password managers key57 // on a real form with a current-password field, a username companion, and a@@ -20,6 +22,33 @@ Deno.test("TicketEnrollForm renders password-manager-compatible form semantics",
2022 }2123 });2224 25+// The typed refusals carry their own remediation; the form must not flatten26+// them into the generic "paste it again" line — re-pasting cannot fix an27+// unprovisioned store or a foreign sync owner.28+Deno.test("describeEnrollmentProblem relays typed refusals verbatim", () => {29+ const cases: [Error, string][] = [30+ [new SyncEnrollmentError("no_schema", "sync"), "no schema deployed"],31+ [new SyncEnrollmentError("ticket_rejected"), "revoked or the node was reset"],32+ [new SyncOwnerError("alice"), "set up by a different account"],33+ ];34+ for (const [error, needle] of cases) {35+ const problem = describeEnrollmentProblem(error);36+ if (problem !== error.message) {37+ throw new Error(`typed refusal was rewritten: ${problem}`);38+ }39+ if (!problem.includes(needle)) {40+ throw new Error(`refusal message lost its remediation: ${problem}`);41+ }42+ }43+});44+45+Deno.test("describeEnrollmentProblem keeps the generic line for unknown failures", () => {46+ const problem = describeEnrollmentProblem(new TypeError("fetch failed"));47+ if (!problem.includes("check the ticket and the node")) {48+ throw new Error(`unknown failure leaked internals: ${problem}`);49+ }50+});51+2352 Deno.test("TicketEnrollForm states the custody story without overclaiming", () => {2453 const html = render(<TicketEnrollForm />);2554 if (!html.includes("bearer credential")) {@@ -3,17 +3,22 @@ import {
33 confirmPhraseAccess,44 createBackupPasskey,55 createRecoverablePasskeyBackup,6+ describeStoreStatus,67 enableSyncBackup,8+ getRuntimeDiagnostics,79 isAccountReplacementError,810 isAuthError,911 isRecoverablePasskeyError,1012 isRecoveryError,13+ isSyncOwnerError,1114 readAccountSession,1215 restoreFromPasskey,1316 restoreFromRecoveryPhrase,1417 revealRecoveryPhrase,18+ runtimeRecreatedEvent,1519 type Session,1620 stopSyncBackup,21+ subscribeRuntimeDiagnostics,1722 } from "@nzip/lofi";1823 import { encodeSharingIdentity } from "@nzip/lofi/access";1924 import { TicketEnrollForm } from "@nzip/lofi/preact";@@ -54,6 +59,7 @@ function describe(error: unknown): string {
5459 }5560 if (isRecoveryError(error)) return error.message;5661 if (isRecoverablePasskeyError(error) || isAccountReplacementError(error)) return error.message;62+ if (isSyncOwnerError(error)) return error.message;5763 return error instanceof Error ? error.message : String(error);5864 }5965 @@ -74,9 +80,19 @@ export default function AccountGate() {
7480 // so the phrase block can say so honestly rather than imply a confirmation.7581 const [unguarded, setUnguarded] = useState(false);7682 83+ const [diagnostics, setDiagnostics] = useState(getRuntimeDiagnostics());84+7785 useEffect(() => {78- void readAccountSession().then(setSession, (cause) => setError(describe(cause)));86+ const refresh = () =>87+ void readAccountSession().then(setSession, (cause) => setError(describe(cause)));88+ refresh();89+ // Electing sync, stopping it, or restoring an account recreates the90+ // runtime; a snapshot read once on mount would keep rendering the old91+ // account state over the new reality.92+ globalThis.addEventListener(runtimeRecreatedEvent, refresh);93+ return () => globalThis.removeEventListener(runtimeRecreatedEvent, refresh);7994 }, []);95+ useEffect(() => subscribeRuntimeDiagnostics(() => setDiagnostics(getRuntimeDiagnostics())), []);8096 8197 // Every account action funnels through here: one busy flag, one error line,8298 // and any returned Session becomes the new snapshot.@@ -182,6 +198,41 @@ export default function AccountGate() {
182198 </>183199 );184200 201+ // Sync on this device was elected by a different account: the runtime booted202+ // with transport suppressed so neither store can merge into the other. The203+ // two remediations are exactly the actions offered here — stop syncing204+ // releases the election for the current account, restore adopts the owner.205+ if (session.syncOwnerMismatch) {206+ const owner = diagnostics.syncOwner.state === "mismatch"207+ ? diagnostics.syncOwner.owner_user_id208+ : null;209+ return (210+ <section class="account account-out" aria-labelledby="account-title">211+ <header>212+ <p class="eyebrow">Account</p>213+ <h2 id="account-title">Sync paused — set up by a different account</h2>214+ </header>215+ <p>216+ Sync on this device was set up by{" "}217+ {owner ? <code>{owner}</code> : "a different account"}, so nothing connects — syncing now218+ would merge two accounts’ data. Stop syncing to release this device for the current219+ account, or restore the owning account to resume where it left off.220+ </p>221+ <div class="account-actions">222+ <button223+ type="button"224+ disabled={disabled}225+ onClick={() => run("stop", stopSyncBackup)}226+ >227+ {busy === "stop" ? "Stopping…" : "Stop syncing & release this device"}228+ </button>229+ </div>230+ {restoreBlock}231+ {error && <p class="account-error" role="alert">{error}</p>}232+ </section>233+ );234+ }235+185236 // No sync location yet: offer the connect step, with restore available so a186237 // fresh device recovers its identity before or after choosing where to sync.187238 if (!session.syncAvailable) {@@ -228,6 +279,16 @@ export default function AccountGate() {
228279 </div>229280 );230281 282+ // A definite store problem is worth a line here, not only in the device283+ // report: the account panel is where the user just acted, and each of these284+ // states means writes are not replicating despite sync being on.285+ const storeStatus = diagnostics.storeStatus;286+ const storeProblem = storeStatus.state === "no_schema" ||287+ storeStatus.state === "ticket_rejected" ||288+ storeStatus.state === "store_unavailable"289+ ? describeStoreStatus(storeStatus)290+ : null;291+231292 if (session.backedUp) {232293 return (233294 <section class="account account-in" aria-labelledby="account-title">@@ -246,6 +307,11 @@ export default function AccountGate() {
246307 Share identity: <code data-sharing-identity>{sharingIdentity}</code>247308 </p>248309 )}310+ {storeProblem && (311+ <p class="account-note" role="alert">312+ Sync location: {storeProblem}. See the device report for details.313+ </p>314+ )}249315 <div class="account-actions">250316 {!session.passkeyRecoverable && (251317 <button@@ -14,7 +14,7 @@
1414 "README.md": "125e906d85029b14d788b8e2dfe6e3037700770b9fd50e76ce69f98c3a82bb05",1515 "src/app.ts": "2021889ec895b7c758e9c541eb968a480f63610073fe2c2d316faa7b66e85113",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",17- "src/islands/AccountGate.tsx": "502009c583a51a6a4b7fb4402e86ec87c61dbc85d87fa0210969144c328a7d1a",17+ "src/islands/AccountGate.tsx": "0faa82edb05ea9c6da1f575ebb75cc2f60d8e95e88524c0720e3c885a1eee659",1818 "src/islands/TaskList.tsx": "7b0da8830e5fab9c59948eff1cbaa357805ecea9c5d070249dd46157e5e36bdf",1919 "src/islands/use-tasks.ts": "0859b1bbd18a7e9fee54cbe919b4e1c9c4b23665227bffa54bd7f0d910192589",2020 "src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",