clone

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

feat(app): sink-aware restore — fresh devices connect and recover in either order (#136)

FelineStateMachine committed ab57ada
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>
4 files changed, +133 -82
Mapps/reference/src/islands/AccountGate.tsx197++++++++++++++++++------------
Mdeno.json6+-
Mdocs/sync-and-recovery.md10++
Mpackage/testdata/starter.snapshot.json2+-
M apps/reference/src/islands/AccountGate.tsx +118 -79
@@ -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 );
M deno.json +4 -2
M docs/sync-and-recovery.md +10 -0
M package/testdata/starter.snapshot.json +1 -1