Lazarus data recovery replaces NIP-78 account backup - #753
Conversation
fe37214 to
e8b263c
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved recovery safety, validation, private-delta, and UI consistency issues remain.
Review effort: Lite
Findings: 3
Open (7)
Failed latest-event reread is treated as no current event · New Pre-sign recheck fails open when relays are unavailable · New Unvalidated relay events can inject foreign candidates · New Private tag deltas are omitted from restore review · New Published relay count includes non-publishing relays · New Profile delta omits extensible metadata fields · New Pool teardown leaves relay connections open · New
What changed in this PR
Replaces legacy NIP-78 and Mutable recovery with explicit Lazarus relay-history recovery.
Changes:
- Adds Lazarus scanning, ranking, paging, delta review, and publishing.
- Adds Settings/profile recovery interfaces and signer safeguards.
- Removes obsolete backup and recovery implementations.
| File | Description |
|---|---|
src/routes/user/[slug]/+page.svelte |
Adds profile recovery entry point. |
src/routes/settings/+page.svelte |
Replaces backup settings with Data Recovery. |
src/lib/profileBackup.ts |
Removes legacy profile backup service. |
src/lib/nostrBackup.ts |
Removes legacy account backup service. |
src/lib/lazarus/source.ts |
Implements relay scanning and paging. |
src/lib/lazarus/source.test.ts |
Tests relay adapter behavior. |
src/lib/lazarus/registry.ts |
Defines recoverable kind metadata. |
src/lib/lazarus/recovery.ts |
Implements recovery analysis and drafts. |
src/lib/lazarus/recovery.test.ts |
Tests recovery behavior. |
src/lib/lazarus/private-items.ts |
Estimates and parses encrypted items. |
src/lib/lazarus/private-items.test.ts |
Tests encrypted-item sizing. |
src/lib/lazarus/paging.bench.test.ts |
Benchmarks deep-history paging. |
src/lib/lazarus/nip46.ts |
Checks remote-signer request limits. |
src/lib/lazarus/lazarusPublish.ts |
Publishes and refreshes recovered data. |
src/lib/followRecovery.ts |
Removes Mutable recovery. |
src/lib/followRecovery.test.ts |
Removes obsolete recovery tests. |
src/components/ProfileEditModal.svelte |
Removes legacy backup controls. |
src/components/NostrBackupSection.svelte |
Removes legacy backup UI. |
src/components/LazarusRecoverySection.svelte |
Adds recovery settings interface. |
src/components/LazarusRecoveryModal.svelte |
Adds profile recovery modal wrapper. |
src/components/LazarusDeltaPanel.svelte |
Adds restore delta review and confirmations. |
src/components/FollowListRecoveryModal.svelte |
Removes obsolete recovery modal. |
package.json |
Updates the application version. |
docs/ndk-readiness-discovery.md |
Updates removed backup references. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const latest = await fetchLatestLazarusVersion(chosen.kind, pubkey); | ||
| if (latest && latest.id !== reviewedCurrent?.id) { | ||
| return { status: 'changed', latest }; | ||
| } | ||
|
|
||
| const draft = buildLazarusRecoveryDraft(chosen, { current: latest ?? reviewedCurrent }); |
There was a problem hiding this comment.
Fixed — this was a real hole in the one write path, thank you. fetchLatestLazarusVersion now distinguishes an empty read from an unavailable one: a fulfilled read (even one that returns zero events) is a real "nothing newer" answer, and it throws when zero write relays produced a usable answer. publishLazarusRecovery maps that to a new LazarusPublishError('current-unreadable', …) and aborts before signing: "Couldn't reach your write relays to confirm the current version — nothing was published. Please retry."
New tests pin both sides: fails closed when no write relay could be reached and resolves to no current event when a relay answers empty, even if the rest time out.
One deliberate asymmetry to call out: if some write relays answer and others time out, the restore proceeds on the partial view. Requiring every relay to answer would make restore impossible whenever a single flaky relay exists, and an edit from another client propagates to all of the user's write relays — so any successful read catches a post-review edit. The fail-closed line is drawn exactly where the current version becomes unknowable: zero answers.
| const results = await Promise.allSettled( | ||
| write.map((url) => | ||
| fetchFromRelay(url, { kinds: [kind], authors: [pubkey], limit: 1 }, LATEST_VERSION_TIMEOUT_MS) | ||
| ) | ||
| ); | ||
| let newest: Event | undefined; | ||
| for (const result of results) { | ||
| if (result.status !== 'fulfilled') continue; | ||
| for (const event of result.value) { | ||
| if (event.pubkey === pubkey && (!newest || event.created_at > newest.created_at)) { | ||
| newest = event; | ||
| } | ||
| } | ||
| } | ||
| return newest; |
There was a problem hiding this comment.
Fixed at the source: fetchLatestLazarusVersion now counts how many write relays produced a usable answer and throws 'No write relay could be reached to confirm the current version' when that count is zero — undefined is reserved for a successful read that genuinely found nothing newer. The per-event check also validates kind alongside the existing pubkey check, so a filter-ignoring relay can't slip a foreign event in as "current" either.
The spec (SPEC.md, "Recover") only mandates the re-read-and-recompute-when-changed behavior and is silent on the unreachable case; failing closed here is the conservative reading — the caller (publishLazarusRecovery) aborts with a new current-unreadable error before anything is signed. See my reply on the lazarusPublish.ts thread for the partial-answer trade-off and the new tests.
| for (const event of relayEvents) { | ||
| tagged.push({ event, relayUrl: url }); | ||
| } |
There was a problem hiding this comment.
Fixed. fetchVersions now filters each relay's answer to event.pubkey === pubkey && event.kind === kind before anything is built from it — candidates, the responding-relay list, and the paging cursor (computed from validated events only, as suggested). A relay whose answer contains only foreign events counts as no answer, same as a timeout.
Signature validity was already handled: SimplePool verifies events in transit and never delivers invalid ones to onevent, so the actual hole was exactly the one you flagged — a validly signed event from the wrong author served by a relay that ignores the authors/kinds filter. fetchLatestLazarusVersion got the same kind check to go with its existing pubkey check.
Two new tests pin it: drops events from another author or kind, even when the relay ignores the filter and counts a relay that answered with only foreign events as no answer.
| $: delta = | ||
| profile?.ranking !== 'recency' ? computeLazarusDelta(candidate.event, currentEvent) : undefined; |
There was a problem hiding this comment.
Addressed with the decryption step, mirroring the reference implementation (jumble-spark's Lazarus page): the delta is no longer computed with an always-empty privateTags map.
When a review opens — and again when the pre-sign re-check surfaces a newer current version — LazarusRecoverySection decrypts the chosen candidate's and the current version's content through the app's encryptionService.decrypt (local key, NIP-07 nip04/nip44, NIP-46 with its denial circuit-breaker and decrypt cache), parses it with parsePrivateTags, and feeds the map into computeLazarusDelta so private adds/removes appear in the review. The scan is re-ranked via applyLazarusPrivateTags, so version counts turn exact too. Restore buttons stay disabled while decryption is in flight — a public-only delta can't be approved mid-decrypt.
Where decryption genuinely isn't possible we keep the spec's flagged tier rather than blocking: view-only accounts (no key), remote signers over the NIP-46 64k request cap (fitsNip46Request skip), or a declined prompt. In those cases the panel now shows an explicit warning that private items couldn't be decrypted, aren't listed, and restore verbatim — the spec permits restoring with disclosed unknowns, and the encrypted blob restores byte-for-byte either way. The old passive "not counted" footnote is gone.
Skipped for now from the reference implementation: background row-by-row decryption of every listed version and the persistent decrypted-content cache (jumble's indexedDb). Review-time decryption closes the approval gap you flagged; row counts remain honest estimates with ranges in the meantime.
| return { | ||
| status: 'published', | ||
| event, | ||
| publishedRelays: Array.from(ndk.pool.relays.keys()) |
There was a problem hiding this comment.
Fixed: publishedRelays now maps the Set<NDKRelay> that event.publish() resolves with — the write relays that actually took the recovery event — instead of ndk.pool.relays, which includes scan connections, temporary publish relays, and anything else the pool happened to touch. The success message's relay count is now the truthful accepted-on-write-relays number.
| return PROFILE_FIELDS.flatMap((field) => { | ||
| const change = { field, from: text(from[field]), to: text(to[field]) }; | ||
| return change.from === change.to ? [] : [change]; | ||
| }); |
There was a problem hiding this comment.
Fixed by comparing the union of keys, as suggested: computeLazarusProfileChanges now walks the well-known fields first (display order preserved) and then every other key present in either version's content, sorted — so pronouns, bot, and client-specific fields show up as changes (or drops) instead of silently reverting. Non-string values are rendered as JSON (bot: true → 'true') rather than reading as absent, which had the same masking effect for booleans/numbers/objects. An empty or whitespace-only string still reads as absent, per the existing display convention.
"This version matches your current profile fields" is now literally true — any content difference surfaces. New tests cover an extra field being dropped by a restore (zapcooking) and a non-string value appearing.
| export function closeLazarusScanPool() { | ||
| if (scanPool) { | ||
| try { | ||
| scanPool.close([]); |
There was a problem hiding this comment.
Good catch — close(relays: string[]) only closes the URLs it's handed (confirmed against nostr-tools' AbstractSimplePool), so close([]) was closing nothing and every recovery-screen teardown leaked that pool's sockets. Now calls destroy(), which closes every connection the pool holds; scanPool is then dropped and recreated lazily on the next scan. Pinned by a test that scans, closes, asserts destroy ran, then scans again and closes again.
775a704 to
554a75e
Compare
Implements the Lazarus spec (0.6.0-draft, github.com/dmnyc/lazarus) in place of the NIP-78 account-backup feature — the same design shipped in the maintained Jumble fork (dmnyc/jumble-spark, the spec's reference implementation) and proposed in DocNR/jank#22. Vendored core (src/lib/lazarus/, ported from the reference implementation in dmnyc/jumble-spark feat/lazarus-data-recovery-v2; nothing is on npm): the 8-kind registry, NIP-51 private-item sizing from ciphertext length, the pure scan/rank/delta/draft algorithms (clobber episodes, settling, tombstone rules, tag-identity deltas), a zap SimplePool adapter with per-relay timeouts + archival relays + until-cursor paging, and the one write path. 89 vitest cases port the spec's conformance vectors, plus a deep-paging bench. The 0.6.0 relay-failure semantics are in full: every relay request ends answered/failed/timed-out and only an answered relay counts as having nothing; a scan no relay answered is a failed scan (versions that arrived first still shown); current is confirmed only when a write relay answered, withholding the recommendation until then; the kind-10002 lookup distinguishes found/missing/unknown and never substitutes defaults for an unknown list; profile deltas cover every field and tag, with decrypted private items in the delta; the pre-sign re-read fails closed, with a retry and an explicit, never-preselected restore-anyway override; only a newer version counts as a change; and the scan pool releases its connections. Settings gains a Data Recovery accordion (kind picker, scan on click only, every version with counts/estimates/found-on relays and per-relay outcomes, recommended highlight, delta review with re-mute warning, shrink confirmation, 10044 intent question, NIP-46 size caps, view-only scan, retry of unreachable relays) with a windowed, memoized version list and an inline delta panel under the clicked row. The profile page's 'Restore' entry opens the same screen; ProfileEditModal's NIP-78 backup features are removed. Deleted: nostrBackup.ts, profileBackup.ts, NostrBackupSection.svelte, and the pre-spec Mutable recovery (followRecovery.ts + modal). Wallet NWC/Spark NIP-78 backups and all other 30078 app-data codecs are untouched.
554a75e to
4b06e03
Compare


Implements the Lazarus data-recovery protocol (spec 0.6.0-draft, SPEC.md) in place of the NIP-78 account-backup feature — the same design already shipped in a maintained Jumble fork (dmnyc/jumble-spark) and proposed in DocNR/jank#22, so this is the web client joining the cross-platform convergence.
The model
Backups are the garage; Lazarus is the seatbelt. Nostr clients overwrite replaceable events without reading them first — when a client touches your follows, mutes, or profile, whatever it publishes replaces what was there. The history survives on relays that keep superseded versions, and Lazarus recovers it: scan the relay set, rank the versions found (clobber detection, not size envy), and restore what a rogue client destroyed — only through an explicit click, only with the user's own signer.
Vendored core (
src/lib/lazarus/, ported from the reference implementation dmnyc/jumble-spark, branchfeat/lazarus-data-recovery; nothing is on npm):registry.ts— the 8-kind registry (tier, ranking profile, meaningful-empty flags, warnings); the algorithm hardcodes no kind semantics.private-items.ts— NIP-51 private-item sizing from ciphertext length (NIP-44 padded lengths, NIP-04 block bounds), no zod dependency.recovery.ts— pure scan/rank/delta/draft: clobber episodes (drops within 24h group; ≥20% and ≥5 items is a drop), settling (5 edits over a week = the user's choice), tombstones never recommended, tag-identity deltas, drafts dated after the version they replace.source.ts— zap adapter: per-relay SimplePool queries with 6s timeouts, the spec's archival relay set,until-cursor paging.lazarusPublish.ts— the one write path: re-reads current before signing and re-asks if it moved, guards the signing account, judges success on write relays, best-effort republish to answering relays, refreshes the app's local copy (followListCache / muteListStore / profileCacheManager).The four invariants hold: never automatic (scan and restore are click-only), everything found is shown (episodes folded but one action away), tombstones never recommended, your keys your publish (one signed event per explicit click, works with NIP-07 / local key / NIP-46).
UI
Removed
nostrBackup.ts,profileBackup.ts,NostrBackupSection.svelte, and the pre-spec Mutable recovery (followRecovery.ts,FollowListRecoveryModal.svelte) — Lazarus supersedes it (it was kind-3-only and NIP-07-only). Old encrypted 30078 backup events are left on relays unread; Lazarus reads the canonical kinds.Kept untouched: wallet NWC/Spark NIP-78 backups and every other 30078 app-data codec (timers, nourish, pantry, grocery, planner, cheffy's table) — those are app-data sync, not account backup.
Testing
svelte-checkat pre-existing baseline.Screenshots