Skip to content

Commit 0b6a3ec

Browse files
fix(preact): sync-state quality pass — first-class blocked dispositions, typed enrollment errors, account-gate freshness (#183)
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.
1 parent 5409df2 commit 0b6a3ec

7 files changed

Lines changed: 284 additions & 18 deletions

File tree

‎apps/reference/src/islands/AccountGate.tsx‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 the
90+
// runtime; a snapshot read once on mount would keep rendering the old
91+
// 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 booted
202+
// with transport suppressed so neither store can merge into the other. The
203+
// two remediations are exactly the actions offered here — stop syncing
204+
// 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_id
208+
: 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 now
218+
would merge two accounts&rsquo; data. Stop syncing to release this device for the current
219+
account, or restore the owning account to resume where it left off.
220+
</p>
221+
<div class="account-actions">
222+
<button
223+
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 a
186237
// 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 device
283+
// report: the account panel is where the user just acted, and each of these
284+
// 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

‎package/preact/DeviceStatus.tsx‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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, or
37+
* the ticket is no longer accepted), the declared sink's sealed record cannot
38+
* be opened on this device, or no sync location exists at all. Exported for
39+
* 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?.sink
77103
? session.sink.source === "declared"
@@ -80,11 +106,13 @@ export function DeviceStatus(): VNode {
80106
: sinkUnopenable
81107
? "declared — record unopenable on this device"
82108
: "not configured";
83-
const syncState = session?.syncing
84-
? "syncing to your account"
85-
: session?.syncAvailable
86-
? "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?.backedUp
89117
? "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_id
175+
? (
176+
<>
177+
the account <code>{syncOwner.owner_user_id}</code>
178+
</>
179+
)
180+
: "a different account"}. Nothing connects while the accounts differ, so neither store
181+
can merge into the other. Stop syncing in the account panel to release this device for
182+
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 — a
206+
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>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 be
4+
// able to tell *why* nothing is syncing. Each blocked disposition names its
5+
// cause, and the precedence puts the most specific cause first — an owner
6+
// mismatch explains more than a store answer, a store refusal more than a
7+
// 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 suppressed
34+
// because of it, so store-level answers describe a connection that is not
35+
// 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+
});

‎package/preact/TicketEnrollForm.tsx‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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 typed
27+
* refusals carry user-presentable messages that name their remediation — a
28+
* refused store preflight ({@link isSyncEnrollmentError}), a sync election
29+
* owned by another account ({@link isSyncOwnerError}), and malformed or
30+
* conflicting tickets ({@link isDataSinkError}) — so those pass through
31+
* 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.message
106-
: "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

‎package/preact/TicketEnrollForm_test.tsx‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 key
57
// 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 flatten
26+
// them into the generic "paste it again" line — re-pasting cannot fix an
27+
// 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")) {

0 commit comments

Comments
 (0)