The account island returned null without a sync location, so on a deployment with no compiled managed app a fresh device could neither restore an account nor enroll a ticket. It now renders the connect state: the packaged TicketEnrollForm (a password manager autofills a saved ticket) alongside the restore controls, shared with the back-up state — identity recovery and choosing where data lives compose in either order, and synced data arrives once both halves exist. A passkey backup stores exactly the 32-byte account secret (the vendor keeps it as the WebAuthn userHandle, spec-capped well below any envelope), so the sync location cannot travel inside the credential; the ticket in the user's password manager is the durable copy of 'where', and the restore docs now say so. Also excludes .claude/ from fmt and lint — agent worktrees live under the repo root and repo-wide checks must not scan them. Part of #107.
- commit
- ab57adae843b0feaf49f817b1b41deb22e0dcab5
- parent
- c656ef5a0c66307481919efd12b43da6bde77a8d
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | apps/ | 197 | ++++++++++++++++++------------ |
| M | deno.json | 6 | +- |
| M | docs/ | 10 | ++ |
| M | package/ | 2 | +- |
@@ -16,6 +16,7 @@ import {
1616 stopSyncBackup,1717 } from "@nzip/lofi";1818 import { encodeSharingIdentity } from "@nzip/lofi/access";19+import { TicketEnrollForm } from "@nzip/lofi/preact";1920 2021 /**2122 * Author-owned account example. lofi is local-first: the app already opened on a@@ -24,9 +25,10 @@ import { encodeSharingIdentity } from "@nzip/lofi/access";
2425 * public `@nzip/lofi` session primitives. Delete it if your app is local-only,2526 * or restyle it freely; framework implementation remains package-owned.2627 *27- * It renders only when a managed Jazz app is configured (`JAZZ_APP_ID` /28- * `JAZZ_SERVER_URL`, e.g. via `deno task jazz:provision`), because a recovery29- * phrase only matters once there is somewhere to sync to.28+ * With no sync location — no compiled managed app and no enrolled ticket — it29+ * offers the connect step instead: enroll an app-connect ticket (a password30+ * manager autofills a saved one), and restore is available first, so a fresh31+ * device can recover its identity before or after choosing where to sync.3032 */3133 3234 type Busy =@@ -92,11 +94,121 @@ export default function AccountGate() {
9294 [],9395 );9496 95- // Nothing to back up to when no Jazz app is configured — stay out of the way.96- if (!session || !session.syncAvailable) return null;97+ if (!session) return null;9798 9899 const disabled = busy !== null;99100 101+ // Restore works without a sync location: it replaces the local identity, and102+ // synced data arrives once a location exists. Rendered in both the connect103+ // and the back-up states.104+ const restoreBlock = (105+ <>106+ <div class="account-actions">107+ <button108+ type="button"109+ class="account-secondary"110+ disabled={disabled || !confirmReplacement}111+ onClick={() => {112+ setRestoring(true);113+ run("passkey-restore", () =>114+ restoreFromPasskey({ confirmLocalReplacement: confirmReplacement }).then((next) => {115+ setConfirmReplacement(false);116+ return next;117+ }));118+ }}119+ >120+ {busy === "passkey-restore" ? "Restoring…" : "Use passkey"}121+ </button>122+ <button123+ type="button"124+ class="account-secondary"125+ disabled={disabled || !confirmReplacement}126+ onClick={() => {127+ setError(null);128+ setRestoring((value) =>129+ !value130+ );131+ }}132+ >133+ {restoring ? "Cancel" : "Restore from recovery phrase"}134+ </button>135+ </div>136+ <label class="account-note">137+ <input138+ type="checkbox"139+ checked={confirmReplacement}140+ disabled={disabled}141+ onChange={(event) => setConfirmReplacement(event.currentTarget.checked)}142+ />{" "}143+ I understand restore replaces this device's current local account and unsynced data may be144+ lost.145+ </label>146+ {restoring && (147+ <div class="account-field">148+ <label for="recovery-input">Recovery phrase</label>149+ <textarea150+ id="recovery-input"151+ value={phraseInput}152+ onInput={(event) => setPhraseInput(event.currentTarget.value)}153+ placeholder="word one word two …"154+ rows={3}155+ disabled={disabled}156+ />157+ <p class="account-note">158+ Restoring replaces this device's local account with the recovered one — any data created159+ here that is not backed up will be discarded.160+ </p>161+ <button162+ type="button"163+ disabled={disabled || phraseInput.trim().length === 0}164+ onClick={() =>165+ run(166+ "restore",167+ () =>168+ restoreFromRecoveryPhrase(phraseInput, {169+ confirmLocalReplacement: confirmReplacement,170+ }).then((next) => {171+ setRestoring(false);172+ setPhraseInput("");173+ setConfirmReplacement(false);174+ return next;175+ }),176+ )}177+ >178+ {busy === "restore" ? "Restoring…" : "Restore account"}179+ </button>180+ </div>181+ )}182+ </>183+ );184+185+ // No sync location yet: offer the connect step, with restore available so a186+ // fresh device recovers its identity before or after choosing where to sync.187+ if (!session.syncAvailable) {188+ return (189+ <section class="account account-out" aria-labelledby="account-title">190+ <header>191+ <p class="eyebrow">Account</p>192+ <h2 id="account-title">Connect a sync location</h2>193+ </header>194+ <p>195+ You are working on a private, on-device account. To sync or recover it, connect a sync196+ location: paste the app-connect ticket from your node — a password manager fills a saved197+ one. Restoring an account works before or after connecting; synced data arrives once a198+ location exists.199+ </p>200+ <TicketEnrollForm201+ title="Enroll an app-connect ticket"202+ onEnrolled={() => {203+ void readAccountSession().then(setSession, (cause) => setError(describe(cause)));204+ }}205+ />206+ {restoreBlock}207+ {error && <p class="account-error" role="alert">{error}</p>}208+ </section>209+ );210+ }211+100212 const phraseBlock = phrase && (101213 <div class="account-phrase">102214 <p class="account-note">@@ -231,45 +343,8 @@ export default function AccountGate() {
231343 {busy === "enable" ? "Backing up…" : "Back up & enable sync"}232344 </button>233345 )}234- <button235- type="button"236- class="account-secondary"237- disabled={disabled || !confirmReplacement}238- onClick={() => {239- setRestoring(true);240- run("passkey-restore", () =>241- restoreFromPasskey({ confirmLocalReplacement: confirmReplacement }).then((next) => {242- setConfirmReplacement(false);243- return next;244- }));245- }}246- >247- {busy === "passkey-restore" ? "Restoring…" : "Use passkey"}248- </button>249- <button250- type="button"251- class="account-secondary"252- disabled={disabled || !confirmReplacement}253- onClick={() => {254- setError(null);255- setRestoring((value) =>256- !value257- );258- }}259- >260- {restoring ? "Cancel" : "Restore from recovery phrase"}261- </button>262346 </div>263- <label class="account-note">264- <input265- type="checkbox"266- checked={confirmReplacement}267- disabled={disabled}268- onChange={(event) => setConfirmReplacement(event.currentTarget.checked)}269- />{" "}270- I understand restore replaces this device's current local account and unsynced data may be271- lost.272- </label>347+ {restoreBlock}273348 {phraseBlock}274349 {pendingEnable && (275350 <div class="account-actions">@@ -286,42 +361,6 @@ export default function AccountGate() {
286361 </button>287362 </div>288363 )}289- {restoring && (290- <div class="account-field">291- <label for="recovery-input">Recovery phrase</label>292- <textarea293- id="recovery-input"294- value={phraseInput}295- onInput={(event) => setPhraseInput(event.currentTarget.value)}296- placeholder="word one word two …"297- rows={3}298- disabled={disabled}299- />300- <p class="account-note">301- Restoring replaces this device's local account with the recovered one — any data created302- here that is not backed up will be discarded.303- </p>304- <button305- type="button"306- disabled={disabled || phraseInput.trim().length === 0}307- onClick={() =>308- run(309- "restore",310- () =>311- restoreFromRecoveryPhrase(phraseInput, {312- confirmLocalReplacement: confirmReplacement,313- }).then((next) => {314- setRestoring(false);315- setPhraseInput("");316- setConfirmReplacement(false);317- return next;318- }),319- )}320- >321- {busy === "restore" ? "Restoring…" : "Restore account"}322- </button>323- </div>324- )}325364 {error && <p class="account-error" role="alert">{error}</p>}326365 </section>327366 );@@ -98,6 +98,16 @@ Semantics to rely on:
9898 - If the node revokes the ticket, requests fail with 401 and live sockets close; treat the stored9999 sink as dead and surface re-enrollment rather than retrying silently.100100 101+### Restoring on a fresh device102+103+Recovery has two halves, held in two places: the passkey or phrase recovers _who you are_ (the104+account identity), and the app-connect ticket names _where your data lives_. A passkey backup stores105+exactly the 32-byte account secret, so on a deployment without a compiled managed app a fresh device106+restores to a local-only account and then connects the sync location — paste the ticket saved in107+your password manager, or have the node re-issue one. The starter account UI offers both steps in108+either order; synced data arrives once the location is enrolled. Deployments with a compiled managed109+app restore straight to syncing.110+101111 ## The user's account journey102112 103113 ```mermaid@@ -14,7 +14,7 @@
1414 "README.md": "10d47c681ae44a3d70b790e3952e0ac361e0eeec4a433f0ff35cfe9db61a7778",1515 "src/app.ts": "0f508004dfb12456d65f3ee2231199b932fe239bef6977405fb93135972c0f33",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",17- "src/islands/AccountGate.tsx": "31e74120f882dc9c1ff44e7ff41c0aa6e816ad8a4e9f937e611a3c19fb69c5f0",17+ "src/islands/AccountGate.tsx": "502009c583a51a6a4b7fb4402e86ec87c61dbc85d87fa0210969144c328a7d1a",1818 "src/islands/TaskList.tsx": "cf559f8529e3836730abcdafab8184032c8ac662a1e794e20f0879161bee37be",1919 "src/islands/use-tasks.ts": "22400693c9b7197adb8af858b0af13456459823229908c87e3af4fd4f6336554",2020 "src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711",