feat(nostr): NIP-05 verification badge with 24h cache (M11.6)
Verify NIP-05 identifiers by fetching .well-known/nostr.json from the domain. Results cached in localStorage for 24 hours. Green checkmark badge shown next to verified NIP-05 on note cards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8ff3d1298e
commit
49c9f71aa2
@@ -143,7 +143,16 @@
|
||||
<span class="text-xs font-semibold truncate text-white/80">
|
||||
{{ note.authorName ?? 'anon' }}
|
||||
</span>
|
||||
<span v-if="note.nip05" class="text-[9px] truncate text-purple-400/60">
|
||||
<span v-if="note.nip05" class="text-[9px] truncate text-purple-400/60 flex items-center gap-0.5">
|
||||
<svg
|
||||
v-if="nip05Status[note.id] === true"
|
||||
class="w-2.5 h-2.5 text-emerald-400 shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
:title="note.nip05"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ note.nip05 }}
|
||||
</span>
|
||||
<span class="text-[9px] ml-auto shrink-0 text-white/20">
|
||||
@@ -209,11 +218,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, reactive, onMounted, nextTick, watch } from 'vue'
|
||||
import NostrDMs from './NostrDMs.vue'
|
||||
import NostrRelayManager from './NostrRelayManager.vue'
|
||||
import NostrProfileEditor from './NostrProfileEditor.vue'
|
||||
import ZapDialog from './ZapDialog.vue'
|
||||
import { useNip05Verification } from '@/composables/useNip05Verification'
|
||||
import { useNostr, type NostrNote, type PublishResult } from '@/composables/useNostr'
|
||||
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||
|
||||
@@ -221,6 +231,7 @@ defineEmits<{ selectNote: [note: NostrNote] }>()
|
||||
|
||||
const { events: notes, isConnected, relayStates, connect, publishEvent } = useNostr()
|
||||
const { isLoggedIn, signEvent } = useNostrIdentity()
|
||||
const { verifyNip05 } = useNip05Verification()
|
||||
|
||||
const activeSubTab = ref<'feed' | 'dms' | 'relays' | 'profile'>('feed')
|
||||
const subTabs = [
|
||||
@@ -249,6 +260,8 @@ function openZap(note: NostrNote) {
|
||||
zapOpen.value = true
|
||||
}
|
||||
|
||||
const nip05Status = reactive<Record<string, boolean | null>>({})
|
||||
|
||||
const noteKinds = [
|
||||
{ id: 1, label: 'Notes' },
|
||||
{ id: 30023, label: 'Articles' },
|
||||
@@ -280,6 +293,18 @@ const filteredNotes = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
// Verify NIP-05 for notes that have it
|
||||
watch(filteredNotes, (visibleNotes) => {
|
||||
for (const note of visibleNotes) {
|
||||
if (note.nip05 && nip05Status[note.id] === undefined) {
|
||||
nip05Status[note.id] = null
|
||||
verifyNip05(note.nip05, note.pubkey).then(result => {
|
||||
nip05Status[note.id] = result
|
||||
})
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function publishNote() {
|
||||
if (!composeText.value.trim() || isPublishing.value) return
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface CacheEntry {
|
||||
verified: boolean
|
||||
pubkey: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
|
||||
const STORAGE_KEY = 'aiui-nip05-cache'
|
||||
|
||||
const verificationCache = ref<Map<string, CacheEntry>>(new Map())
|
||||
|
||||
function loadCache() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const entries = JSON.parse(stored) as [string, CacheEntry][]
|
||||
const now = Date.now()
|
||||
const valid = entries.filter(([, e]) => now - e.timestamp < CACHE_TTL)
|
||||
verificationCache.value = new Map(valid)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function saveCache() {
|
||||
try {
|
||||
const entries = Array.from(verificationCache.value.entries())
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
loadCache()
|
||||
|
||||
export function useNip05Verification() {
|
||||
async function verifyNip05(nip05: string, expectedPubkey: string): Promise<boolean> {
|
||||
const cacheKey = `${nip05}:${expectedPubkey}`
|
||||
const cached = verificationCache.value.get(cacheKey)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.verified
|
||||
}
|
||||
|
||||
try {
|
||||
const [name, domain] = nip05.split('@')
|
||||
if (!name || !domain) return false
|
||||
|
||||
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`)
|
||||
if (!res.ok) return cacheResult(cacheKey, false, expectedPubkey)
|
||||
|
||||
const data = await res.json()
|
||||
const pubkey = data?.names?.[name]
|
||||
const verified = pubkey === expectedPubkey
|
||||
|
||||
return cacheResult(cacheKey, verified, expectedPubkey)
|
||||
} catch {
|
||||
return cacheResult(cacheKey, false, expectedPubkey)
|
||||
}
|
||||
}
|
||||
|
||||
function cacheResult(key: string, verified: boolean, pubkey: string): boolean {
|
||||
verificationCache.value.set(key, { verified, pubkey, timestamp: Date.now() })
|
||||
saveCache()
|
||||
return verified
|
||||
}
|
||||
|
||||
function isVerified(nip05: string, pubkey: string): boolean | null {
|
||||
const cached = verificationCache.value.get(`${nip05}:${pubkey}`)
|
||||
if (!cached || Date.now() - cached.timestamp >= CACHE_TTL) return null
|
||||
return cached.verified
|
||||
}
|
||||
|
||||
return {
|
||||
verifyNip05,
|
||||
isVerified,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user