Skip to content

Lazarus data recovery replaces NIP-78 account backup - #753

Merged
dmnyc merged 1 commit into
zapcooking:mainfrom
dmnyc:feat/lazarus-data-recovery
Sep 26, 2026
Merged

dmnyc merged 1 commit into
zapcooking:mainfrom
dmnyc:feat/lazarus-data-recovery

Conversation

@dmnyc

@dmnyc dmnyc commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

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, branch feat/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).
  • 89 vitest cases port the spec's conformance vectors (Tier-1 ranking, private-item estimates against real ciphertexts, paging, relay outcomes, the re-read check, the unreachable-re-read override) plus a bench pinning deep-paging performance.

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

  • Settings → Data Recovery: kind picker (Follows, Mutes, Profile, Bookmarks, Encryption keys), scan on click, all versions with counts/estimates/found-on relays, recommended-clobber highlight, delta review before any publish (re-mute warning, shrink confirmation, NIP-46 size-cap marking, 10044 intent question), load-older paging, view-only accounts scan but can't restore. Version list is windowed (60 rows + Show more) with per-scan memoized row metadata — deep archival histories don't freeze the page (bench + browser-verified at 344 versions).
  • Profile → Restore follows or profile data opens the same screen; ProfileEditModal's NIP-78 auto-backup/manual backup/restore is removed.

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

  • 66 Lazarus tests (conformance vectors + adapter + paging bench); full suite 2142 passing.
  • svelte-check at pre-existing baseline.
  • Browser-verified: scan against a live account with deep relay history, paging, heaviest render config, page-wide responsiveness, delta review flow.

Screenshots

Profile page → Restore: the recovery screen opened from a user profile Recovery screen opened from the profile page
Version list — current and recommended versions, found-on relays, counts and estimates Version list
Inline delta review — opens under the clicked row, where the user is looking, with the delta, re-mute warning, and explicit restore Inline delta review
Kind picker Kind picker

@dmnyc
dmnyc force-pushed the feat/lazarus-data-recovery branch 6 times, most recently from fe37214 to e8b263c Compare September 25, 2026 19:03
@spe1020
spe1020 requested a lite review from Copilot September 26, 2026 01:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved recovery safety, validation, private-delta, and UI consistency issues remain.

Review effort: Lite
Findings: 3 High severity · 4 Medium severity

Open (7)
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.

Comment thread src/lib/lazarus/lazarusPublish.ts Outdated
Comment on lines +99 to +104
const latest = await fetchLatestLazarusVersion(chosen.kind, pubkey);
if (latest && latest.id !== reviewedCurrent?.id) {
return { status: 'changed', latest };
}

const draft = buildLazarusRecoveryDraft(chosen, { current: latest ?? reviewedCurrent });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/lazarus/source.ts Outdated
Comment on lines +128 to +142
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;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/lazarus/source.ts
Comment on lines +170 to +172
for (const event of relayEvents) {
tagged.push({ event, relayUrl: url });
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/components/LazarusDeltaPanel.svelte Outdated
Comment on lines +50 to +51
$: delta =
profile?.ranking !== 'recency' ? computeLazarusDelta(candidate.event, currentEvent) : undefined;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/lazarus/lazarusPublish.ts Outdated
Comment on lines +158 to +161
return {
status: 'published',
event,
publishedRelays: Array.from(ndk.pool.relays.keys())

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/lazarus/recovery.ts Outdated
Comment on lines +560 to +563
return PROFILE_FIELDS.flatMap((field) => {
const change = { field, from: text(from[field]), to: text(to[field]) };
return change.from === change.to ? [] : [change];
});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/lazarus/source.ts Outdated
export function closeLazarusScanPool() {
if (scanPool) {
try {
scanPool.close([]);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@dmnyc
dmnyc force-pushed the feat/lazarus-data-recovery branch from 775a704 to 554a75e Compare September 26, 2026 15:23
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.
@dmnyc
dmnyc force-pushed the feat/lazarus-data-recovery branch from 554a75e to 4b06e03 Compare September 26, 2026 15:26
@dmnyc
dmnyc merged commit 9e8eb9d into zapcooking:main Sep 26, 2026
5 checks passed
@dmnyc
dmnyc deleted the feat/lazarus-data-recovery branch September 26, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants