CI / check (push) Failing after 6m11s
Client: useNostr.ts's auto-restore now calls GET /api/auth/me (a plain
authFetch, no body) instead of POSTing {pubkey} to /api/auth/login —
identity is derived server-side from the JWT alone, never claimed by
the client.
Server: POST /login is reduced to a pure, documented-deprecated read.
Removed the creator auto-create branch and the creator auto-upgrade
db.update block — an unauthenticated request can no longer mutate the
database via this endpoint. The identical creator auto-create/upgrade
logic already exists, correctly gated behind NIP-98 verification, in
POST /nostr/session, so a creator signing in with a real signer still
gets the same row created/upgraded. Added a handler doc comment plus a
new auth.test.ts case asserting an unregistered creator pubkey now
returns exists:false and leaves the bots table row count unchanged.
e2e/helpers/auth.ts: doc comments updated to describe loginWithPubkey
as a read-only test lookup helper, not a login; request/signature
unchanged so existing e2e specs keep working.
Verification: auth.test.ts + auth-edge.test.ts + auth-audit.test.ts +
auth-me.test.ts = 56/56 pass. Full server suite (bypassing pnpm's
install-gate via ./node_modules/.bin/vitest, since this environment's
pnpm needs an interactive build-approval step unrelated to this task)
= 810/817 pass, remaining 7 are pre-existing timing/perf flakes under
CPU load (lifecycle/speed-meta/tier-balance/bot-auth constant-time),
none touching auth. tsc (server) and vue-tsc (frontend) both exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
676 lines
20 KiB
TypeScript
676 lines
20 KiB
TypeScript
import { ref, readonly, computed } from 'vue'
|
|
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
|
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
|
import { nsecEncode } from 'nostr-tools/nip19'
|
|
import { buildNip98Token, setToken, getToken, isTokenExpired, authFetch } from '../lib/nostr-auth'
|
|
|
|
/** NIP-55: Build a nostrsigner: URI for sign_event (inline since nostr-tools doesn't export nip55) */
|
|
function buildSignEventUri(eventJson: Record<string, unknown>, callbackUrl: string): string {
|
|
const params = new URLSearchParams({
|
|
type: 'sign_event',
|
|
callbackUrl,
|
|
returnType: 'event',
|
|
compressionType: 'none',
|
|
})
|
|
return `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}?${params.toString()}`
|
|
}
|
|
|
|
interface BotCustomization {
|
|
archetype?: string
|
|
primaryColor?: string
|
|
secondaryColor?: string
|
|
forceVisor?: boolean
|
|
forceMohawk?: boolean
|
|
forceHorns?: boolean
|
|
}
|
|
|
|
interface BotData {
|
|
id: string
|
|
name: string
|
|
avatarSeed: string
|
|
archetype: string
|
|
profilePicUrl: string | null
|
|
customization: BotCustomization | null
|
|
isHuman?: boolean
|
|
eloRating: number
|
|
wins: number
|
|
losses: number
|
|
winStreak: number
|
|
bestStreak: number
|
|
tier: number
|
|
satsWon: number
|
|
satsWagered: number
|
|
hasWallet: boolean
|
|
}
|
|
|
|
interface NostrWindow {
|
|
getPublicKey(): Promise<string>
|
|
signEvent(event: Record<string, unknown>): Promise<Record<string, unknown>>
|
|
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
nostr?: NostrWindow
|
|
}
|
|
}
|
|
|
|
// Persist auth state across page navigations and HMR reloads
|
|
function loadStored<T>(key: string): T | null {
|
|
try {
|
|
const raw = localStorage.getItem(key)
|
|
return raw ? JSON.parse(raw) : null
|
|
} catch { return null }
|
|
}
|
|
function store(key: string, value: unknown) {
|
|
if (value == null) localStorage.removeItem(key)
|
|
else localStorage.setItem(key, JSON.stringify(value))
|
|
}
|
|
|
|
/** Normalize server bot data to fill missing fields */
|
|
function normalizeBotData(data: Record<string, unknown>): BotData {
|
|
return {
|
|
satsWon: 0,
|
|
satsWagered: 0,
|
|
hasWallet: false,
|
|
...data,
|
|
} as BotData
|
|
}
|
|
|
|
/** Clear all auth-related state including JWT and nsec */
|
|
function clearAllState() {
|
|
pubkey.value = null
|
|
bot.value = null
|
|
profilePicUrl.value = null
|
|
store('bf_pubkey', null)
|
|
store('bf_bot', null)
|
|
store('bf_pic', null)
|
|
setToken(null)
|
|
sessionNsec = null
|
|
sessionStorage.removeItem('bf_nsec')
|
|
}
|
|
|
|
const pubkey = ref<string | null>(loadStored('bf_pubkey'))
|
|
const bot = ref<BotData | null>(loadStored('bf_bot'))
|
|
const profilePicUrl = ref<string | null>(loadStored('bf_pic'))
|
|
const isLoading = ref(false)
|
|
|
|
// Guard: only auto-restore once — survives Vite HMR module re-evaluation
|
|
let autoRestoreRan = (globalThis as any).__bf_autoRestoreRan ?? false
|
|
// Flag: skip relay pic fetch for freshly generated keys (no profile exists)
|
|
let freshlyGenerated = false
|
|
// In-memory nsec for current session (never auto-persisted to localStorage)
|
|
let sessionNsec: string | null = null
|
|
|
|
// Sync in-memory auth state when tab regains focus (handles external localStorage clearing)
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState !== 'visible') return
|
|
const storedPubkey = loadStored<string>('bf_pubkey')
|
|
if (!storedPubkey && pubkey.value) {
|
|
// localStorage was cleared externally — wipe in-memory state to match
|
|
clearAllState()
|
|
}
|
|
})
|
|
}
|
|
|
|
// Auto-restore session from JWT on first load.
|
|
// Identity comes from the token alone — GET /api/auth/me derives the
|
|
// pubkey server-side via extractPubkeyFromAuth, so no bare pubkey is
|
|
// ever sent to claim a session (D-01/BOT-01).
|
|
if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) {
|
|
autoRestoreRan = true;
|
|
(globalThis as any).__bf_autoRestoreRan = true
|
|
authFetch('/api/auth/me').then(r => r.json()).then(data => {
|
|
if (data.exists) {
|
|
bot.value = normalizeBotData(data.bot)
|
|
store('bf_bot', bot.value)
|
|
}
|
|
}).catch(err => console.warn('[Nostr] auto-restore failed:', err))
|
|
} else if (!autoRestoreRan && pubkey.value && !getToken()) {
|
|
// No JWT — clear stale pubkey from before the JWT migration
|
|
autoRestoreRan = true;
|
|
(globalThis as any).__bf_autoRestoreRan = true
|
|
pubkey.value = null
|
|
store('bf_pubkey', null)
|
|
}
|
|
|
|
export function useNostr() {
|
|
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
|
|
const hasExtension = computed(() => !!window.nostr)
|
|
|
|
/** Wait for window.nostr to appear (mobile signers inject late) */
|
|
async function waitForSigner(timeoutMs = 3000): Promise<boolean> {
|
|
if (window.nostr) return true
|
|
const start = Date.now()
|
|
while (Date.now() - start < timeoutMs) {
|
|
await new Promise(r => setTimeout(r, 200))
|
|
if (window.nostr) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Authenticate with the server using NIP-98 signed request.
|
|
* Works with NIP-07 extension, Amber signer, or local nsec.
|
|
* Returns JWT session token + bot data.
|
|
*/
|
|
async function authenticateSession(secretKeyHex?: string | null): Promise<{ pubkey: string; bot: BotData | null }> {
|
|
const sessionUrl = new URL('/api/auth/nostr/session', window.location.origin).toString()
|
|
const nip98Token = await buildNip98Token(sessionUrl, 'POST', secretKeyHex)
|
|
|
|
const res = await fetch('/api/auth/nostr/session', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Nostr ${nip98Token}`,
|
|
},
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({ error: 'Authentication failed' }))
|
|
throw new Error(data.error || 'NIP-98 authentication failed')
|
|
}
|
|
|
|
const data = await res.json()
|
|
|
|
// Store JWT
|
|
setToken(data.token)
|
|
|
|
// Store pubkey
|
|
pubkey.value = data.pubkey
|
|
store('bf_pubkey', data.pubkey)
|
|
|
|
if (data.exists && data.bot) {
|
|
bot.value = normalizeBotData(data.bot)
|
|
store('bf_bot', bot.value)
|
|
return { pubkey: data.pubkey, bot: bot.value }
|
|
}
|
|
|
|
bot.value = null
|
|
store('bf_bot', null)
|
|
return { pubkey: data.pubkey, bot: null }
|
|
}
|
|
|
|
/**
|
|
* Sign in using NIP-07 extension or Amber signer (window.nostr).
|
|
* Authenticates via NIP-98 and receives a JWT session.
|
|
*/
|
|
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
|
|
if (!window.nostr) {
|
|
// Mobile signers (Amber) inject window.nostr late — poll for up to 3s
|
|
const found = await waitForSigner(3000)
|
|
if (!found) {
|
|
// Fall back to session or persisted nsec if available
|
|
const storedNsec = sessionNsec || sessionStorage.getItem('bf_nsec')
|
|
if (storedNsec) {
|
|
return loginWithNsec(storedNsec)
|
|
}
|
|
throw new Error('No Nostr signer found. Install a browser extension or use "Generate New Identity".')
|
|
}
|
|
}
|
|
|
|
// Verify extension is responsive
|
|
try {
|
|
await window.nostr!.getPublicKey()
|
|
} catch {
|
|
throw new Error('Nostr signer denied access. Approve the request and try again.')
|
|
}
|
|
|
|
isLoading.value = true
|
|
try {
|
|
const result = await authenticateSession()
|
|
|
|
// Fetch Nostr profile pic (non-blocking)
|
|
if (!freshlyGenerated) {
|
|
fetchNostrProfilePic(result.pubkey).then(pic => {
|
|
profilePicUrl.value = pic
|
|
store('bf_pic', pic)
|
|
}).catch(err => console.warn('[Nostr] relay profile fetch failed:', err))
|
|
} else {
|
|
freshlyGenerated = false
|
|
profilePicUrl.value = null
|
|
store('bf_pic', null)
|
|
}
|
|
|
|
return result
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
/** Generate a fresh Nostr keypair locally — no extension needed */
|
|
function generateLogin(): { pubkey: string; nsec: string } {
|
|
const secretKey = generateSecretKey()
|
|
const pk = getPublicKey(secretKey)
|
|
const nsecHex = bytesToHex(secretKey)
|
|
const nsecBech32 = nsecEncode(secretKey)
|
|
|
|
// Clear all stale state from previous identity
|
|
clearAllState()
|
|
|
|
// Mark as freshly generated so login() skips relay pic fetch
|
|
freshlyGenerated = true
|
|
|
|
// Hold key in session memory only — user must opt in to persist
|
|
sessionNsec = nsecHex
|
|
pubkey.value = pk
|
|
store('bf_pubkey', pk)
|
|
|
|
return { pubkey: pk, nsec: nsecBech32 }
|
|
}
|
|
|
|
/** Login with an existing nsec (hex). Signs NIP-98 locally. If persist=true, saves to localStorage. */
|
|
async function loginWithNsec(nsecHex: string, persist = false): Promise<{ pubkey: string; bot: BotData | null }> {
|
|
let secretKey: Uint8Array
|
|
try {
|
|
secretKey = hexToBytes(nsecHex)
|
|
} catch {
|
|
throw new Error('Invalid secret key format.')
|
|
}
|
|
getPublicKey(secretKey) // validate key
|
|
|
|
// Clear stale state before switching identity
|
|
bot.value = null
|
|
profilePicUrl.value = null
|
|
store('bf_bot', null)
|
|
store('bf_pic', null)
|
|
|
|
sessionNsec = nsecHex
|
|
if (persist) sessionStorage.setItem('bf_nsec', nsecHex)
|
|
|
|
isLoading.value = true
|
|
try {
|
|
const result = await authenticateSession(nsecHex)
|
|
|
|
// Fetch Nostr profile pic (non-blocking)
|
|
fetchNostrProfilePic(result.pubkey).then(pic => {
|
|
profilePicUrl.value = pic
|
|
store('bf_pic', pic)
|
|
}).catch(err => console.warn('[Nostr] profile pic fetch failed:', err))
|
|
|
|
return result
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function registerBot(name: string, webhookUrl: string, archetype: string): Promise<{ bot: BotData; secret: string; mode: string }> {
|
|
if (!pubkey.value) throw new Error('Not logged in')
|
|
|
|
const res = await authFetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
pubkey: pubkey.value,
|
|
name,
|
|
webhookUrl,
|
|
archetype,
|
|
profilePicUrl: profilePicUrl.value,
|
|
}),
|
|
})
|
|
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
let msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed')
|
|
if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)`
|
|
throw new Error(msg)
|
|
}
|
|
|
|
bot.value = {
|
|
id: data.id,
|
|
name: data.name,
|
|
avatarSeed: data.name,
|
|
archetype: data.archetype,
|
|
profilePicUrl: profilePicUrl.value,
|
|
customization: data.customization || null,
|
|
eloRating: 1200,
|
|
wins: 0,
|
|
losses: 0,
|
|
winStreak: 0,
|
|
bestStreak: 0,
|
|
tier: 0,
|
|
satsWon: 0,
|
|
satsWagered: 0,
|
|
hasWallet: false,
|
|
}
|
|
store('bf_bot', bot.value)
|
|
|
|
// Re-authenticate to get fresh JWT with botId
|
|
try {
|
|
await authenticateSession(sessionNsec)
|
|
} catch {
|
|
// Non-critical: existing JWT still works, just missing botId
|
|
}
|
|
|
|
return { bot: bot.value, secret: data.secret, mode: data.mode || 'webhook' }
|
|
}
|
|
|
|
async function updateCustomization(customization: BotCustomization): Promise<void> {
|
|
if (!pubkey.value) throw new Error('Not logged in')
|
|
|
|
const res = await authFetch('/api/auth/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pubkey: pubkey.value, customization }),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json()
|
|
throw new Error(data.error || 'Update failed')
|
|
}
|
|
|
|
if (bot.value) {
|
|
bot.value = {
|
|
...bot.value,
|
|
customization: { ...(bot.value.customization || {}), ...customization },
|
|
archetype: customization.archetype || bot.value.archetype,
|
|
}
|
|
store('bf_bot', bot.value)
|
|
}
|
|
}
|
|
|
|
async function updateWebhook(newUrl: string): Promise<{ latencyMs: number }> {
|
|
if (!pubkey.value) throw new Error('Not logged in')
|
|
|
|
const res = await authFetch('/api/auth/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }),
|
|
})
|
|
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
const msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Update failed')
|
|
throw new Error(msg)
|
|
}
|
|
|
|
return { latencyMs: data.latencyMs || 0 }
|
|
}
|
|
|
|
async function regenerateSecret(): Promise<{ botId: string; secret: string }> {
|
|
if (!pubkey.value) throw new Error('Not logged in')
|
|
|
|
const res = await authFetch('/api/auth/regenerate-secret', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
throw new Error(data.error || 'Failed to regenerate secret')
|
|
}
|
|
|
|
return { botId: data.botId, secret: data.secret }
|
|
}
|
|
|
|
async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> {
|
|
if (!pubkey.value) throw new Error('Not logged in')
|
|
|
|
const res = await authFetch('/api/auth/register-human', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
pubkey: pubkey.value,
|
|
name,
|
|
avatarSeed,
|
|
profilePicUrl: profilePicUrl.value,
|
|
}),
|
|
})
|
|
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
let msg = data.error || 'Registration failed'
|
|
if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)`
|
|
throw new Error(msg)
|
|
}
|
|
|
|
bot.value = {
|
|
id: data.id,
|
|
name: data.name,
|
|
avatarSeed: avatarSeed || data.name,
|
|
archetype: 'human',
|
|
profilePicUrl: profilePicUrl.value,
|
|
customization: null,
|
|
isHuman: true,
|
|
eloRating: 1200,
|
|
wins: 0,
|
|
losses: 0,
|
|
winStreak: 0,
|
|
bestStreak: 0,
|
|
tier: 0,
|
|
satsWon: 0,
|
|
satsWagered: 0,
|
|
hasWallet: false,
|
|
}
|
|
store('bf_bot', bot.value)
|
|
|
|
// Re-authenticate to get fresh JWT with botId
|
|
try {
|
|
await authenticateSession(sessionNsec)
|
|
} catch {
|
|
// Non-critical
|
|
}
|
|
|
|
return bot.value
|
|
}
|
|
|
|
/** Sign out — clears everything including JWT, nsec, and all stored state */
|
|
function logout() {
|
|
clearAllState()
|
|
}
|
|
|
|
/** Check if user has a locally stored key (no extension needed) */
|
|
const hasStoredKey = computed(() => !!sessionStorage.getItem('bf_nsec'))
|
|
|
|
/** Get the current nsec hex (session memory first, then localStorage) */
|
|
function getStoredNsec(): string | null {
|
|
return sessionNsec || sessionStorage.getItem('bf_nsec')
|
|
}
|
|
|
|
/** Persist the current session key to localStorage (opt-in) */
|
|
function persistKey(): void {
|
|
if (sessionNsec) sessionStorage.setItem('bf_nsec', sessionNsec)
|
|
}
|
|
|
|
/**
|
|
* NIP-55: Initiate login via Android signer app (Amber/Primal).
|
|
* Builds an unsigned NIP-98 event and navigates to a nostrsigner: URI.
|
|
* The signer app signs the event and redirects back to callbackUrl.
|
|
*/
|
|
function initiateNip55Login() {
|
|
const sessionUrl = new URL('/api/auth/nostr/session', window.location.origin).toString()
|
|
const unsignedEvent = {
|
|
kind: 27235,
|
|
tags: [['u', sessionUrl], ['method', 'POST']],
|
|
content: '',
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
}
|
|
|
|
const callbackUrl = new URL('/join-bout', window.location.origin).toString()
|
|
|
|
const uri = buildSignEventUri(unsignedEvent, callbackUrl)
|
|
|
|
window.location.href = uri
|
|
}
|
|
|
|
/**
|
|
* NIP-55: Process the callback after returning from an Android signer.
|
|
* Parses the signed event from the URL and authenticates with the server.
|
|
* Returns null if no NIP-55 callback params are present.
|
|
*/
|
|
async function processNip55Return(): Promise<{ pubkey: string; bot: BotData | null } | null> {
|
|
const params = new URLSearchParams(window.location.search)
|
|
|
|
// Look for the signed event in common NIP-55 callback param names
|
|
const eventStr = params.get('event') || params.get('result') || params.get('signed_event')
|
|
if (!eventStr) return null
|
|
|
|
// Clean the URL so the params don't persist
|
|
window.history.replaceState({}, '', window.location.pathname)
|
|
|
|
try {
|
|
const signedEvent = JSON.parse(decodeURIComponent(eventStr))
|
|
const nip98Token = btoa(JSON.stringify(signedEvent))
|
|
|
|
const res = await fetch('/api/auth/nostr/session', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Nostr ${nip98Token}` },
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({ error: 'Authentication failed' }))
|
|
throw new Error(data.error || 'NIP-98 authentication failed')
|
|
}
|
|
|
|
const data = await res.json()
|
|
|
|
setToken(data.token)
|
|
pubkey.value = data.pubkey
|
|
store('bf_pubkey', data.pubkey)
|
|
|
|
if (data.exists && data.bot) {
|
|
bot.value = normalizeBotData(data.bot)
|
|
store('bf_bot', bot.value)
|
|
return { pubkey: data.pubkey, bot: bot.value }
|
|
}
|
|
|
|
bot.value = null
|
|
store('bf_bot', null)
|
|
return { pubkey: data.pubkey, bot: null }
|
|
} catch (e) {
|
|
console.error('[NIP-55] Failed to process signer callback:', e)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/** Detect if Android signer apps (Amber/Primal) might be available */
|
|
const hasAndroidSigner = computed(() => /android/i.test(navigator.userAgent))
|
|
|
|
return {
|
|
pubkey: readonly(pubkey),
|
|
bot: readonly(bot),
|
|
profilePicUrl: readonly(profilePicUrl),
|
|
isLoggedIn,
|
|
isLoading: readonly(isLoading),
|
|
hasExtension,
|
|
hasStoredKey,
|
|
login,
|
|
waitForSigner,
|
|
generateLogin,
|
|
loginWithNsec,
|
|
registerBot,
|
|
registerHuman,
|
|
updateCustomization,
|
|
updateWebhook,
|
|
regenerateSecret,
|
|
getStoredNsec,
|
|
persistKey,
|
|
logout,
|
|
fetchNostrProfile,
|
|
initiateNip55Login,
|
|
processNip55Return,
|
|
hasAndroidSigner,
|
|
}
|
|
}
|
|
|
|
export interface NostrProfile {
|
|
displayName: string | null
|
|
about: string | null
|
|
picture: string | null
|
|
banner: string | null
|
|
nip05: string | null
|
|
}
|
|
|
|
// Profile cache with 5-min TTL
|
|
const profileCache = new Map<string, { data: NostrProfile; ts: number }>()
|
|
const PROFILE_TTL = 5 * 60 * 1000
|
|
|
|
const RELAYS = [
|
|
'wss://relay.damus.io',
|
|
'wss://relay.nostr.band',
|
|
'wss://nos.lol',
|
|
]
|
|
|
|
// Fetch profile picture from a Nostr relay
|
|
async function fetchNostrProfilePic(pk: string): Promise<string | null> {
|
|
const profile = await fetchNostrProfile(pk)
|
|
return profile?.picture || null
|
|
}
|
|
|
|
/** Fetch full Nostr profile (kind:0 metadata) with caching.
|
|
* Queries all relays in parallel, picks result with latest created_at. */
|
|
async function fetchNostrProfile(pk: string): Promise<NostrProfile | null> {
|
|
const cached = profileCache.get(pk)
|
|
if (cached && Date.now() - cached.ts < PROFILE_TTL) return cached.data
|
|
|
|
const results = await Promise.allSettled(
|
|
RELAYS.map(relay => queryRelayProfile(relay, pk))
|
|
)
|
|
|
|
// Pick the profile with the latest created_at (newest wins)
|
|
let best: { profile: NostrProfile; createdAt: number } | null = null
|
|
for (const r of results) {
|
|
if (r.status !== 'fulfilled' || !r.value) continue
|
|
if (!best || r.value.createdAt > best.createdAt) {
|
|
best = r.value
|
|
}
|
|
}
|
|
|
|
if (best) {
|
|
profileCache.set(pk, { data: best.profile, ts: Date.now() })
|
|
return best.profile
|
|
}
|
|
return null
|
|
}
|
|
|
|
function queryRelayProfile(url: string, pk: string): Promise<{ profile: NostrProfile; createdAt: number } | null> {
|
|
return new Promise((resolve) => {
|
|
let timedOut = false
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true
|
|
if (ws.readyState === WebSocket.OPEN) ws.close()
|
|
resolve(null)
|
|
}, 3000)
|
|
|
|
const ws = new WebSocket(url)
|
|
const subId = Math.random().toString(36).slice(2, 10)
|
|
|
|
ws.onopen = () => {
|
|
if (timedOut) { ws.close(); return }
|
|
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }]))
|
|
}
|
|
|
|
ws.onmessage = (msg) => {
|
|
try {
|
|
const data = JSON.parse(msg.data)
|
|
if (data[0] === 'EVENT' && data[2]?.kind === 0) {
|
|
const meta = JSON.parse(data[2].content)
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
resolve({
|
|
profile: {
|
|
displayName: meta.display_name || meta.name || null,
|
|
about: meta.about || null,
|
|
picture: meta.picture || null,
|
|
banner: meta.banner || null,
|
|
nip05: meta.nip05 || null,
|
|
},
|
|
createdAt: data[2].created_at || 0,
|
|
})
|
|
} else if (data[0] === 'EOSE') {
|
|
clearTimeout(timeout)
|
|
ws.close()
|
|
resolve(null)
|
|
}
|
|
} catch {
|
|
// ignore parse errors
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(timeout)
|
|
resolve(null)
|
|
}
|
|
})
|
|
}
|