From 93d0f0ae437f5a26abd35c0b6419dedf8ad271b6 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 26 Sep 2026 10:44:07 -0400 Subject: [PATCH 1/4] fix(recovery): tell unreachable relays from empty ones, validate relay answers Every relay request now ends as answered, failed or timed out, and only an answered relay counts as having nothing. Versions a relay sent before failing stay candidates, a scan no relay answered is an error rather than an empty result, and nothing is recommended until one of the user's write relays has answered. When the app has no relay list for the user, a direct lookup tells a missing list (the defaults stand in, and the page says so) from one that couldn't be fetched (current stays unconfirmed). The results show how many relays answered, with each relay's outcome on request. A scanned event counts only with a valid signature, the scanned author and the requested kind. The re-read before a restore goes through checkLazarusCurrent: only a newer version counts as a change, and without an answer from a write relay the restore stops. A restore succeeds when one write relay accepts it. Profile changes cover every field and tag a restore would replace. Spec: https://github.com/dmnyc/lazarus/blob/main/SPEC.md (0.6.0-draft) --- src/i18n/locales/en.ts | 19 +- .../secondary/LazarusSettingsPage/index.tsx | 117 +++++- src/services/lazarus/recovery.spec.ts | 356 ++++++++++++++-- src/services/lazarus/recovery.ts | 391 ++++++++++++++---- 4 files changed, 756 insertions(+), 127 deletions(-) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 888b3ef..6a23328 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1188,6 +1188,23 @@ export default { 'Your list changed since this review, so the changes above now compare against the newest version. Check them and restore again.', 'No profile fields would change.': 'No profile fields would change.', 'If another client clobbered your follows, mutes, profile, or bookmarks, scan relay history to find and restore an older version': - 'If another client clobbered your follows, mutes, profile, or bookmarks, scan relay history to find and restore an older version' + 'If another client clobbered your follows, mutes, profile, or bookmarks, scan relay history to find and restore an older version', + 'Could not reach your write relays to confirm the current version. Nothing was published.': + 'Could not reach your write relays to confirm the current version. Nothing was published.', + '{{answered}} of {{queried}} relays answered': '{{answered}} of {{queried}} relays answered', + Answered: 'Answered', + Failed: 'Failed', + 'Timed out': 'Timed out', + 'No relay finished answering, so these versions may be incomplete. Scan again to retry.': + 'No relay finished answering, so these versions may be incomplete. Scan again to retry.', + 'Could not fetch your relay list, so the newest version found may not be current and nothing is recommended. Scan again to retry.': + 'Could not fetch your relay list, so the newest version found may not be current and nothing is recommended. Scan again to retry.', + 'None of your write relays answered, so the newest version found may not be current and nothing is recommended. Scan again to retry.': + 'None of your write relays answered, so the newest version found may not be current and nothing is recommended. Scan again to retry.', + 'No relay list found for this account, so the default relays stand in as its write relays.': + 'No relay list found for this account, so the default relays stand in as its write relays.', + 'No versions found. The relays that answered may have no history of this list.': + 'No versions found. The relays that answered may have no history of this list.', + 'No recoverable improvement found.': 'No recoverable improvement found.' } } diff --git a/src/pages/secondary/LazarusSettingsPage/index.tsx b/src/pages/secondary/LazarusSettingsPage/index.tsx index dbf373f..889d67f 100644 --- a/src/pages/secondary/LazarusSettingsPage/index.tsx +++ b/src/pages/secondary/LazarusSettingsPage/index.tsx @@ -29,9 +29,9 @@ import indexedDb from '@/services/indexed-db.service' import { applyLazarusPrivateTags, buildLazarusRecoveryDraft, + checkLazarusCurrent, computeLazarusDelta, computeLazarusProfileChanges, - fetchLatestLazarusVersion, fitsLazarusRemoteRestore, getContentEncryption, getLazarusItemRange, @@ -39,8 +39,11 @@ import { getLazarusPublishRelays, groupLazarusCandidates, isPastEmptyVersion, + lazarusScanReachedNoRelay, loadOlderLazarusVersions, parsePrivateTags, + publishLazarusRecovery, + readLazarusCurrent, scanLazarusKind, sortLazarusCandidates, type LazarusCandidate, @@ -68,6 +71,15 @@ function formatDate(seconds: number): string { return dayjs.unix(seconds).format('YYYY-MM-DD') } +function ScanNotice({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + function formatItemRange(itemCount: LazarusItemCount): string { const { min, max } = getLazarusItemRange(itemCount) return min === max ? `${min}` : `≈ ${min}–${max}` @@ -208,6 +220,7 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { const [sortBy, setSortBy] = useState('date') const [preparingReview, setPreparingReview] = useState() const [showEmpty, setShowEmpty] = useState(false) + const [showRelays, setShowRelays] = useState(false) // Bumped whenever a new scan starts, so a slow decryption can't land on a newer scan const scanIdRef = useRef(0) // The latest decrypted private items and notes, so decryptions that finish @@ -415,7 +428,7 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { setDecryptingIds(new Set()) setExpandedGroups(new Set()) setShowEmpty(false) - setShowEmpty(false) + setShowRelays(false) setLoadingOlder(false) try { const result = await scanLazarusKind(kind, pubkey) @@ -555,26 +568,31 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { try { // The list may have changed since the review opened (another column, // device or client), and restoring over it would drop those edits - const [onRelays, stored] = await Promise.all([ - fetchLatestLazarusVersion(chosen.kind, pubkey), + const [answers, stored] = await Promise.all([ + readLazarusCurrent(chosen.kind, pubkey), indexedDb.getReplaceableEvent(pubkey, chosen.kind) ]) - let latest = pending.current - for (const event of [onRelays, stored]) { - if (event && (!latest || event.created_at > latest.created_at)) latest = event + const check = checkLazarusCurrent(pending.current, stored ?? undefined, answers) + if (check.status === 'unconfirmed') { + toast.error( + t( + 'Could not reach your write relays to confirm the current version. Nothing was published.' + ) + ) + return } - if (latest && latest.id !== pending.current?.id) { - await reviewAgainst(latest) + if (check.status === 'changed') { + await reviewAgainst(check.current) return } - const draft = buildLazarusRecoveryDraft(chosen, { current: latest }) + const draft = buildLazarusRecoveryDraft(chosen, { current: check.current }) const signed = await signEvent(draft) if (signed.pubkey !== chosen.pubkey) { toast.error(t('This version belongs to another account. Switch back to it to restore.')) return } const { write, extra } = await getLazarusPublishRelays(pubkey, scan?.respondingRelays ?? []) - await client.publishEvent(write, signed) + await publishLazarusRecovery(write, signed) // The other relays that answered hold older copies of the list; replace // them where they accept it, without holding up the result if (extra.length > 0) client.publishEvent(extra, signed).catch(() => {}) @@ -713,12 +731,72 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { )} {phase === 'done' && scan && (
- {scan.candidates.length === 0 ? ( -
+ {scan.relayOutcomes && ( +
+ + {showRelays && ( +
    + {scan.queriedRelays.map((url) => ( +
  • + {url} + + {scan.relayOutcomes?.[url] === 'answered' + ? t('Answered') + : scan.relayOutcomes?.[url] === 'timed-out' + ? t('Timed out') + : t('Failed')} + +
  • + ))} +
+ )} +
+ )} + {lazarusScanReachedNoRelay(scan) ? ( + {t( - 'No versions found. This scan saw {{queried}} relays and {{responding}} answered, so an empty result may just mean the relays asked had no history.', - { queried: scan.queriedRelays.length, responding: scan.respondingRelays.length } + 'No relay finished answering, so these versions may be incomplete. Scan again to retry.' )} + + ) : ( + !scan.currentConfirmed && ( + + {scan.relayList === 'unknown' + ? t( + 'Could not fetch your relay list, so the newest version found may not be current and nothing is recommended. Scan again to retry.' + ) + : t( + 'None of your write relays answered, so the newest version found may not be current and nothing is recommended. Scan again to retry.' + )} + + ) + )} + {scan.relayList === 'missing' && ( + + {t( + 'No relay list found for this account, so the default relays stand in as its write relays.' + )} + + )} + {scan.candidates.length === 0 ? ( +
+ {t('No versions found. The relays that answered may have no history of this list.')}
) : ( <> @@ -737,10 +815,17 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { {t('Decrypting private items…')}
)} - {scan.recommended && ( + {scan.recommended ? (
{renderCandidate(scan.recommended)}
+ ) : ( + sortable && + scan.currentConfirmed && ( +
+ {t('No recoverable improvement found.')} +
+ ) )} {sortable && scan.candidates.length > 1 && (
diff --git a/src/services/lazarus/recovery.spec.ts b/src/services/lazarus/recovery.spec.ts index e9901a0..6dbcc49 100644 --- a/src/services/lazarus/recovery.spec.ts +++ b/src/services/lazarus/recovery.spec.ts @@ -12,13 +12,20 @@ vi.mock('@/services/fetchers/relay-list.service', () => ({ })) vi.mock('@/services/client.service', () => ({ default: { + publishEvent: vi.fn(), subscribe: vi.fn() } })) vi.mock('@/lib/relay', () => ({ getDefaultRelayUrls: () => ['wss://default'] })) +// The fixtures aren't signed; tests that need a forged event override this +vi.mock('nostr-tools/pure', async (importOriginal) => ({ + ...(await importOriginal()), + verifyEvent: vi.fn(() => true) +})) +import { verifyEvent } from 'nostr-tools/pure' import client from '@/services/client.service' import relayListService from '@/services/fetchers/relay-list.service' import { normalizeUrl } from '@/lib/url' @@ -26,17 +33,20 @@ import { getLazarusKindProfile, LAZARUS_REGISTRY } from './registry' import { applyLazarusPrivateTags, buildLazarusRecoveryDraft, + checkLazarusCurrent, computeLazarusDelta, computeLazarusProfileChanges, - fetchLatestLazarusVersion, fitsLazarusRemoteRestore, getLazarusItemRange, getLazarusPublishRelays, - getLazarusScanRelays, + getLazarusScanPlan, groupLazarusCandidates, LAZARUS_ARCHIVAL_RELAYS, + lazarusScanReachedNoRelay, loadOlderLazarusVersions, + publishLazarusRecovery, rankLazarusCandidates, + readLazarusCurrent, scanLazarusKind, sortLazarusCandidates, type LazarusListItem, @@ -401,9 +411,10 @@ describe('computeLazarusDelta', () => { }) describe('computeLazarusProfileChanges', () => { + const profileEvent = (content: object, tags: string[][] = []) => + ({ ...makeEvent({ kind: 0 }), content: JSON.stringify(content), tags }) as Event + it('lists only the profile fields that change', () => { - const profileEvent = (content: object) => - ({ ...makeEvent({ kind: 0 }), content: JSON.stringify(content) }) as Event const current = profileEvent({ name: 'clobbered', about: 'same' }) const chosen = profileEvent({ name: 'Daniel', about: 'same', picture: 'https://pic' }) expect(computeLazarusProfileChanges(chosen, current)).toEqual([ @@ -411,6 +422,85 @@ describe('computeLazarusProfileChanges', () => { { field: 'picture', from: undefined, to: 'https://pic' } ]) }) + + it('covers every field and tag a restore would replace', () => { + const current = profileEvent({ name: 'same', pronouns: 'they/them' }, [ + ['emoji', 'wave', 'https://wave'] + ]) + const chosen = profileEvent({ name: 'same', bot: false }) + expect(computeLazarusProfileChanges(chosen, current)).toEqual([ + { field: 'bot', from: undefined, to: 'false' }, + { field: 'pronouns', from: 'they/them', to: undefined }, + { field: 'emoji tags', from: 'wave https://wave', to: undefined } + ]) + }) + + it('ignores tag order', () => { + const tags = [ + ['emoji', 'a', 'https://a'], + ['emoji', 'b', 'https://b'] + ] + const current = profileEvent({ name: 'same' }, tags) + const chosen = profileEvent({ name: 'same' }, [...tags].reverse()) + expect(computeLazarusProfileChanges(chosen, current)).toEqual([]) + }) +}) + +describe('checkLazarusCurrent', () => { + // Explicit ids: makeEvent's padded ids can repeat + const version = (id: string, createdAt: number) => ({ + ...followListEvent(5, createdAt), + id: id.padStart(64, '0') + }) + const reviewed = version('c1', 2000) + const older = version('c2', 1000) + const newer = version('c3', 3000) + const answered = (...events: Event[]) => ({ events, answered: true }) + const unanswered = (...events: Event[]) => ({ events, answered: false }) + + it('proceeds over an older copy on the write relays', () => { + expect(checkLazarusCurrent(reviewed, older, [answered(older), answered()])).toEqual({ + status: 'proceed', + current: reviewed + }) + }) + + it('reports a newer version from a write relay or the local copy as a change', () => { + expect(checkLazarusCurrent(reviewed, undefined, [answered(older), answered(newer)])).toEqual({ + status: 'changed', + current: newer + }) + expect(checkLazarusCurrent(reviewed, newer, [answered()])).toEqual({ + status: 'changed', + current: newer + }) + // A relay that sent a newer version and then failed still shows the edit + expect(checkLazarusCurrent(reviewed, undefined, [unanswered(newer)])).toEqual({ + status: 'changed', + current: newer + }) + }) + + it('treats a version found when none was reviewed as a change', () => { + expect(checkLazarusCurrent(undefined, undefined, [answered(older)])).toEqual({ + status: 'changed', + current: older + }) + }) + + it('refuses when no write relay answered', () => { + expect(checkLazarusCurrent(reviewed, older, [unanswered(older), unanswered()])).toEqual({ + status: 'unconfirmed' + }) + expect(checkLazarusCurrent(reviewed, undefined, [])).toEqual({ status: 'unconfirmed' }) + }) + + it('counts one empty answer as enough', () => { + expect(checkLazarusCurrent(reviewed, undefined, [unanswered(), answered()])).toEqual({ + status: 'proceed', + current: reviewed + }) + }) }) describe('buildLazarusRecoveryDraft', () => { @@ -570,14 +660,48 @@ describe('groupLazarusCandidates', () => { }) }) -describe('getLazarusScanRelays', () => { - it('scans every user relay, the defaults, and the archival set', async () => { - vi.mocked(relayListService.fetchRelayList).mockResolvedValueOnce({ - write: ['wss://w1/', 'wss://w2/', 'wss://w3/', 'wss://w4/', 'wss://w5/', 'wss://w6/'], - read: ['wss://hist.nostr.land/', 'wss://r1/'], - originalRelays: [] +/** A relay list as the service returns it once it has found the user's kind 10002. */ +function relayListOf(write: string[], read: string[] = []) { + return { + write, + read, + originalRelays: [...write, ...read].map((url) => ({ url, scope: 'both' as const })) + } +} + +/** Every relay answers every request (EOSE), serving `events` whatever the filter. */ +function answerAll(events: Event[] = []) { + vi.mocked(client.subscribe).mockImplementation((_urls, _filter, handlers) => { + queueMicrotask(() => { + events.forEach((event) => handlers.onevent?.(event)) + handlers.oneose?.(true) }) - const relays = await getLazarusScanRelays('pubkey') + return { close: () => {} } + }) +} + +/** Every relay's connection fails before EOSE. */ +function failAll() { + vi.mocked(client.subscribe).mockImplementation((_urls, _filter, handlers) => { + queueMicrotask(() => handlers.onAllClose?.(['connection failed'])) + return { close: () => {} } + }) +} + +describe('getLazarusScanPlan', () => { + afterEach(() => { + vi.mocked(client.subscribe).mockReset() + }) + + it('scans every user relay, the defaults, and the archival set', async () => { + vi.mocked(relayListService.fetchRelayList).mockResolvedValueOnce( + relayListOf( + ['wss://w1/', 'wss://w2/', 'wss://w3/', 'wss://w4/', 'wss://w5/', 'wss://w6/'], + ['wss://hist.nostr.land/', 'wss://r1/'] + ) + ) + const { relays, relayList } = await getLazarusScanPlan('pubkey') + expect(relayList).toBe('found') // Past the first five write relays, and read relays too expect(relays).toEqual(expect.arrayContaining(['wss://w6/', 'wss://r1/', 'wss://default/'])) for (const url of LAZARUS_ARCHIVAL_RELAYS) { @@ -588,21 +712,52 @@ describe('getLazarusScanRelays', () => { expect(new Set(relays).size).toBe(relays.length) }) - it('still scans the default and archival sets without a relay list', async () => { + it('looks the relay list up itself when the service has none, taking the newest', async () => { vi.mocked(relayListService.fetchRelayList).mockRejectedValueOnce(new Error('offline')) - const relays = await getLazarusScanRelays('pubkey') - expect(relays).toContain('wss://default/') - expect(relays).toContain('wss://hist.nostr.land/') + const relayListEvent = (id: string, createdAt: number, url: string) => ({ + ...makeEvent({ kind: 10002, created_at: createdAt }), + id: id.padStart(64, '0'), + tags: [['r', url, 'write']] + }) + answerAll([relayListEvent('b1', 1000, 'wss://old/'), relayListEvent('b2', 2000, 'wss://new/')]) + const plan = await getLazarusScanPlan('test-pubkey') + expect(plan.relayList).toBe('found') + expect(plan.write).toEqual(['wss://new/']) + }) + + it('lets the defaults stand in when relays answered without a relay list', async () => { + vi.mocked(relayListService.fetchRelayList).mockRejectedValueOnce(new Error('offline')) + answerAll() + const plan = await getLazarusScanPlan('pubkey') + expect(plan.relayList).toBe('missing') + expect(plan.write).toEqual(['wss://default/']) + expect(plan.relays).toContain('wss://hist.nostr.land/') + }) + + it('never substitutes the defaults when no relay answered the lookup', async () => { + vi.mocked(relayListService.fetchRelayList).mockRejectedValueOnce(new Error('offline')) + failAll() + const plan = await getLazarusScanPlan('pubkey') + expect(plan.relayList).toBe('unknown') + expect(plan.write).toEqual([]) + // The default and archival sets are still scanned + expect(plan.relays).toContain('wss://default/') + expect(plan.relays).toContain('wss://hist.nostr.land/') }) }) describe('getLazarusPublishRelays', () => { + afterEach(() => { + vi.mocked(client.subscribe).mockReset() + }) + it('judges success on the write relays and sends the rest as extras', async () => { - vi.mocked(relayListService.fetchRelayList).mockResolvedValueOnce({ - write: ['wss://w1/', 'wss://w2/', 'wss://w3/', 'wss://w4/', 'wss://w5/', 'wss://w6/'], - read: ['wss://r1/'], - originalRelays: [] - }) + vi.mocked(relayListService.fetchRelayList).mockResolvedValueOnce( + relayListOf( + ['wss://w1/', 'wss://w2/', 'wss://w3/', 'wss://w4/', 'wss://w5/', 'wss://w6/'], + ['wss://r1/'] + ) + ) const relays = await getLazarusPublishRelays('pubkey', ['wss://hist.nostr.land', 'wss://w1/']) expect(relays).toEqual({ write: ['wss://w1/', 'wss://w2/', 'wss://w3/', 'wss://w4/', 'wss://w5/', 'wss://w6/'], @@ -610,8 +765,9 @@ describe('getLazarusPublishRelays', () => { }) }) - it('falls back to the default relays without a relay list', async () => { + it('falls back to the default relays when the user has no relay list', async () => { vi.mocked(relayListService.fetchRelayList).mockRejectedValueOnce(new Error('offline')) + answerAll() expect(await getLazarusPublishRelays('pubkey', ['wss://a/'])).toEqual({ write: ['wss://default/'], extra: ['wss://a/'] @@ -619,6 +775,31 @@ describe('getLazarusPublishRelays', () => { }) }) +describe('publishLazarusRecovery', () => { + const event = followListEvent(3, 1000) + + afterEach(() => { + vi.mocked(client.publishEvent).mockReset() + }) + + it('succeeds when one write relay accepts, short of the client threshold', async () => { + vi.mocked(client.publishEvent).mockRejectedValueOnce( + new AggregateError([new Error('wss://w2/: blocked'), new Error('wss://w3/: blocked')]) + ) + await expect( + publishLazarusRecovery(['wss://w1/', 'wss://w2/', 'wss://w3/'], event) + ).resolves.toBeUndefined() + }) + + it('fails when no write relay accepts', async () => { + vi.mocked(client.publishEvent).mockRejectedValueOnce( + new AggregateError([new Error('wss://w1/: blocked'), new Error('wss://w2/: blocked')]) + ) + await expect(publishLazarusRecovery(['wss://w1/', 'wss://w2/'], event)).rejects.toThrow() + await expect(publishLazarusRecovery([], event)).rejects.toThrow() + }) +}) + describe('relay queries', () => { const HISTORY_RELAY = 'wss://hist.nostr.land/' // 70 versions on one relay: more than one page. Explicit ids, since @@ -683,37 +864,144 @@ describe('relay queries', () => { expect(older.olderCursors).toEqual({}) }) - it('closes relay subscriptions that time out', async () => { + it('closes relay subscriptions that time out, and fails a scan no relay answered', async () => { vi.useFakeTimers() try { const close = vi.fn() vi.mocked(client.subscribe).mockImplementation(() => ({ close })) const pending = scanLazarusKind(3, 'test-pubkey') - await vi.advanceTimersByTimeAsync(6000) - const scan = await pending - expect(scan.respondingRelays).toEqual([]) - expect(close).toHaveBeenCalledTimes(scan.queriedRelays.length) + // Attach the rejection handler before the timers fire + const failed = expect(pending).rejects.toThrow('No relay answered the scan') + // The relay list lookup times out, then the scan + await vi.advanceTimersByTimeAsync(12000) + await failed + expect(close).toHaveBeenCalledTimes(vi.mocked(client.subscribe).mock.calls.length) } finally { vi.useRealTimers() } }) - it('finds the newest version on the write relays before a restore', async () => { - vi.mocked(relayListService.fetchRelayList).mockResolvedValue({ - write: ['wss://w1/', 'wss://w2/'], - read: [], - originalRelays: [] + it('records how each relay ended, keeping versions sent before a failure', async () => { + vi.mocked(relayListService.fetchRelayList).mockResolvedValue(relayListOf(['wss://w1/'])) + const partial = { ...followListEvent(5, 1000), id: 'p1'.padStart(64, '0') } + vi.mocked(client.subscribe).mockImplementation((urls, _filter, handlers) => { + queueMicrotask(() => { + if (urls[0] === HISTORY_RELAY) { + // Sends a version, then its connection drops before EOSE + handlers.onevent?.(partial) + return handlers.onAllClose?.(['connection dropped']) + } + if (urls[0] === 'wss://w1/') return handlers.onAllClose?.(['auth-required: sign in']) + handlers.oneose?.(true) + }) + return { close: () => {} } + }) + const scan = await scanLazarusKind(3, 'test-pubkey') + expect(scan.candidates.map((c) => c.event.id)).toEqual([partial.id]) + expect(scan.relayOutcomes?.[HISTORY_RELAY]).toBe('failed') + expect(scan.relayOutcomes?.['wss://w1/']).toBe('failed') + expect(scan.relayOutcomes?.['wss://nos.lol/']).toBe('answered') + // The only write relay failed, so current is unconfirmed + expect(scan.currentConfirmed).toBe(false) + expect(lazarusScanReachedNoRelay(scan)).toBe(false) + }) + + it('recommends nothing while no write relay answered', async () => { + vi.mocked(relayListService.fetchRelayList).mockResolvedValue(relayListOf(['wss://w1/'])) + // A clear clobber on the history relay: 40 follows, then 3 + const full = { ...followListEvent(40, 1000), id: 'd1'.padStart(64, '0') } + const clobbered = { ...followListEvent(3, 2000), id: 'd2'.padStart(64, '0') } + const serveClobber = (writeRelayAnswers: boolean) => + vi.mocked(client.subscribe).mockImplementation((urls, _filter, handlers) => { + queueMicrotask(() => { + if (urls[0] === 'wss://w1/' && !writeRelayAnswers) { + return handlers.onAllClose?.(['connection failed']) + } + if (urls[0] === HISTORY_RELAY) [full, clobbered].forEach((e) => handlers.onevent?.(e)) + handlers.oneose?.(true) + }) + return { close: () => {} } + }) + serveClobber(true) + expect((await scanLazarusKind(3, 'test-pubkey')).recommended?.event.id).toBe(full.id) + serveClobber(false) + const unconfirmed = await scanLazarusKind(3, 'test-pubkey') + expect(unconfirmed.currentConfirmed).toBe(false) + expect(unconfirmed.recommended).toBeUndefined() + }) + + it('shows versions that arrived even when no relay answered', async () => { + vi.mocked(relayListService.fetchRelayList).mockResolvedValue(relayListOf(['wss://w1/'])) + const partial = { ...followListEvent(5, 1000), id: 'e1'.padStart(64, '0') } + vi.mocked(client.subscribe).mockImplementation((urls, _filter, handlers) => { + queueMicrotask(() => { + if (urls[0] === HISTORY_RELAY) handlers.onevent?.(partial) + handlers.onAllClose?.(['connection dropped']) + }) + return { close: () => {} } }) - const older = followListEvent(5, 1000) + const scan = await scanLazarusKind(3, 'test-pubkey') + expect(scan.candidates.map((c) => c.event.id)).toEqual([partial.id]) + expect(lazarusScanReachedNoRelay(scan)).toBe(true) + }) + + it('counts only valid versions of the list from each relay', async () => { + vi.mocked(verifyEvent).mockImplementation((event) => event.sig !== 'forged') + try { + const version = (id: string, overrides: Partial = {}) => ({ + ...followListEvent(5, 1000), + id: id.padStart(64, '0'), + ...overrides + }) + const valid = version('v1') + const foreign = version('v2', { pubkey: 'someone-else' }) + const wrongKind = version('v3', { kind: 10000 }) + // A full page of forged versions must not count or move a cursor + const forged = Array.from({ length: 50 }, (_, i) => + version(`f${i}`, { sig: 'forged', created_at: 900 + i }) + ) + vi.mocked(client.subscribe).mockImplementation((urls, _filter, handlers) => { + queueMicrotask(() => { + const events = + urls[0] === HISTORY_RELAY + ? [valid, foreign, wrongKind] + : urls[0] === 'wss://nos.lol/' + ? forged + : [] + events.forEach((event) => handlers.onevent?.(event)) + handlers.oneose?.(true) + }) + return { close: () => {} } + }) + const scan = await scanLazarusKind(3, 'test-pubkey') + expect(scan.candidates.map((c) => c.event.id)).toEqual([valid.id]) + expect(scan.respondingRelays).toEqual([HISTORY_RELAY]) + expect(scan.olderCursors).toEqual({}) + } finally { + vi.mocked(verifyEvent).mockImplementation(() => true) + } + }) + + it('reads every write relay before a restore, telling failed reads from empty ones', async () => { + vi.mocked(relayListService.fetchRelayList).mockResolvedValue( + relayListOf(['wss://w1/', 'wss://w2/', 'wss://w3/']) + ) const newer = followListEvent(6, 2000) + const foreign = { ...followListEvent(6, 3000), pubkey: 'someone-else' } vi.mocked(client.subscribe).mockImplementation((urls, _filter, handlers) => { queueMicrotask(() => { - handlers.onevent?.(urls[0] === 'wss://w1/' ? older : newer) + // w3's connection fails: closed before finishing, nothing sent + if (urls[0] === 'wss://w3/') return handlers.onAllClose?.(['connection failed']) + handlers.onevent?.(urls[0] === 'wss://w1/' ? foreign : newer) handlers.oneose?.(true) }) return { close: () => {} } }) - expect((await fetchLatestLazarusVersion(3, 'test-pubkey'))?.id).toBe(newer.id) + expect(await readLazarusCurrent(3, 'test-pubkey')).toEqual([ + { events: [], answered: true }, + { events: [newer], answered: true }, + { events: [], answered: false } + ]) }) }) diff --git a/src/services/lazarus/recovery.ts b/src/services/lazarus/recovery.ts index ba4fc14..3465cfa 100644 --- a/src/services/lazarus/recovery.ts +++ b/src/services/lazarus/recovery.ts @@ -1,4 +1,5 @@ import { kinds, type Event, type Filter } from 'nostr-tools' +import { verifyEvent } from 'nostr-tools/pure' import client from '@/services/client.service' import relayListService from '@/services/fetchers/relay-list.service' import { fitsNip46Request } from '@/lib/nip46' @@ -46,6 +47,20 @@ export interface LazarusTaggedEvent { relayUrl: string } +/** + * How a relay request ended: the relay sent EOSE, failed (couldn't connect, + * or closed the request or connection first), or timed out. Only an answered + * relay counts as having nothing. + */ +export type LazarusRelayOutcome = 'answered' | 'failed' | 'timed-out' + +/** + * The user's relay list: found (in the app's copy or on relays), missing + * (relays answered without one, so the defaults stand in as write relays), + * or unknown (no relay answered the lookup, so there are no write relays). + */ +export type LazarusRelayListStatus = 'found' | 'missing' | 'unknown' + export interface LazarusCandidate { event: Event /** Relay URLs where this exact event id was observed. */ @@ -65,7 +80,16 @@ export interface LazarusScanResult { /** True for meaningful-empty kinds: the user must choose with intent. */ requiresIntentConfirmation: boolean queriedRelays: string[] + /** Relays that returned at least one version. */ respondingRelays: string[] + /** + * True once at least one of the user's write relays answered. Until then + * the newest version found may not be current, and nothing is recommended. + */ + currentConfirmed: boolean + /** How each queried relay's request ended. */ + relayOutcomes?: Record + relayList?: LazarusRelayListStatus /** * Relays that may hold versions older than the scan returned, keyed to the * created_at to page back from. Empty when the scan saw everything. @@ -124,53 +148,141 @@ export interface LazarusRelaySource { respondingRelays: string[] /** Relays whose answer filled the page, keyed to the cursor for the next one. */ olderCursors?: Record + /** How each relay's request ended. */ + outcomes?: Record + /** + * Whether one of the user's write relays answered. A source that doesn't + * report it is taken as confirmed. + */ + currentConfirmed?: boolean + relayList?: LazarusRelayListStatus }> } +/** What a relay sent in answer to a request, and how the request ended. */ +interface LazarusRelayAnswer { + events: Event[] + outcome: LazarusRelayOutcome +} + /** * One relay's answer to a filter. The subscription is closed as soon as the - * relay finishes or times out, so a slow relay doesn't stay subscribed after - * the scan moves on; a timeout counts as no answer. + * relay finishes, fails or times out, so a slow relay doesn't stay subscribed + * after the scan moves on. Only EOSE counts as an answer. Events that arrived + * before a failure or timeout are kept: they're real versions, even though + * that relay's history is incomplete. */ function fetchFromRelay( url: string, filter: Filter, timeoutMs: number = SCAN_TIMEOUT_MS -): Promise { - return new Promise((resolve, reject) => { +): Promise { + return new Promise((resolve) => { const events: Event[] = [] let done = false - const finish = (error?: Error) => { + const finish = (outcome: LazarusRelayOutcome) => { if (done) return done = true clearTimeout(timer) sub.close() - if (error) reject(error) - else resolve(events) + resolve({ events, outcome }) } - const timer = setTimeout(() => finish(new Error('relay timeout')), timeoutMs) + const timer = setTimeout(() => finish('timed-out'), timeoutMs) const sub = client.subscribe([url], filter, { onevent: (event) => { events.push(event) }, oneose: (eosed) => { - if (eosed) finish() + if (eosed) finish('answered') }, - onAllClose: () => finish() + onAllClose: () => finish('failed') }) }) } +/** Ask several relays at once. A request that couldn't even start counts as failed. */ +async function fetchFromRelays( + urls: string[], + filterFor: (url: string) => Filter, + timeoutMs?: number +): Promise { + const results = await Promise.allSettled( + urls.map((url) => fetchFromRelay(url, filterFor(url), timeoutMs)) + ) + return results.map((result) => + result.status === 'fulfilled' ? result.value : { events: [], outcome: 'failed' } + ) +} + +/** + * Whether an event a relay returned counts as a version of the scanned list. + * Relays are untrusted: one can return events outside the filter, or forged + * ones, and a restore would sign their content as the user's own. + */ +export function isLazarusVersion(event: Event, kind: number, pubkey: string): boolean { + return event.kind === kind && event.pubkey === pubkey && verifyEvent(event) +} + function uniqueRelayUrls(urls: string[]): string[] { return Array.from(new Set(urls.map((url) => normalizeUrl(url)).filter(Boolean))) } -async function getWriteRelays(pubkey: string): Promise { +/** The relays a kind 10002 names. An unmarked relay is both read and write (NIP-65). */ +function parseRelayList(event: Event): { read: string[]; write: string[] } { + const read: string[] = [] + const write: string[] = [] + for (const [name, url, marker] of event.tags) { + if (name !== 'r' || !url) continue + if (marker !== 'write') read.push(url) + if (marker !== 'read') write.push(url) + } + return { read: uniqueRelayUrls(read), write: uniqueRelayUrls(write) } +} + +/** + * The user's relay list: the app's own copy when it has one, otherwise a + * lookup on the default and archival relays, which tells a missing list + * (relays answered without one) from one that couldn't be fetched (no relay + * answered). A missing list, or one naming no write relays, lets the + * defaults stand in as write relays. An unknown one leaves none, so current + * can't be confirmed. + */ +async function getLazarusUserRelays( + pubkey: string +): Promise<{ read: string[]; write: string[]; relayList: LazarusRelayListStatus }> { + let found: { read: string[]; write: string[] } | undefined try { - return uniqueRelayUrls((await relayListService.fetchRelayList(pubkey)).write) + const relayList = await relayListService.fetchRelayList(pubkey) + // Without a list, the service stands its defaults in with no original relays + if (relayList.originalRelays.length > 0) { + found = { read: uniqueRelayUrls(relayList.read), write: uniqueRelayUrls(relayList.write) } + } } catch { - return uniqueRelayUrls(getDefaultRelayUrls()) + // Look the list up directly below + } + if (!found) { + const answers = await fetchFromRelays( + uniqueRelayUrls([...getDefaultRelayUrls(), ...LAZARUS_ARCHIVAL_RELAYS]), + () => ({ kinds: [kinds.RelayList], authors: [pubkey], limit: 1 }) + ) + const newest = answers + .flatMap((answer) => answer.events) + .filter((event) => isLazarusVersion(event, kinds.RelayList, pubkey)) + .sort((a, b) => b.created_at - a.created_at)[0] + if (newest) { + found = parseRelayList(newest) + } else if (!answers.some((answer) => answer.outcome === 'answered')) { + return { read: [], write: [], relayList: 'unknown' } + } + } + if (!found || found.write.length === 0) { + return { + read: found?.read ?? [], + write: uniqueRelayUrls(getDefaultRelayUrls()), + relayList: 'missing' + } } + return { ...found, relayList: 'found' } } /** @@ -193,17 +305,23 @@ export const LAZARUS_ARCHIVAL_RELAYS = [ /** * Relays to scan: every relay in the user's relay list (read and write, not * just the first few outbox relays), the app's default relays, and the - * archival set. + * archival set. The plan also names the user's write relays, since current + * is confirmed only once one of them answers. */ -export async function getLazarusScanRelays(pubkey: string): Promise { - let userRelays: string[] = [] - try { - const relayList = await relayListService.fetchRelayList(pubkey) - userRelays = [...relayList.write, ...relayList.read] - } catch { - // Without the user's relay list, still scan the default and archival sets +export async function getLazarusScanPlan( + pubkey: string +): Promise<{ relays: string[]; write: string[]; relayList: LazarusRelayListStatus }> { + const user = await getLazarusUserRelays(pubkey) + return { + relays: uniqueRelayUrls([ + ...user.write, + ...user.read, + ...getDefaultRelayUrls(), + ...LAZARUS_ARCHIVAL_RELAYS + ]), + write: user.write, + relayList: user.relayList } - return uniqueRelayUrls([...userRelays, ...getDefaultRelayUrls(), ...LAZARUS_ARCHIVAL_RELAYS]) } /** @@ -216,60 +334,72 @@ export async function getLazarusPublishRelays( pubkey: string, respondingRelays: string[] ): Promise<{ write: string[]; extra: string[] }> { - const write = await getWriteRelays(pubkey) + const { write } = await getLazarusUserRelays(pubkey) const extra = uniqueRelayUrls(respondingRelays).filter((url) => !write.includes(url)) return { write, extra } } /** - * The newest version on the user's write relays right now, to catch edits - * made after a scan (from another column, device or client) before a restore - * overwrites them. + * Publish a signed recovery to the user's write relays. It succeeds when at + * least one of them accepts it. The client's own publish asks more of a long + * relay list, and rejects once every relay has finished short of that, even + * when one accepted. + */ +export async function publishLazarusRecovery(write: string[], event: Event): Promise { + if (write.length === 0) throw new Error('No write relays to publish to') + try { + await client.publishEvent(write, event) + } catch (error) { + const failed = error instanceof AggregateError ? error.errors.length : write.length + if (failed >= write.length) throw error + } +} + +/** + * Every write relay's answer to the re-read before a restore, for + * checkLazarusCurrent. It catches edits made after a scan (from another + * column, device or client) before a restore overwrites them. */ -export async function fetchLatestLazarusVersion( +export async function readLazarusCurrent( kind: number, pubkey: string -): Promise { - const write = await getWriteRelays(pubkey) - const results = await Promise.allSettled( - write.map((url) => fetchFromRelay(url, { kinds: [kind], authors: [pubkey], limit: 1 }, 4000)) +): Promise { + const { write } = await getLazarusUserRelays(pubkey) + const answers = await fetchFromRelays( + write, + () => ({ kinds: [kind], authors: [pubkey], limit: 1 }), + 4000 ) - 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 + return answers.map(({ events, outcome }) => ({ + events: events.filter((event) => isLazarusVersion(event, kind, pubkey)), + answered: outcome === 'answered' + })) } /** * Per-relay scanning so each candidate keeps an accurate found-on list and - * the result can report which relays actually answered. A relay that fails + * the result can report how each relay's request ended. A relay that fails * or times out never counts as "nothing found". */ export const defaultLazarusRelaySource: LazarusRelaySource = { async fetchVersions(kind, pubkey, cursors) { - const urls = cursors ? Object.keys(cursors) : await getLazarusScanRelays(pubkey) - - const results = await Promise.allSettled( - urls.map((url) => { - const filter = { kinds: [kind], authors: [pubkey], limit: SCAN_LIMIT } - const page = cursors ? { ...filter, until: cursors[url] } : filter - return fetchFromRelay(url, page) - }) - ) + const plan = cursors ? undefined : await getLazarusScanPlan(pubkey) + const urls = plan ? plan.relays : Object.keys(cursors ?? {}) + const answers = await fetchFromRelays(urls, (url) => { + const filter = { kinds: [kind], authors: [pubkey], limit: SCAN_LIMIT } + return cursors ? { ...filter, until: cursors[url] } : filter + }) const tagged: LazarusTaggedEvent[] = [] const respondingRelays: string[] = [] const olderCursors: Record = {} - results.forEach((result, index) => { - if (result.status !== 'fulfilled') return + const outcomes: Record = {} + answers.forEach(({ events, outcome }, index) => { const url = urls[index] - const relayEvents = result.value + outcomes[url] = outcome + // Only valid versions of the list count, as candidates, toward the + // relays that returned versions, and for paging + const relayEvents = events.filter((event) => isLazarusVersion(event, kind, pubkey)) if (relayEvents.length > 0) respondingRelays.push(url) for (const event of relayEvents) { tagged.push({ event, relayUrl: url }) @@ -283,7 +413,17 @@ export const defaultLazarusRelaySource: LazarusRelaySource = { } }) - return { tagged, queriedRelays: urls, respondingRelays, olderCursors } + return { + tagged, + queriedRelays: urls, + respondingRelays, + olderCursors, + outcomes, + ...(plan && { + currentConfirmed: plan.write.some((url) => outcomes[url] === 'answered'), + relayList: plan.relayList + }) + } } } @@ -296,12 +436,35 @@ export function scanLazarusKind( if (!profile) { return Promise.reject(new Error(`kind ${kind} is not in the Lazarus registry`)) } - return source - .fetchVersions(kind, pubkey) - .then(({ tagged, queriedRelays, respondingRelays, olderCursors }) => ({ - ...rankLazarusCandidates(profile, tagged, queriedRelays, respondingRelays), - olderCursors: olderCursors ?? {} - })) + return source.fetchVersions(kind, pubkey).then((result) => { + // A scan no relay answered failed: it isn't an empty result. Versions that + // arrived before the relays failed are still shown. + if (result.outcomes && result.tagged.length === 0 && !answeredAny(result.outcomes)) { + throw new Error('No relay answered the scan') + } + return { + ...rankLazarusCandidates( + profile, + result.tagged, + result.queriedRelays, + result.respondingRelays, + new Map(), + result.currentConfirmed ?? true + ), + olderCursors: result.olderCursors ?? {}, + relayOutcomes: result.outcomes, + relayList: result.relayList + } + }) +} + +function answeredAny(outcomes: Record): boolean { + return Object.values(outcomes).includes('answered') +} + +/** True when the scan got versions but no relay answered: what arrived may be incomplete. */ +export function lazarusScanReachedNoRelay(scan: LazarusScanResult): boolean { + return !!scan.relayOutcomes && !answeredAny(scan.relayOutcomes) } /** @@ -324,9 +487,12 @@ export async function loadOlderLazarusVersions( [...scanToTagged(scan), ...older.tagged], scan.queriedRelays, Array.from(new Set([...scan.respondingRelays, ...older.respondingRelays])), - privateTags + privateTags, + scan.currentConfirmed ), - olderCursors: older.olderCursors ?? {} + olderCursors: older.olderCursors ?? {}, + relayOutcomes: scan.relayOutcomes, + relayList: scan.relayList } } @@ -420,7 +586,8 @@ export function rankLazarusCandidates( taggedEvents: LazarusTaggedEvent[], queriedRelays: string[] = [], respondingRelays: string[] = [], - privateTags: LazarusPrivateTags = new Map() + privateTags: LazarusPrivateTags = new Map(), + currentConfirmed = true ): LazarusScanResult { const byId = new Map() for (const { event, relayUrl } of taggedEvents) { @@ -461,8 +628,9 @@ export function rankLazarusCandidates( ordered = [...candidates].sort( (a, b) => size(b) - size(a) || b.event.created_at - a.event.created_at ) - // Nothing is recommended while the current size is unknown - if (current && isLazarusSizeKnown(current.itemCount)) { + // Nothing is recommended while the current size is unknown, or while no + // write relay answered: current may be a version the user already replaced + if (currentConfirmed && current && isLazarusSizeKnown(current.itemCount)) { recommended = findRestorePoint(candidates, current) } } else { @@ -481,7 +649,8 @@ export function rankLazarusCandidates( recommended, requiresIntentConfirmation, queriedRelays, - respondingRelays + respondingRelays, + currentConfirmed } } @@ -500,9 +669,12 @@ export function applyLazarusPrivateTags( scanToTagged(scan), scan.queriedRelays, scan.respondingRelays, - privateTags + privateTags, + scan.currentConfirmed ), - olderCursors: scan.olderCursors + olderCursors: scan.olderCursors, + relayOutcomes: scan.relayOutcomes, + relayList: scan.relayList } } @@ -661,7 +833,7 @@ export function computeLazarusDelta( } } -/** Profile fields a restore can change, in the order they're shown. */ +/** Well-known profile fields, shown first in this order. */ const PROFILE_FIELDS = [ 'name', 'display_name', @@ -680,7 +852,14 @@ export interface LazarusProfileChange { to?: string } -/** The profile (kind 0) fields a restore would change: its data lives in content, not tags. */ +/** + * The profile (kind 0) fields a restore would change. Profile content is + * extensible (pronouns, bot, client-specific fields) and a restore replaces + * all of it, tags included, so every field and tag counts: the well-known + * fields first, then any other field, then tags by name (NIP-30 custom emoji + * live there). Values that aren't strings show as JSON, and an empty string + * reads as absent. + */ export function computeLazarusProfileChanges( chosen: Event, current: Event | undefined @@ -693,13 +872,73 @@ export function computeLazarusProfileChanges( return {} } } - const text = (value: unknown) => (typeof value === 'string' && value.trim() ? value : undefined) + // Tag order carries no meaning here, so each tag name compares as a sorted set + const tagsOf = (event: Event | undefined): Record => { + const byName: Record = {} + for (const [name, ...values] of event?.tags ?? []) { + if (name) (byName[`${name} tags`] ??= []).push(values.join(' ')) + } + return Object.fromEntries( + Object.entries(byName).map(([field, values]) => [field, values.sort().join(', ')]) + ) + } + const text = (value: unknown) => { + if (typeof value === 'string') return value.trim() ? value : undefined + return value === undefined || value === null ? undefined : JSON.stringify(value) + } const to = fieldsOf(chosen) const from = fieldsOf(current) - return PROFILE_FIELDS.flatMap((field) => { - const change = { field, from: text(from[field]), to: text(to[field]) } - return change.from === change.to ? [] : [change] - }) + const toTags = tagsOf(chosen) + const fromTags = tagsOf(current) + const otherFields = Array.from(new Set([...Object.keys(from), ...Object.keys(to)])) + .filter((field) => !PROFILE_FIELDS.includes(field)) + .sort() + const tagFields = Array.from(new Set([...Object.keys(fromTags), ...Object.keys(toTags)])).sort() + return [ + ...[...PROFILE_FIELDS, ...otherFields].map((field) => ({ + field, + from: text(from[field]), + to: text(to[field]) + })), + ...tagFields.map((field) => ({ field, from: fromTags[field], to: toTags[field] })) + ].filter((change) => change.from !== change.to) +} + +/** + * One write relay's answer to the re-read before a restore: the valid + * versions it sent, and whether it answered (sent EOSE). Versions from a + * relay that failed or timed out still show an edit. + */ +export interface LazarusReadAnswer { + events: Event[] + answered: boolean +} + +export type LazarusCurrentCheck = + | { status: 'proceed'; current: Event | undefined } + | { status: 'changed'; current: Event } + | { status: 'unconfirmed' } + +/** + * Decide the re-read before a restore. The list changed only if the local + * copy or a write relay holds a version newer than the one the delta was + * computed against: the re-read asks fewer relays than the scan, so an older + * copy is no edit. Otherwise at least one write relay must have answered (an + * answer with no events counts), or current can't be confirmed and the + * restore must not go ahead. The local copy can't confirm it on its own. + */ +export function checkLazarusCurrent( + reviewed: Event | undefined, + local: Event | undefined, + answers: LazarusReadAnswer[] +): LazarusCurrentCheck { + let newest = reviewed + for (const event of [local, ...answers.flatMap((answer) => answer.events)]) { + if (event && (!newest || event.created_at > newest.created_at)) newest = event + } + if (newest && newest.id !== reviewed?.id) return { status: 'changed', current: newest } + if (!answers.some((answer) => answer.answered)) return { status: 'unconfirmed' } + return { status: 'proceed', current: reviewed } } export interface LazarusRecoveryDraft { From 189d682b91cef52d49edc88575044a5660101fba Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 26 Sep 2026 15:36:49 -0400 Subject: [PATCH 2/4] fix(recovery): count the n tags NIP-4e uses for encryption keys The kind 10044 registry entry counted p tags, but NIP-4e lists encryption pubkeys in n tags, so every key list scanned as empty. Spec: https://github.com/dmnyc/lazarus/blob/main/SPEC.md (0.6.1-draft) --- src/services/lazarus/recovery.spec.ts | 16 ++++++++++++++-- src/services/lazarus/registry.ts | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/services/lazarus/recovery.spec.ts b/src/services/lazarus/recovery.spec.ts index 6dbcc49..0f76c75 100644 --- a/src/services/lazarus/recovery.spec.ts +++ b/src/services/lazarus/recovery.spec.ts @@ -117,6 +117,18 @@ describe('registry', () => { expect(profile?.ranking).toBe('intent') }) + it('counts the n tags NIP-4e lists encryption keys in', () => { + const keyList = { + ...makeEvent({ kind: 10044 }), + tags: [ + ['n', 'a'.repeat(64)], + ['n', 'b'.repeat(64)], + ['p', 'c'.repeat(64)] + ] + } as Event + expect(getLazarusKindProfile(10044)?.itemCount(keyList).count).toBe(2) + }) + it('never returns profiles for unregistered kinds', () => { expect(getLazarusKindProfile(30078)).toBeUndefined() expect(getLazarusKindProfile(1)).toBeUndefined() @@ -293,7 +305,7 @@ describe('rankLazarusCandidates', () => { it('recommends nothing for meaningful-empty kinds and requires intent', () => { const keys = { ...makeEvent({ kind: 10044, created_at: 1000 }), - tags: [['p', 'encryption-pubkey-1']] + tags: [['n', 'encryption-pubkey-1']] } as Event const emptied = { ...makeEvent({ kind: 10044, created_at: 2000 }), tags: [] } as Event const result = rankLazarusCandidates(LAZARUS_REGISTRY[10044], [ @@ -637,7 +649,7 @@ describe('groupLazarusCandidates', () => { it('keeps empty versions of meaningful-empty kinds, where empty is a valid option', () => { const events = [1000, 1001].map((createdAt, i) => ({ ...makeEvent({ created_at: createdAt, kind: 10044 }), - tags: i === 0 ? [] : [['p', 'a'.repeat(64)]] + tags: i === 0 ? [] : [['n', 'a'.repeat(64)]] })) const scan = rankLazarusCandidates( LAZARUS_REGISTRY[10044], diff --git a/src/services/lazarus/registry.ts b/src/services/lazarus/registry.ts index 638ade6..842dae0 100644 --- a/src/services/lazarus/registry.ts +++ b/src/services/lazarus/registry.ts @@ -123,7 +123,8 @@ export const LAZARUS_REGISTRY: Record = { ranking: 'intent', meaningfulEmpty: true, requiredWarnings: ['affects-others'], - itemCount: countTags(['p']) + // NIP-4e lists encryption pubkeys in `n` tags + itemCount: countTags(['n']) }, 10002: { kind: 10002, From db34fc864a27831594dbde9079da43142f5bd9ef Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 26 Sep 2026 19:09:15 -0400 Subject: [PATCH 3/4] fix(recovery): ask each relay directly, and follow spec 0.6.2 The scan asked relays through the client's shared subscription, which reports a relay that couldn't connect as finished. A dead relay counted as answered with nothing, so a refused write relay could confirm current and let the re-read before a restore pass. Each relay is now asked directly: only EOSE is an answer, a refused connection or a CLOSED is a failure, and a relay's own EOSE timeout can't stand in for one. Scans authenticate only to relays in the user's own list. Items are counted once each, a tag without a value isn't one, a profile's items are its fields, and relay URLs compare normalized. Versions from the same second order as NIP-01 keeps them, including in the re-read. The review says which side's private items couldn't be decrypted; uncounted ones on current, like a shrinking restore, need a separate confirmation, now a checkbox. Kind 10044 states what both versions mean in the restore's direction and asks for intent. Spec: https://github.com/dmnyc/lazarus/blob/main/SPEC.md (0.6.2-draft) --- src/i18n/locales/en.ts | 31 +- .../secondary/LazarusSettingsPage/index.tsx | 83 +++- src/services/lazarus/private-items.spec.ts | 26 +- src/services/lazarus/private-items.ts | 4 - src/services/lazarus/recovery.spec.ts | 415 ++++++++++++++---- src/services/lazarus/recovery.ts | 296 +++++++++---- src/services/lazarus/registry.ts | 147 +++++-- 7 files changed, 762 insertions(+), 240 deletions(-) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6a23328..8f2255a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -875,8 +875,8 @@ export default { 'Invalid npub': 'Invalid npub', 'Already paired as {{name}}': 'Already paired as {{name}}', 'this agent': 'this agent', - "Get your agent's npub from your runtime's startup output (e.g. Claude Agent SDK + ContextVM Proxy CLI prints \"agent npub: npub1...\"; other MCP-over-Nostr runtimes use similar formats)": - "Get your agent's npub from your runtime's startup output (e.g. Claude Agent SDK + ContextVM Proxy CLI prints \"agent npub: npub1...\"; other MCP-over-Nostr runtimes use similar formats)", + 'Get your agent\'s npub from your runtime\'s startup output (e.g. Claude Agent SDK + ContextVM Proxy CLI prints "agent npub: npub1..."; other MCP-over-Nostr runtimes use similar formats)': + 'Get your agent\'s npub from your runtime\'s startup output (e.g. Claude Agent SDK + ContextVM Proxy CLI prints "agent npub: npub1..."; other MCP-over-Nostr runtimes use similar formats)', 'Display name (optional)': 'Display name (optional)', 'e.g. Claude on my laptop': 'e.g. Claude on my laptop', 'Scope: Read-only': 'Scope: Read-only', @@ -904,8 +904,7 @@ export default { 'Allow agents to see your other paired accounts', "On: agents see columns that reference your other paired accounts' npubs.": "On: agents see columns that reference your other paired accounts' npubs.", - 'Off (default): agents only see this account.': - 'Off (default): agents only see this account.', + 'Off (default): agents only see this account.': 'Off (default): agents only see this account.', 'Allow sibling exposure?': 'Allow sibling exposure?', 'Agents paired to this account will be able to see:': 'Agents paired to this account will be able to see:', @@ -913,8 +912,7 @@ export default { 'Columns in this deck that view your other paired jank accounts (their npubs become visible to the agent)', 'This does NOT change what events the agent can publish (still none) or what other accounts the agent can read directly (still just this one).': 'This does NOT change what events the agent can publish (still none) or what other accounts the agent can read directly (still just this one).', - 'You can turn this off again at any time.': - 'You can turn this off again at any time.', + 'You can turn this off again at any time.': 'You can turn this off again at any time.', 'Allow exposure': 'Allow exposure', 'Relatr Discovery': 'Relatr Discovery', 'Topic to discover': 'Topic to discover', @@ -1011,8 +1009,7 @@ export default { 'Message your agent…': 'Message your agent…', 'Sign in to chat with your agent': 'Sign in to chat with your agent', 'Loading conversation…': 'Loading conversation…', - 'No messages yet. Say hello to your agent.': - 'No messages yet. Say hello to your agent.', + 'No messages yet. Say hello to your agent.': 'No messages yet. Say hello to your agent.', 'Could not load conversation': 'Could not load conversation', 'Message failed to send': 'Message failed to send', 'Agent chat npub (optional)': 'Agent chat npub (optional)', @@ -1157,8 +1154,6 @@ export default { '{{count}} items would be removed_other': '{{count}} items would be removed', 'This restores fewer items than you have now. It will also re-apply moderation from that version for mute lists.': 'This restores fewer items than you have now. It will also re-apply moderation from that version for mute lists.', - 'Private items in one of these versions could not be decrypted, so the changes above cover public items only. By size, the selected version has {{chosen}} items and your current one has {{current}}.': - 'Private items in one of these versions could not be decrypted, so the changes above cover public items only. By size, the selected version has {{chosen}} items and your current one has {{current}}.', 'This version is too large to sign with a remote signer: NIP-46 requests are limited to 64 KB. Large lists, like a mute list with many private items or a big follow list, can only be restored with a browser extension signer (NIP-07) or a local key.': 'This version is too large to sign with a remote signer: NIP-46 requests are limited to 64 KB. Large lists, like a mute list with many private items or a big follow list, can only be restored with a browser extension signer (NIP-07) or a local key.', 'Accounts present now but missing from the restored version would be unmuted.': @@ -1205,6 +1200,20 @@ export default { 'No relay list found for this account, so the default relays stand in as its write relays.', 'No versions found. The relays that answered may have no history of this list.': 'No versions found. The relays that answered may have no history of this list.', - 'No recoverable improvement found.': 'No recoverable improvement found.' + 'No recoverable improvement found.': 'No recoverable improvement found.', + 'Private items in the selected version could not be decrypted, so the changes above leave them out. By size, it has {{size}} items.': + 'Private items in the selected version could not be decrypted, so the changes above leave them out. By size, it has {{size}} items.', + 'Private items in your current version could not be decrypted, so the changes above leave them out. The restore replaces them, so it may remove items no count shows. By size, your current version has {{size}} items.': + 'Private items in your current version could not be decrypted, so the changes above leave them out. The restore replaces them, so it may remove items no count shows. By size, your current version has {{size}} items.', + 'This restores your NIP-4e encryption keys. Clients will encrypt direct messages to them again.': + 'This restores your NIP-4e encryption keys. Clients will encrypt direct messages to them again.', + 'This announces that you no longer use NIP-4e; clients stop encrypting direct messages to your keys.': + 'This announces that you no longer use NIP-4e; clients stop encrypting direct messages to your keys.', + 'The current empty state announces that you do not use NIP-4e.': + 'The current empty state announces that you do not use NIP-4e.', + 'Your current version lists keys that clients encrypt direct messages to.': + 'Your current version lists keys that clients encrypt direct messages to.', + 'I intend this change': 'I intend this change', + 'I understand this can remove items I have now': 'I understand this can remove items I have now' } } diff --git a/src/pages/secondary/LazarusSettingsPage/index.tsx b/src/pages/secondary/LazarusSettingsPage/index.tsx index 889d67f..2b24cb9 100644 --- a/src/pages/secondary/LazarusSettingsPage/index.tsx +++ b/src/pages/secondary/LazarusSettingsPage/index.tsx @@ -11,6 +11,7 @@ import { TriangleAlert } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, @@ -19,6 +20,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { fitsNip46Request } from '@/lib/nip46' import { useNostr } from '@/providers/NostrProvider' @@ -212,6 +214,10 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { | undefined >() const [recovering, setRecovering] = useState(false) + // Separate confirmations for a restore that removes items, and for one + // whose empty state has a meaning of its own (kind 10044) + const [shrinkConfirmed, setShrinkConfirmed] = useState(false) + const [intentConfirmed, setIntentConfirmed] = useState(false) const [privateTags, setPrivateTags] = useState>(new Map()) const [privateItemsNotes, setPrivateItemsNotes] = useState>({}) const [decryptingIds, setDecryptingIds] = useState>(new Set()) @@ -385,6 +391,13 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { } }, [scrollContainer]) + // A confirmation belongs to the review it was given in: a new review, or + // one compared again against a newer version, asks again + useEffect(() => { + setShrinkConfirmed(false) + setIntentConfirmed(false) + }, [pending]) + // A scan belongs to the account it ran for, so switching accounts starts over useEffect(() => { scanIdRef.current += 1 @@ -938,22 +951,49 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
)} - {pending.delta.privateUnknown && ( + {pending.delta.privateUnknownChosen && ( +
+ + + {t( + 'Private items in the selected version could not be decrypted, so the changes above leave them out. By size, it has {{size}} items.', + { size: formatItemRange(pending.candidate.itemCount) } + )} + +
+ )} + {pending.delta.privateUnknownCurrent && pending.current && (
{t( - 'Private items in one of these versions could not be decrypted, so the changes above cover public items only. By size, the selected version has {{chosen}} items and your current one has {{current}}.', - { - chosen: formatItemRange(pending.candidate.itemCount), - current: pending.current - ? formatItemRange(pending.profile.itemCount(pending.current)) - : '0' - } + 'Private items in your current version could not be decrypted, so the changes above leave them out. The restore replaces them, so it may remove items no count shows. By size, your current version has {{size}} items.', + { size: formatItemRange(pending.profile.itemCount(pending.current)) } )}
)} + {pending.profile.meaningfulEmpty && ( +
+
+ {getLazarusItemRange(pending.candidate.itemCount).max === 0 + ? t( + 'This announces that you no longer use NIP-4e; clients stop encrypting direct messages to your keys.' + ) + : t( + 'This restores your NIP-4e encryption keys. Clients will encrypt direct messages to them again.' + )} +
+
+ {!pending.current || + getLazarusItemRange(pending.profile.itemCount(pending.current)).max === 0 + ? t('The current empty state announces that you do not use NIP-4e.') + : t( + 'Your current version lists keys that clients encrypt direct messages to.' + )} +
+
+ )} {pending.tooLargeForRemoteSigner && (
@@ -982,6 +1022,26 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => { {t('This list affects how other accounts interact with you.')}
)} + {pending.profile.meaningfulEmpty && ( + + )} + {pending.delta.needsShrinkConfirmation && ( + + )}
)} @@ -990,7 +1050,12 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {