diff --git a/src/components/LazarusLetterforms.tsx b/src/components/LazarusLetterforms.tsx
new file mode 100644
index 0000000..65d1f26
--- /dev/null
+++ b/src/components/LazarusLetterforms.tsx
@@ -0,0 +1,35 @@
+/**
+ * Lazarus wordmark ("LAZARUS"), the recovery spec's brand letterforms
+ * (Metal Lord by Ray Larabie / Typodermic Fonts, CC0 1.0; geometry from
+ * github.com/dmnyc/lazarus). A small, subdued mark beside the spec
+ * attribution, tinted to the host theme. `fill` inherits `currentColor`
+ * (like JankMark), so it themes across light/dark and every preset —
+ * pair with a softened accent class (e.g. `text-primary/60`) so it reads
+ * secondary next to a `text-primary` link.
+ *
+ * Canvas is 1460.36×358.6 (≈4.07:1). The Data recovery page anchors it at
+ * 20px in the titlebar's right slot. Decorative: the spec link
+ * names Lazarus in text.
+ */
+export default function LazarusLetterforms({
+ height = 16,
+ className
+}: {
+ height?: number
+ className?: string
+}) {
+ return (
+
+
+
+ )
+}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 888b3ef..076f61c 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.':
@@ -1188,6 +1183,39 @@ 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.',
+ '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',
+ 'Follows the': 'Follows the',
+ 'recovery spec (0.6.2-draft).': 'recovery spec (0.6.2-draft).'
}
}
diff --git a/src/pages/secondary/LazarusSettingsPage/index.tsx b/src/pages/secondary/LazarusSettingsPage/index.tsx
index dbf373f..8a52f24 100644
--- a/src/pages/secondary/LazarusSettingsPage/index.tsx
+++ b/src/pages/secondary/LazarusSettingsPage/index.tsx
@@ -11,6 +11,8 @@ import {
TriangleAlert
} from 'lucide-react'
import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import LazarusLetterforms from '@/components/LazarusLetterforms'
import {
Dialog,
DialogContent,
@@ -19,6 +21,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'
@@ -29,9 +32,9 @@ import indexedDb from '@/services/indexed-db.service'
import {
applyLazarusPrivateTags,
buildLazarusRecoveryDraft,
+ checkLazarusCurrent,
computeLazarusDelta,
computeLazarusProfileChanges,
- fetchLatestLazarusVersion,
fitsLazarusRemoteRestore,
getContentEncryption,
getLazarusItemRange,
@@ -39,8 +42,11 @@ import {
getLazarusPublishRelays,
groupLazarusCandidates,
isPastEmptyVersion,
+ lazarusScanReachedNoRelay,
loadOlderLazarusVersions,
parsePrivateTags,
+ publishLazarusRecovery,
+ readLazarusCurrent,
scanLazarusKind,
sortLazarusCandidates,
type LazarusCandidate,
@@ -68,6 +74,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}`
@@ -200,6 +215,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())
@@ -208,6 +227,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
@@ -372,6 +392,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
@@ -415,7 +442,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 +582,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(() => {})
@@ -641,7 +673,14 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
if (!pubkey) {
return (
-
+
+ }
+ >
{t('Sign in to scan your relay history for lost data.')}
@@ -650,13 +689,32 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
}
return (
-
+
+ }
+ >
{t(
'Scan your relays for older versions of your lists and profiles, and restore one if a client clobbered it. Scans are read-only. Nothing is published until you review the change and confirm.'
)}
+
+ {t('Follows the')}{' '}
+
+ Lazarus
+ {' '}
+ {t('recovery spec (0.6.2-draft).')}
+
{usesRemoteSigner && (
@@ -713,12 +771,72 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
)}
{phase === 'done' && scan && (
- {scan.candidates.length === 0 ? (
-
+ {scan.relayOutcomes && (
+
+
setShowRelays((shown) => !shown)}
+ >
+ {t('{{answered}} of {{queried}} relays answered', {
+ answered: Object.values(scan.relayOutcomes).filter(
+ (outcome) => outcome === 'answered'
+ ).length,
+ queried: scan.queriedRelays.length
+ })}
+ {showRelays ? (
+
+ ) : (
+
+ )}
+
+ {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 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 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 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 +855,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 && (
@@ -853,22 +978,49 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
)}
- {pending.delta.privateUnknown && (
+ {pending.delta.privateUnknownChosen && (
{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 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 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 && (
@@ -897,6 +1049,26 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
{t('This list affects how other accounts interact with you.')}
)}
+ {pending.profile.meaningfulEmpty && (
+
+ setIntentConfirmed(!!checked)}
+ />
+ {t('I intend this change')}
+
+ )}
+ {pending.delta.needsShrinkConfirmation && (
+
+ setShrinkConfirmed(!!checked)}
+ />
+
+ {t('I understand this can remove items I have now')}
+
+
+ )}
)}
@@ -905,7 +1077,12 @@ const LazarusSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
{recovering ? (
diff --git a/src/services/lazarus/private-items.spec.ts b/src/services/lazarus/private-items.spec.ts
index c4040ec..5ba41e2 100644
--- a/src/services/lazarus/private-items.spec.ts
+++ b/src/services/lazarus/private-items.spec.ts
@@ -2,7 +2,6 @@ import { bytesToHex } from '@noble/hashes/utils'
import { generateSecretKey, getPublicKey, nip04, nip44 } from 'nostr-tools'
import { describe, expect, it } from 'vitest'
import {
- countItemTags,
estimatePrivateItems,
getContentEncryption,
getPlaintextLengthRange,
@@ -63,6 +62,20 @@ describe('estimatePrivateItems', () => {
expect(estimate.max).toBeGreaterThanOrEqual(40)
})
+ it('sizes NIP-44 padded lengths that are not powers of two', () => {
+ // 300 bytes pad to 320, which holds any plaintext from 257 bytes up
+ const content = nip44.encrypt('x'.repeat(300), conversationKey)
+ expect(getPlaintextLengthRange(content)).toEqual({ min: 257, max: 320 })
+ expect(estimatePrivateItems(content)).toEqual({ min: 3, max: 5 })
+ })
+
+ it('lets a one-block NIP-04 payload hold no items', async () => {
+ // One block holds 0 to 15 bytes, so the minimum is zero, never negative
+ const content = await nip04.encrypt(secretKey, pubkey, '[]')
+ expect(getPlaintextLengthRange(content)).toEqual({ min: 0, max: 15 })
+ expect(estimatePrivateItems(content)).toEqual({ min: 0, max: 1 })
+ })
+
it('returns undefined for payloads that are not a valid size', () => {
expect(estimatePrivateItems('A'.repeat(133))).toBeUndefined()
expect(estimatePrivateItems('plain text')).toBeUndefined()
@@ -76,15 +89,4 @@ describe('parsePrivateTags', () => {
expect(parsePrivateTags('{"not":"tags"}')).toBeUndefined()
expect(parsePrivateTags('not json')).toBeUndefined()
})
-
- it('counts only the item tag types asked for', () => {
- const tags = [
- ['p', 'a'],
- ['word', 'spam'],
- ['t', 'nsfw'],
- ['e', 'x'],
- ['alt', 'ignored']
- ]
- expect(countItemTags(tags, ['p', 'word', 't', 'e'])).toBe(4)
- })
})
diff --git a/src/services/lazarus/private-items.ts b/src/services/lazarus/private-items.ts
index b26228f..dda3aae 100644
--- a/src/services/lazarus/private-items.ts
+++ b/src/services/lazarus/private-items.ts
@@ -102,7 +102,3 @@ export function parsePrivateTags(plainText: string): string[][] | undefined {
return undefined
}
}
-
-export function countItemTags(tags: string[][], types: string[]): number {
- return tags.filter((tag) => types.includes(tag[0])).length
-}
diff --git a/src/services/lazarus/recovery.spec.ts b/src/services/lazarus/recovery.spec.ts
index e9901a0..e33f76b 100644
--- a/src/services/lazarus/recovery.spec.ts
+++ b/src/services/lazarus/recovery.spec.ts
@@ -1,6 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { bytesToHex } from '@noble/hashes/utils'
-import { generateSecretKey, getPublicKey, nip44, type Event } from 'nostr-tools'
+import {
+ generateSecretKey,
+ getPublicKey,
+ nip44,
+ type Event,
+ type EventTemplate,
+ type Filter
+} from 'nostr-tools'
// Mock the services before importing the module under test: the real ones
// initialize local storage (window) at module load, and the relay source is
@@ -12,31 +19,44 @@ vi.mock('@/services/fetchers/relay-list.service', () => ({
}))
vi.mock('@/services/client.service', () => ({
default: {
- subscribe: vi.fn()
+ publishEvent: vi.fn(),
+ pool: { ensureRelay: vi.fn() },
+ getSignerFor: vi.fn(),
+ signer: undefined
}
}))
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'
-import { getLazarusKindProfile, LAZARUS_REGISTRY } from './registry'
+import type { IRelay } from '@/types/relay-pool'
+import { getLazarusKindProfile, LAZARUS_REGISTRY, normalizeLazarusRelayUrl } 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,
@@ -107,6 +127,53 @@ 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('counts each item once, and no tag without a value', () => {
+ const follows = {
+ ...makeEvent({ kind: 3 }),
+ tags: [['p', 'a'], ['p', 'a', 'wss://hint'], ['p', 'b'], ['p'], ['p', ''], ['alt', 'x']]
+ } as Event
+ expect(LAZARUS_REGISTRY[3].itemCount(follows).count).toBe(2)
+ })
+
+ it("counts a profile's fields, so one wiped to {} is empty", () => {
+ const profile = (content: string) => ({ ...makeEvent({ kind: 0 }), content }) as Event
+ const count = (content: string) => LAZARUS_REGISTRY[0].itemCount(profile(content)).count
+ expect(count('{"name":"a","about":"b","bot":null}')).toBe(2)
+ expect(count('{}')).toBe(0)
+ expect(count('not json')).toBe(0)
+ expect(count('["name"]')).toBe(0)
+ })
+
+ it('compares relay URLs normalized on the relay kinds', () => {
+ const relayList = (kind: number, name: string) =>
+ ({
+ ...makeEvent({ kind }),
+ tags: [
+ [name, 'wss://Relay.Example/'],
+ [name, 'wss://relay.example'],
+ [name, 'wss://relay.example:443//']
+ ]
+ }) as Event
+ expect(LAZARUS_REGISTRY[10002].itemCount(relayList(10002, 'r')).count).toBe(1)
+ expect(LAZARUS_REGISTRY[10050].itemCount(relayList(10050, 'relay')).count).toBe(1)
+ expect(LAZARUS_REGISTRY[10006].itemCount(relayList(10006, 'relay')).count).toBe(1)
+ expect(normalizeLazarusRelayUrl('wss://Relay.Example:443//path/')).toBe(
+ 'wss://relay.example/path'
+ )
+ })
+
it('never returns profiles for unregistered kinds', () => {
expect(getLazarusKindProfile(30078)).toBeUndefined()
expect(getLazarusKindProfile(1)).toBeUndefined()
@@ -280,10 +347,64 @@ describe('rankLazarusCandidates', () => {
expect(result.recommended?.event.id).toBe(versions[5].id)
})
+ it('takes the lowest id as current among versions from the same second', () => {
+ const higher = { ...followListEvent(10, 1000), id: 'b'.repeat(64) }
+ const lower = { ...followListEvent(12, 1000), id: 'a'.repeat(64) }
+ const result = rankLazarusCandidates(LAZARUS_REGISTRY[3], [
+ { event: higher, relayUrl: 'wss://a' },
+ { event: lower, relayUrl: 'wss://a' }
+ ])
+ expect(result.current?.event.id).toBe(lower.id)
+ })
+
+ it('recommends the version before a small list was emptied', () => {
+ const small = followListEvent(3, 1000)
+ const emptied = followListEvent(0, 2000)
+ const result = rankLazarusCandidates(LAZARUS_REGISTRY[3], [
+ { event: emptied, relayUrl: 'wss://a' },
+ { event: small, relayUrl: 'wss://a' }
+ ])
+ expect(result.recommended?.event.id).toBe(small.id)
+ })
+
+ it("measures a settled clobber from its episode's last drop", () => {
+ const hour = 60 * 60
+ const full = followListEvent(100, 0)
+ const events = [
+ full,
+ followListEvent(10, hour),
+ // Edits on the clobbered list, then a second drop the same day: one episode
+ ...[2, 3, 4, 5, 6].map((h) => followListEvent(12, h * hour)),
+ followListEvent(2, 7 * hour),
+ followListEvent(3, 8 * 24 * hour)
+ ]
+ const result = rankLazarusCandidates(
+ LAZARUS_REGISTRY[3],
+ events.map((event) => ({ event, relayUrl: 'wss://a' }))
+ )
+ // One version follows the episode's last drop, so it isn't settled
+ expect(result.recommended?.event.id).toBe(full.id)
+ })
+
+ it('leaves versions of unknown size out of drops, and never recommends one', () => {
+ // 50 public mutes, and private ones that can be neither decrypted nor sized
+ const unsizable = { ...muteListEvent(50, 1000), content: 'A'.repeat(133) }
+ const current = muteListEvent(10, 2000)
+ const rank = (...events: Event[]) =>
+ rankLazarusCandidates(
+ LAZARUS_REGISTRY[10000],
+ events.map((event) => ({ event, relayUrl: 'wss://a' }))
+ )
+ expect(rank(current, unsizable).recommended).toBeUndefined()
+ // Consecutive means consecutive among versions of known size
+ const full = muteListEvent(50, 500)
+ expect(rank(current, unsizable, full).recommended?.event.id).toBe(full.id)
+ })
+
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], [
@@ -400,10 +521,52 @@ describe('computeLazarusDelta', () => {
})
})
+describe('computeLazarusDelta items', () => {
+ it('counts a duplicated tag once, and ignores tags that are not items', () => {
+ const current = {
+ ...makeEvent({ kind: 3 }),
+ tags: [
+ ['p', 'a'],
+ ['p', 'a'],
+ ['client', 'x']
+ ]
+ } as Event
+ const chosen = {
+ ...makeEvent({ kind: 3 }),
+ tags: [['p', 'a'], ['p'], ['client', 'y']]
+ } as Event
+ const delta = computeLazarusDelta(chosen, current)
+ expect(delta.addedCount).toBe(0)
+ expect(delta.removedCount).toBe(0)
+ })
+
+ it('compares relay URLs normalized', () => {
+ const relayList = (url: string) =>
+ ({ ...makeEvent({ kind: 10002 }), tags: [['r', url, 'write']] }) as Event
+ const delta = computeLazarusDelta(
+ relayList('wss://relay.example'),
+ relayList('wss://Relay.Example/')
+ )
+ expect(delta.addedCount + delta.removedCount).toBe(0)
+ })
+
+ it('counts removed profile fields toward a shrink', () => {
+ const profile = (fields: object) =>
+ ({ ...makeEvent({ kind: 0 }), content: JSON.stringify(fields) }) as Event
+ const delta = computeLazarusDelta(
+ profile({ name: 'a' }),
+ profile({ name: 'b', about: 'c', lud16: 'd@e.f' })
+ )
+ expect(delta.removedCount).toBe(2)
+ expect(delta.needsShrinkConfirmation).toBe(true)
+ })
+})
+
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 +574,98 @@ 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('reports a version from the same second with a lower id as a change', () => {
+ const lowerId = version('c0', 2000)
+ expect(checkLazarusCurrent(reviewed, undefined, [answered(lowerId)])).toEqual({
+ status: 'changed',
+ current: lowerId
+ })
+ // One with a higher id is the version relays drop
+ expect(checkLazarusCurrent(reviewed, undefined, [answered(version('c9', 2000))])).toEqual({
+ status: 'proceed',
+ current: reviewed
+ })
+ })
+
+ 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', () => {
@@ -547,7 +802,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],
@@ -570,14 +825,70 @@ describe('groupLazarusCandidates', () => {
})
})
-describe('getLazarusScanRelays', () => {
+/** 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 }))
+ }
+}
+
+type RelayHandlers = {
+ onevent?: (event: Event) => void
+ oneose?: () => void
+ onclose?: (reason: string) => void
+ eoseTimeout?: number
+}
+
+/**
+ * Relays answer through the pool, the way the scan asks them: `respond` plays
+ * each relay's side of a request, a microtask after it's made. Relays in
+ * `refuse` refuse the connection.
+ */
+function relays(
+ respond: (url: string, filter: Filter, handlers: RelayHandlers) => void,
+ { refuse = [] }: { refuse?: string[] } = {}
+) {
+ vi.mocked(client.pool.ensureRelay).mockImplementation(async (url) => {
+ if (refuse.includes(url)) throw new Error('connection refused')
+ return {
+ url,
+ subscribe: (filters: Filter[], handlers: RelayHandlers) => {
+ queueMicrotask(() => respond(url, filters[0], handlers))
+ return { close: () => {} }
+ }
+ } as unknown as IRelay
+ })
+}
+
+/** Every relay answers every request (EOSE), serving `events` whatever the filter. */
+function answerAll(events: Event[] = []) {
+ relays((_url, _filter, handlers) => {
+ events.forEach((event) => handlers.onevent?.(event))
+ handlers.oneose?.()
+ })
+}
+
+/** Every relay refuses the connection. */
+function failAll() {
+ vi.mocked(client.pool.ensureRelay).mockRejectedValue(new Error('connection refused'))
+}
+
+describe('getLazarusScanPlan', () => {
+ afterEach(() => {
+ vi.mocked(client.pool.ensureRelay).mockReset()
+ })
+
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: []
- })
- const relays = await getLazarusScanRelays('pubkey')
+ 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 +899,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 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 every relay refuses the lookup', 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/')
+ 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.pool.ensureRelay).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 +952,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 +962,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
@@ -629,21 +997,18 @@ describe('relay queries', () => {
}))
const profile = getLazarusKindProfile(3)!
- // Relays answer through client.subscribe; this one serves a fixed history
+ // This relay serves a fixed history; every other relay answers with nothing
const serve = (relay: string, events: Event[], honorUntil = true) =>
- vi.mocked(client.subscribe).mockImplementation((urls, filter, handlers) => {
+ relays((url, filter, handlers) => {
const { until, limit = 50 } = filter as { until?: number; limit?: number }
- queueMicrotask(() => {
- if (urls[0] === relay) {
- events
- .filter((event) => !honorUntil || until === undefined || event.created_at <= until)
- .sort((a, b) => b.created_at - a.created_at)
- .slice(0, limit)
- .forEach((event) => handlers.onevent?.(event))
- }
- handlers.oneose?.(true)
- })
- return { close: () => {} }
+ if (url === relay) {
+ events
+ .filter((event) => !honorUntil || until === undefined || event.created_at <= until)
+ .sort((a, b) => b.created_at - a.created_at)
+ .slice(0, limit)
+ .forEach((event) => handlers.onevent?.(event))
+ }
+ handlers.oneose?.()
})
beforeEach(() => {
@@ -656,7 +1021,8 @@ describe('relay queries', () => {
afterEach(() => {
vi.mocked(relayListService.fetchRelayList).mockReset()
- vi.mocked(client.subscribe).mockReset()
+ vi.mocked(client.pool.ensureRelay).mockReset()
+ vi.mocked(client.getSignerFor).mockReset()
})
it('pages back from relays that filled a page', async () => {
@@ -683,37 +1049,201 @@ 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 subscribe = vi.fn(() => ({ close }))
+ vi.mocked(client.pool.ensureRelay).mockImplementation(
+ async (url) => ({ url, subscribe }) as unknown as IRelay
+ )
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(subscribe).toHaveBeenCalled()
+ expect(close).toHaveBeenCalledTimes(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('times a silent relay out before its own EOSE timeout can report an answer', async () => {
+ vi.useFakeTimers()
+ try {
+ // Like nostr-tools, a relay subscription reports EOSE by itself once its
+ // timeout passes, whether or not the relay sent one
+ vi.mocked(client.pool.ensureRelay).mockImplementation(
+ async (url) =>
+ ({
+ url,
+ subscribe: (_filters: Filter[], handlers: RelayHandlers) => {
+ const timer = setTimeout(() => handlers.oneose?.(), handlers.eoseTimeout ?? 4400)
+ return { close: () => clearTimeout(timer) }
+ }
+ }) as unknown as IRelay
+ )
+ const pending = scanLazarusKind(3, 'test-pubkey')
+ const failed = expect(pending).rejects.toThrow('No relay answered the scan')
+ await vi.advanceTimersByTimeAsync(12000)
+ await failed
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ 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') }
+ relays(
+ (url, _filter, handlers) => {
+ if (url === HISTORY_RELAY) {
+ // Sends a version, then its connection drops before EOSE
+ handlers.onevent?.(partial)
+ return handlers.onclose?.('relay connection closed')
+ }
+ // Asks to authenticate, with no signer to do it
+ if (url === 'wss://w1/') return handlers.onclose?.('auth-required: sign in')
+ if (url === 'wss://nostr.mom/') return handlers.onclose?.('blocked: rate limited')
+ handlers.oneose?.()
+ },
+ { refuse: ['wss://purplepag.es/'] }
+ )
+ 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')
+ // A CLOSED before EOSE, and a refused connection, are failures, never empty answers
+ expect(scan.relayOutcomes?.['wss://nostr.mom/']).toBe('failed')
+ expect(scan.relayOutcomes?.['wss://purplepag.es/']).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('authenticates only to relays in the user list', async () => {
+ vi.mocked(relayListService.fetchRelayList).mockResolvedValue(relayListOf(['wss://w1/']))
+ const signer = { signEvent: vi.fn(async (event: EventTemplate) => ({ ...event, sig: 'auth' })) }
+ vi.mocked(client.getSignerFor).mockReturnValue(signer as never)
+ const authenticated = new Set()
+ vi.mocked(client.pool.ensureRelay).mockImplementation(
+ async (url) =>
+ ({
+ url,
+ auth: async (sign: (event: EventTemplate) => Promise) => {
+ await sign({ kind: 22242, created_at: 0, tags: [], content: '' })
+ authenticated.add(url)
+ },
+ subscribe: (_filters: Filter[], handlers: RelayHandlers) => {
+ queueMicrotask(() => {
+ // The user's own relay and an archival one both require authentication
+ const restricted = url === 'wss://w1/' || url === HISTORY_RELAY
+ if (restricted && !authenticated.has(url)) {
+ return handlers.onclose?.('auth-required: sign in')
+ }
+ handlers.oneose?.()
+ })
+ return { close: () => {} }
+ }
+ }) as unknown as IRelay
+ )
+ const scan = await scanLazarusKind(3, 'test-pubkey')
+ expect([...authenticated]).toEqual(['wss://w1/'])
+ expect(signer.signEvent).toHaveBeenCalledTimes(1)
+ expect(scan.relayOutcomes?.['wss://w1/']).toBe('answered')
+ expect(scan.relayOutcomes?.[HISTORY_RELAY]).toBe('failed')
+ expect(scan.currentConfirmed).toBe(true)
+ })
+
+ 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) =>
+ relays(
+ (url, _filter, handlers) => {
+ if (url === HISTORY_RELAY) [full, clobbered].forEach((e) => handlers.onevent?.(e))
+ handlers.oneose?.()
+ },
+ { refuse: writeRelayAnswers ? [] : ['wss://w1/'] }
+ )
+ 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') }
+ relays((url, _filter, handlers) => {
+ if (url === HISTORY_RELAY) handlers.onevent?.(partial)
+ handlers.onclose?.('relay connection closed')
})
- const older = followListEvent(5, 1000)
- const newer = followListEvent(6, 2000)
- vi.mocked(client.subscribe).mockImplementation((urls, _filter, handlers) => {
- queueMicrotask(() => {
- handlers.onevent?.(urls[0] === 'wss://w1/' ? older : newer)
- handlers.oneose?.(true)
+ 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
})
- return { close: () => {} }
- })
- expect((await fetchLatestLazarusVersion(3, 'test-pubkey'))?.id).toBe(newer.id)
+ 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 })
+ )
+ relays((url, _filter, handlers) => {
+ const events =
+ url === HISTORY_RELAY
+ ? [valid, foreign, wrongKind]
+ : url === 'wss://nos.lol/'
+ ? forged
+ : []
+ events.forEach((event) => handlers.onevent?.(event))
+ handlers.oneose?.()
+ })
+ 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' }
+ // w3 refuses the connection, so it sends nothing and doesn't answer
+ relays(
+ (url, _filter, handlers) => {
+ handlers.onevent?.(url === 'wss://w1/' ? foreign : newer)
+ handlers.oneose?.()
+ },
+ { refuse: ['wss://w3/'] }
+ )
+ expect(await readLazarusCurrent(3, 'test-pubkey')).toEqual([
+ { events: [], answered: true },
+ { events: [newer], answered: true },
+ { events: [], answered: false }
+ ])
})
})
@@ -839,4 +1369,31 @@ describe('private items', () => {
const chosen = privateMuteListEvent(privateTags(5), 1000)
expect(computeLazarusDelta(chosen, current).privateUnknown).toBe(true)
})
+
+ it('says which side has private items that were not decrypted', () => {
+ const encrypted = privateMuteListEvent(privateTags(3), 2000)
+ const plain = { ...muteListEvent(0, 1000), tags: [['p', 'x']] }
+ const overEncrypted = computeLazarusDelta(plain, encrypted)
+ expect(overEncrypted.privateUnknownCurrent).toBe(true)
+ expect(overEncrypted.privateUnknownChosen).toBe(false)
+ // It adds an item, but replaces private items nobody counted
+ expect(overEncrypted.shrinks).toBe(false)
+ expect(overEncrypted.needsShrinkConfirmation).toBe(true)
+ const restoringEncrypted = computeLazarusDelta(encrypted, plain)
+ expect(restoringEncrypted.privateUnknownChosen).toBe(true)
+ expect(restoringEncrypted.privateUnknownCurrent).toBe(false)
+ })
+
+ it('treats identical encrypted content as the same private items', () => {
+ const tags = privateTags(4)
+ const current = privateMuteListEvent(tags, 2000)
+ const chosen = { ...current, id: 'f'.repeat(64), created_at: 1000, tags: [['p', 'x']] }
+ const delta = computeLazarusDelta(chosen, current)
+ expect(delta.privateUnknown).toBe(false)
+ expect(delta.needsShrinkConfirmation).toBe(false)
+ // Decrypting either one covers both
+ const decrypted = computeLazarusDelta(chosen, current, new Map([[current.id, tags]]))
+ expect(decrypted.added).toEqual([['p', 'x']])
+ expect(decrypted.removedCount).toBe(0)
+ })
})
diff --git a/src/services/lazarus/recovery.ts b/src/services/lazarus/recovery.ts
index ba4fc14..08bb969 100644
--- a/src/services/lazarus/recovery.ts
+++ b/src/services/lazarus/recovery.ts
@@ -1,11 +1,20 @@
-import { kinds, type Event, type Filter } from 'nostr-tools'
+import { kinds, type Event, type EventTemplate, type Filter, type VerifiedEvent } from 'nostr-tools'
+import { verifyEvent } from 'nostr-tools/pure'
+import { selectAuthSigner } from '@/services/auth-signer'
import client from '@/services/client.service'
import relayListService from '@/services/fetchers/relay-list.service'
import { fitsNip46Request } from '@/lib/nip46'
import { getDefaultRelayUrls } from '@/lib/relay'
import { normalizeUrl } from '@/lib/url'
-import { countItemTags, getContentEncryption } from './private-items'
-import { getLazarusKindProfile, type LazarusItemCount, type LazarusKindProfile } from './registry'
+import type { IRelay, TSubCloser } from '@/types/relay-pool'
+import { getContentEncryption } from './private-items'
+import {
+ getLazarusItems,
+ getLazarusKindProfile,
+ getLazarusProfileFields,
+ type LazarusItemCount,
+ type LazarusKindProfile
+} from './registry'
/**
* Lazarus core: scan, rank, delta, recover-draft.
@@ -40,12 +49,35 @@ const CLOBBER_EPISODE_SECONDS = 24 * 60 * 60
const SETTLED_MIN_EDITS = 5
const SETTLED_MIN_SECONDS = 7 * 24 * 60 * 60
+/**
+ * Newer first, in the order NIP-01 has relays keep versions: the later
+ * created_at, and of two from the same second, the lower id. Every "newer"
+ * and "consecutive" below follows it.
+ */
+export function compareLazarusVersions(a: Event, b: Event): number {
+ return b.created_at - a.created_at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
+}
+
/** An event together with the relay it was observed on. */
export interface LazarusTaggedEvent {
event: Event
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 +97,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.
@@ -102,10 +143,13 @@ function countItems(
): LazarusItemCount {
const itemCount = profile.itemCount(event)
if (!privateTags || !profile.privateItemTypes) return itemCount
+ // An item listed both publicly and privately is one item
+ const publicItems = getLazarusItems(profile, event.tags)
+ const privateItems = [...getLazarusItems(profile, privateTags).keys()]
return {
count: itemCount.count,
partial: false,
- privateCount: countItemTags(privateTags, profile.privateItemTypes)
+ privateCount: privateItems.filter((key) => !publicItems.has(key)).length
}
}
@@ -124,53 +168,204 @@ 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
+}
+
+interface LazarusRelayRequestOptions {
+ timeoutMs?: number
+ /**
+ * The account to authenticate as when the relay requires it (NIP-42). Set
+ * only for relays in the user's own list: authenticating to any other
+ * proves to that relay that the account itself is asking.
+ */
+ authAs?: string
+}
+
/**
- * 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.
+ * One relay's answer to a filter, asked of the relay itself. The client's
+ * shared subscription reports a relay that couldn't connect as finished, and
+ * a relay's own EOSE timeout reports silence as an answer, so neither can
+ * tell an unreachable relay from an empty one. Only EOSE counts as an
+ * answer; a refused connection, a CLOSED or a dropped connection is a
+ * failure. The request is closed as soon as the relay finishes, fails or
+ * times out, so a slow relay doesn't stay subscribed after the scan moves
+ * on. 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) => {
+ { timeoutMs = SCAN_TIMEOUT_MS, authAs }: LazarusRelayRequestOptions = {}
+): Promise {
+ return new Promise((resolve) => {
const events: Event[] = []
+ const seen = new Set()
let done = false
- const finish = (error?: Error) => {
+ let sub: TSubCloser | undefined
+ const finish = (outcome: LazarusRelayOutcome) => {
if (done) return
done = true
clearTimeout(timer)
- sub.close()
- if (error) reject(error)
- else resolve(events)
+ sub?.close()
+ resolve({ events, outcome })
}
- const timer = setTimeout(() => finish(new Error('relay timeout')), timeoutMs)
- const sub = client.subscribe([url], filter, {
- onevent: (event) => {
- events.push(event)
- },
- oneose: (eosed) => {
- if (eosed) finish()
- },
- onAllClose: () => finish()
- })
+ const timer = setTimeout(() => finish('timed-out'), timeoutMs)
+ const authenticate = (relay: IRelay, account: string) => {
+ const signer = selectAuthSigner((pk) => client.getSignerFor(pk), client.signer, account)
+ if (!signer) return finish('failed')
+ relay
+ .auth(async (authEvt: EventTemplate) => {
+ const signed = await signer.signEvent(authEvt)
+ if (!signed) throw new Error('sign event failed')
+ return signed as VerifiedEvent
+ })
+ .then(
+ () => request(relay, true),
+ () => finish('failed')
+ )
+ }
+ const request = (relay: IRelay, authenticated: boolean) => {
+ if (done) return
+ try {
+ sub = relay.subscribe([filter], {
+ onevent: (event) => {
+ if (done || seen.has(event.id)) return
+ seen.add(event.id)
+ events.push(event)
+ },
+ oneose: () => finish('answered'),
+ onclose: (reason) => {
+ if (done) return
+ if (authAs && !authenticated && reason.startsWith('auth-required')) {
+ return authenticate(relay, authAs)
+ }
+ finish('failed')
+ },
+ // This request's own timeout decides first
+ eoseTimeout: timeoutMs + 1000
+ })
+ if (done) sub.close()
+ } catch {
+ finish('failed')
+ }
+ }
+ client.pool.ensureRelay(url).then(
+ (relay) => request(relay, false),
+ () => finish('failed')
+ )
})
}
+/** Ask several relays at once. Only relays in `ownRelays` are asked to authenticate. */
+async function fetchFromRelays(
+ urls: string[],
+ filterFor: (url: string) => Filter,
+ { ownRelays = [], authAs, ...options }: LazarusRelayRequestOptions & { ownRelays?: string[] } = {}
+): Promise {
+ const own = new Set(ownRelays)
+ const results = await Promise.allSettled(
+ urls.map((url) =>
+ fetchFromRelay(url, filterFor(url), {
+ ...options,
+ authAs: own.has(url) ? authAs : undefined
+ })
+ )
+ )
+ 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. `own` is every relay the user's list names, the only
+ * relays a scan authenticates to.
+ */
+async function getLazarusUserRelays(pubkey: string): Promise<{
+ read: string[]
+ write: string[]
+ own: 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) {
+ // These relays are outside the user's list, so the lookup doesn't authenticate to them
+ 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(compareLazarusVersions)[0]
+ if (newest) {
+ found = parseRelayList(newest)
+ } else if (!answers.some((answer) => answer.outcome === 'answered')) {
+ return { read: [], write: [], own: [], relayList: 'unknown' }
+ }
}
+ const own = found ? uniqueRelayUrls([...found.write, ...found.read]) : []
+ if (!found || found.write.length === 0) {
+ return {
+ read: found?.read ?? [],
+ write: uniqueRelayUrls(getDefaultRelayUrls()),
+ own,
+ relayList: 'missing'
+ }
+ }
+ return { ...found, own, relayList: 'found' }
}
/**
@@ -193,17 +388,28 @@ 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, and the relays of the user's
+ * own list, the only ones the scan authenticates to.
*/
-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[]
+ own: string[]
+ relayList: LazarusRelayListStatus
+}> {
+ const user = await getLazarusUserRelays(pubkey)
+ return {
+ relays: uniqueRelayUrls([
+ ...user.write,
+ ...user.read,
+ ...getDefaultRelayUrls(),
+ ...LAZARUS_ARCHIVAL_RELAYS
+ ]),
+ write: user.write,
+ own: user.own,
+ relayList: user.relayList
}
- return uniqueRelayUrls([...userRelays, ...getDefaultRelayUrls(), ...LAZARUS_ARCHIVAL_RELAYS])
}
/**
@@ -216,60 +422,78 @@ 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, own } = await getLazarusUserRelays(pubkey)
+ const answers = await fetchFromRelays(
+ write,
+ () => ({ kinds: [kind], authors: [pubkey], limit: 1 }),
+ { timeoutMs: 4000, authAs: pubkey, ownRelays: own }
)
- 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 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 }
- const page = cursors ? { ...filter, until: cursors[url] } : filter
- return fetchFromRelay(url, page)
- })
+ return cursors ? { ...filter, until: cursors[url] } : filter
+ },
+ // Pages come from relays that filled one, mostly archival relays, and
+ // are asked for without authenticating
+ { authAs: pubkey, ownRelays: plan?.own }
)
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 +507,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 +530,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 +581,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
}
}
@@ -344,11 +604,15 @@ function looksClobbered(laterMax: number, earlierMin: number): boolean {
return loss >= CLOBBER_MIN_LOSS_ITEMS && loss >= earlierMin * CLOBBER_MIN_LOSS_RATIO
}
-/** Versions with a known size, oldest first. */
+/**
+ * Versions with a known size, oldest first. A version whose size is unknown
+ * takes no part in finding drops, episodes, the settled count or the fullest
+ * version, so consecutive means consecutive among these.
+ */
function knownTimeline(candidates: LazarusCandidate[]): LazarusCandidate[] {
return candidates
.filter((c) => isLazarusSizeKnown(c.itemCount))
- .sort((a, b) => a.event.created_at - b.event.created_at || (a.event.id < b.event.id ? 1 : -1))
+ .sort((a, b) => compareLazarusVersions(b.event, a.event))
}
interface ClobberEpisode {
@@ -420,7 +684,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) {
@@ -441,9 +706,7 @@ export function rankLazarusCandidates(
}
const candidates = Array.from(byId.values())
- const newestFirst = [...candidates].sort(
- (a, b) => b.event.created_at - a.event.created_at || (a.event.id < b.event.id ? -1 : 1)
- )
+ const newestFirst = [...candidates].sort((a, b) => compareLazarusVersions(a.event, b.event))
const current = newestFirst[0]
if (current) current.isCurrent = true
@@ -459,10 +722,11 @@ export function rankLazarusCandidates(
return range.min + range.max
}
ordered = [...candidates].sort(
- (a, b) => size(b) - size(a) || b.event.created_at - a.event.created_at
+ (a, b) => size(b) - size(a) || compareLazarusVersions(a.event, b.event)
)
- // 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 +745,8 @@ export function rankLazarusCandidates(
recommended,
requiresIntentConfirmation,
queriedRelays,
- respondingRelays
+ respondingRelays,
+ currentConfirmed
}
}
@@ -500,9 +765,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
}
}
@@ -514,7 +782,7 @@ export function sortLazarusCandidates(
order: LazarusSortOrder
): LazarusCandidate[] {
const byDate = (a: LazarusCandidate, b: LazarusCandidate) =>
- b.event.created_at - a.event.created_at || (a.event.id < b.event.id ? -1 : 1)
+ compareLazarusVersions(a.event, b.event)
if (order === 'date') return [...candidates].sort(byDate)
const size = (c: LazarusCandidate) => {
const range = getLazarusItemRange(c.itemCount)
@@ -610,58 +878,87 @@ export interface LazarusDelta {
/** True when recovery would shrink the list below current. */
shrinks: boolean
/**
- * True when either version has encrypted private items that weren't
- * decrypted, so the changes above cover public tags only.
+ * True when the chosen version has encrypted private items that weren't
+ * decrypted, so the changes above leave them out.
*/
+ privateUnknownChosen: boolean
+ /**
+ * True when current has encrypted private items that weren't decrypted.
+ * The restore replaces them uncounted, so it may remove items no count
+ * shows.
+ */
+ privateUnknownCurrent: boolean
+ /** True when either side's private items weren't decrypted. */
privateUnknown: boolean
+ /**
+ * True when the restore needs the separate confirmation a shrinking one
+ * takes: it shrinks the list, or replaces private items nobody counted.
+ */
+ needsShrinkConfirmation: boolean
}
/**
- * What makes two tags the same item: their type and value. A relay hint or
- * petname a client rewrote doesn't change who is followed or muted. On relay
- * lists the read/write marker counts too, since it changes what the relay is
- * for.
+ * What a restore would change: the items it adds and removes. Items are the
+ * tags the registry names for the kind, each once, compared by type and
+ * value (see getLazarusItemKey); a profile's items are its content fields.
+ * Decrypted private items are compared together with the public tags, so an
+ * item that only moved between public and private isn't a change. Two
+ * versions with identical content hold the same private items, counted or
+ * not.
*/
-function tagIdentity(tag: string[], kind: number): string {
- return JSON.stringify(tag.slice(0, kind === kinds.RelayList ? 3 : 2))
-}
-
export function computeLazarusDelta(
chosen: Event,
current: Event | undefined,
privateTags: LazarusPrivateTags = new Map()
): LazarusDelta {
- // Decrypted private items are compared together with the public tags, so
- // an item that only moved between public and private isn't a change
+ const profile = getLazarusKindProfile(chosen.kind) ?? { kind: chosen.kind }
+ const samePrivate = current?.content === chosen.content
+ const shared =
+ current && samePrivate ? (privateTags.get(chosen.id) ?? privateTags.get(current.id)) : undefined
const itemsOf = (event: Event | undefined) => {
- if (!event) return { tags: [] as string[][], unknown: false }
- const decrypted = privateTags.get(event.id)
+ if (!event) return { items: new Map(), unknown: false }
+ if (event.kind === kinds.Metadata) {
+ const fields = Object.entries(getLazarusProfileFields(event))
+ return {
+ items: new Map(
+ fields.map(([field, value]) => [
+ field,
+ [field, typeof value === 'string' ? value : JSON.stringify(value)]
+ ])
+ ),
+ unknown: false
+ }
+ }
+ const decrypted = privateTags.get(event.id) ?? shared
return {
- tags: [...event.tags, ...(decrypted ?? [])],
- unknown: !decrypted && !!getContentEncryption(event.content)
+ items: getLazarusItems(profile, [...event.tags, ...(decrypted ?? [])]),
+ unknown: !decrypted && !samePrivate && !!getContentEncryption(event.content)
}
}
- const identity = (tag: string[]) => tagIdentity(tag, chosen.kind)
- const unique = (tags: string[][]) =>
- Array.from(new Map(tags.map((t) => [identity(t), t])).values())
- const chosenTags = unique(itemsOf(chosen).tags)
- const currentTags = unique(itemsOf(current).tags)
- const chosenIds = new Set(chosenTags.map(identity))
- const currentIds = new Set(currentTags.map(identity))
- const added = chosenTags.filter((tag) => !currentIds.has(identity(tag)))
- const removed = currentTags.filter((tag) => !chosenIds.has(identity(tag)))
+ const chosenItems = itemsOf(chosen)
+ const currentItems = itemsOf(current)
+ const added = [...chosenItems.items]
+ .filter(([key]) => !currentItems.items.has(key))
+ .map(([, tag]) => tag)
+ const removed = [...currentItems.items]
+ .filter(([key]) => !chosenItems.items.has(key))
+ .map(([, tag]) => tag)
+ const shrinks = removed.length > added.length
return {
added,
removed,
addedCount: added.length,
removedCount: removed.length,
grows: added.length > 0 && added.length >= removed.length,
- shrinks: removed.length > added.length,
- privateUnknown: itemsOf(chosen).unknown || itemsOf(current).unknown
+ shrinks,
+ privateUnknownChosen: chosenItems.unknown,
+ privateUnknownCurrent: currentItems.unknown,
+ privateUnknown: chosenItems.unknown || currentItems.unknown,
+ needsShrinkConfirmation: shrinks || currentItems.unknown
}
}
-/** 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,26 +977,86 @@ 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
): LazarusProfileChange[] {
- const fieldsOf = (event: Event | undefined): Record => {
- try {
- const parsed = JSON.parse(event?.content || '{}')
- return parsed && typeof parsed === 'object' ? parsed : {}
- } catch {
- return {}
+ // 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) => (typeof value === 'string' && value.trim() ? value : undefined)
- 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 text = (value: unknown) => {
+ if (typeof value === 'string') return value.trim() ? value : undefined
+ return value === undefined || value === null ? undefined : JSON.stringify(value)
+ }
+ const to = getLazarusProfileFields(chosen)
+ const from = getLazarusProfileFields(current)
+ 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, including one from the same second with a lower id: 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 || compareLazarusVersions(event, newest) < 0)) 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 {
diff --git a/src/services/lazarus/registry.ts b/src/services/lazarus/registry.ts
index 638ade6..0b58f71 100644
--- a/src/services/lazarus/registry.ts
+++ b/src/services/lazarus/registry.ts
@@ -1,4 +1,4 @@
-import type { Event } from 'nostr-tools'
+import { kinds, type Event } from 'nostr-tools'
import { estimatePrivateItems, getContentEncryption } from './private-items'
/**
@@ -10,7 +10,7 @@ import { estimatePrivateItems, getContentEncryption } from './private-items'
* kinds are added here and nowhere else.
*
* Spec: https://github.com/dmnyc/lazarus/blob/main/SPEC.md
- * (0.5.0-draft at the time of writing).
+ * (0.6.2-draft at the time of writing).
*/
export type LazarusRanking = 'count' | 'recency' | 'intent'
@@ -50,6 +50,13 @@ export interface LazarusKindProfile {
*/
meaningfulEmpty: boolean
requiredWarnings: LazarusWarning[]
+ /**
+ * Tag types whose entries are the list's items. Profiles (kind 0) have
+ * none: their items are the fields in their content.
+ */
+ itemTypes?: string[]
+ /** True when the items are relay URLs, which compare normalized. */
+ relayItems?: boolean
itemCount: (event: Event) => LazarusItemCount
/**
* Tag types counted among decrypted private items (NIP-51), for kinds
@@ -58,45 +65,107 @@ export interface LazarusKindProfile {
privateItemTypes?: string[]
}
-const MUTE_TAG_TYPES = ['p', 'word', 't', 'e']
+/**
+ * A relay URL as items compare it: scheme and host lowercased, a default
+ * port, repeated slashes and a trailing slash dropped, so a client that
+ * rewrote wss://Relay.Example/ as wss://relay.example changed nothing.
+ */
+export function normalizeLazarusRelayUrl(url: string): string {
+ const value = url.trim()
+ try {
+ const parsed = new URL(value)
+ const path = parsed.pathname.replace(/\/+/g, '/').replace(/\/$/, '')
+ return `${parsed.protocol}//${parsed.host}${path}${parsed.search}`
+ } catch {
+ return value
+ }
+}
-function countTags(types: string[], mayHavePrivateItems = false) {
- return (event: Event): LazarusItemCount => {
- const count = event.tags.filter((tag) => types.includes(tag[0])).length
- // Only encrypted content holds private items; kind 3 content is often
- // legacy relay JSON, which isn't a hidden part of the list
- if (!mayHavePrivateItems || !getContentEncryption(event.content)) {
- return { count, partial: false }
- }
- return { count, partial: true, privateEstimate: estimatePrivateItems(event.content) }
+/**
+ * What makes a tag an item, and two tags the same item: its type and value.
+ * A relay hint or petname a client rewrote doesn't change who is followed or
+ * muted. Relay URLs compare normalized, and on relay lists the read/write
+ * marker counts too, since it changes what the relay is for. A tag without
+ * a value is not an item. Without item types, every tag type counts.
+ */
+export function getLazarusItemKey(
+ profile: Pick,
+ tag: string[]
+): string | undefined {
+ const [type, value, marker] = tag
+ if (!type || !value) return undefined
+ if (profile.itemTypes && !profile.itemTypes.includes(type)) return undefined
+ const key = profile.relayItems ? normalizeLazarusRelayUrl(value) : value
+ return JSON.stringify(profile.kind === kinds.RelayList ? [type, key, marker ?? ''] : [type, key])
+}
+
+/** A version's items, keyed as they compare, each item once. */
+export function getLazarusItems(
+ profile: Pick,
+ tags: string[][]
+): Map {
+ const items = new Map()
+ for (const tag of tags) {
+ const key = getLazarusItemKey(profile, tag)
+ if (key !== undefined && !items.has(key)) items.set(key, tag)
+ }
+ return items
+}
+
+/**
+ * A profile's items: the fields of its content that have a value. Content
+ * that isn't a JSON object has none, so a profile wiped to {} is empty.
+ */
+export function getLazarusProfileFields(event: Event | undefined): Record {
+ try {
+ const parsed = JSON.parse(event?.content || '{}')
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+ return Object.fromEntries(Object.entries(parsed).filter(([, value]) => value !== null))
+ } catch {
+ return {}
}
}
-function contentPresence(event: Event): LazarusItemCount {
- return { count: event.content.trim().length > 0 ? 1 : 0, partial: false }
+/** A list kind's profile, counting its public items and sizing any encrypted ones. */
+function listKind(profile: Omit): LazarusKindProfile {
+ const listProfile: LazarusKindProfile = {
+ ...profile,
+ itemCount: (event) => {
+ const count = getLazarusItems(listProfile, event.tags).size
+ // Only encrypted content holds private items; kind 3 content is often
+ // legacy relay JSON, which isn't a hidden part of the list
+ if (!profile.privateItemTypes || !getContentEncryption(event.content)) {
+ return { count, partial: false }
+ }
+ return { count, partial: true, privateEstimate: estimatePrivateItems(event.content) }
+ }
+ }
+ return listProfile
}
+const MUTE_TAG_TYPES = ['p', 'word', 't', 'e']
+
export const LAZARUS_REGISTRY: Record = {
- 3: {
+ 3: listKind({
kind: 3,
name: 'Follow list',
tier: 1,
ranking: 'count',
meaningfulEmpty: false,
requiredWarnings: [],
- itemCount: countTags(['p'], true),
+ itemTypes: ['p'],
privateItemTypes: ['p']
- },
- 10000: {
+ }),
+ 10000: listKind({
kind: 10000,
name: 'Mute list',
tier: 1,
ranking: 'count',
meaningfulEmpty: false,
requiredWarnings: ['remute', 'affects-others'],
- itemCount: countTags(MUTE_TAG_TYPES, true),
+ itemTypes: MUTE_TAG_TYPES,
privateItemTypes: MUTE_TAG_TYPES
- },
+ }),
0: {
kind: 0,
name: 'Profile metadata',
@@ -104,54 +173,61 @@ export const LAZARUS_REGISTRY: Record = {
ranking: 'recency',
meaningfulEmpty: false,
requiredWarnings: [],
- itemCount: contentPresence
+ itemCount: (event) => ({
+ count: Object.keys(getLazarusProfileFields(event)).length,
+ partial: false
+ })
},
- 10003: {
+ 10003: listKind({
kind: 10003,
name: 'Bookmarks',
tier: 2,
ranking: 'count',
meaningfulEmpty: false,
requiredWarnings: [],
- itemCount: countTags(['e', 'a'], true),
+ itemTypes: ['e', 'a'],
privateItemTypes: ['e', 'a']
- },
- 10044: {
+ }),
+ 10044: listKind({
kind: 10044,
name: 'Encryption key list (NIP-4e)',
tier: 2,
ranking: 'intent',
meaningfulEmpty: true,
requiredWarnings: ['affects-others'],
- itemCount: countTags(['p'])
- },
- 10002: {
+ // NIP-4e lists encryption pubkeys in `n` tags
+ itemTypes: ['n']
+ }),
+ 10002: listKind({
kind: 10002,
name: 'Relay list',
tier: 3,
ranking: 'recency',
meaningfulEmpty: false,
requiredWarnings: ['stale-relays'],
- itemCount: countTags(['r'])
- },
- 10050: {
+ itemTypes: ['r'],
+ relayItems: true
+ }),
+ 10050: listKind({
kind: 10050,
name: 'DM relay inbox',
tier: 3,
ranking: 'recency',
meaningfulEmpty: false,
requiredWarnings: ['stale-relays'],
- itemCount: countTags(['relay'])
- },
- 10006: {
+ itemTypes: ['relay'],
+ relayItems: true
+ }),
+ 10006: listKind({
kind: 10006,
name: 'Blocked relays',
tier: 3,
ranking: 'count',
meaningfulEmpty: false,
requiredWarnings: [],
- itemCount: countTags(['relay'])
- }
+ itemTypes: ['relay'],
+ relayItems: true
+ })
}
/** Registry order: tier ascending, then kind ascending. */