1 Commits

Author SHA1 Message Date
woikos
ecd7c36400 Add NRC (Nostr Relay Connect) for cross-device sync
Implements NRC listener that allows other user clients to connect
and sync events through a rendezvous relay. Features:
- REQ-only (read) sync for security
- Secret-based and CAT token authentication
- NIP-44 encrypted tunneling
- Device-specific event filtering via d-tag prefix
- Session management with timeouts
- Settings UI with QR code connection flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:16:03 +01:00
10 changed files with 1921 additions and 3 deletions

View File

@@ -16,6 +16,7 @@ import { SocialGraphFilterProvider } from '@/providers/SocialGraphFilterProvider
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
import { MuteListProvider } from '@/providers/MuteListProvider'
import { NostrProvider } from '@/providers/NostrProvider'
import { NRCProvider } from '@/providers/NRCProvider'
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
import { PinListProvider } from '@/providers/PinListProvider'
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
@@ -38,6 +39,7 @@ export default function App(): JSX.Element {
<DeletedEventProvider>
<PasswordPromptProvider>
<NostrProvider>
<NRCProvider>
<RepositoryProvider>
<SettingsSyncProvider>
<ZapProvider>
@@ -72,6 +74,7 @@ export default function App(): JSX.Element {
</ZapProvider>
</SettingsSyncProvider>
</RepositoryProvider>
</NRCProvider>
</NostrProvider>
</PasswordPromptProvider>
</DeletedEventProvider>

View File

@@ -0,0 +1,409 @@
/**
* NRC Settings Component
*
* UI for managing Nostr Relay Connect (NRC) connections and listener settings.
*/
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNRC } from '@/providers/NRCProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@/components/ui/alert-dialog'
import {
Link2,
Plus,
Trash2,
Copy,
Check,
QrCode,
Wifi,
WifiOff,
Users,
Server
} from 'lucide-react'
import { NRCConnection } from '@/services/nrc'
import QRCode from 'qrcode'
export default function NRCSettings() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const {
isEnabled,
isConnected,
connections,
activeSessions,
rendezvousUrl,
enable,
disable,
addConnection,
removeConnection,
getConnectionURI,
setRendezvousUrl
} = useNRC()
const [newConnectionLabel, setNewConnectionLabel] = useState('')
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isQRDialogOpen, setIsQRDialogOpen] = useState(false)
const [currentQRConnection, setCurrentQRConnection] = useState<NRCConnection | null>(null)
const [currentQRUri, setCurrentQRUri] = useState('')
const [qrDataUrl, setQrDataUrl] = useState('')
const [copiedUri, setCopiedUri] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Generate QR code when URI changes
const generateQRCode = useCallback(async (uri: string) => {
try {
const dataUrl = await QRCode.toDataURL(uri, {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#ffffff' }
})
setQrDataUrl(dataUrl)
} catch (error) {
console.error('Failed to generate QR code:', error)
}
}, [])
const handleToggleEnabled = useCallback(async () => {
if (isEnabled) {
disable()
} else {
setIsLoading(true)
try {
await enable()
} catch (error) {
console.error('Failed to enable NRC:', error)
} finally {
setIsLoading(false)
}
}
}, [isEnabled, enable, disable])
const handleAddConnection = useCallback(async () => {
if (!newConnectionLabel.trim()) return
setIsLoading(true)
try {
const { uri, connection } = await addConnection(newConnectionLabel.trim())
setIsAddDialogOpen(false)
setNewConnectionLabel('')
// Show QR code
setCurrentQRConnection(connection)
setCurrentQRUri(uri)
await generateQRCode(uri)
setIsQRDialogOpen(true)
} catch (error) {
console.error('Failed to add connection:', error)
} finally {
setIsLoading(false)
}
}, [newConnectionLabel, addConnection])
const handleShowQR = useCallback(
async (connection: NRCConnection) => {
try {
const uri = getConnectionURI(connection)
setCurrentQRConnection(connection)
setCurrentQRUri(uri)
await generateQRCode(uri)
setIsQRDialogOpen(true)
} catch (error) {
console.error('Failed to get connection URI:', error)
}
},
[getConnectionURI, generateQRCode]
)
const handleCopyUri = useCallback(async () => {
try {
await navigator.clipboard.writeText(currentQRUri)
setCopiedUri(true)
setTimeout(() => setCopiedUri(false), 2000)
} catch (error) {
console.error('Failed to copy URI:', error)
}
}, [currentQRUri])
const handleRemoveConnection = useCallback(
async (id: string) => {
try {
await removeConnection(id)
} catch (error) {
console.error('Failed to remove connection:', error)
}
},
[removeConnection]
)
if (!pubkey) {
return (
<div className="text-muted-foreground text-sm">
{t('Login required to use NRC')}
</div>
)
}
return (
<div className="space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="nrc-enabled" className="text-base font-medium">
{t('Enable Relay Connect')}
</Label>
<p className="text-sm text-muted-foreground">
{t('Allow other devices to sync with this client')}
</p>
</div>
<Switch
id="nrc-enabled"
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
disabled={isLoading}
/>
</div>
{/* Status Indicator */}
{isEnabled && (
<div className="flex items-center gap-4 p-3 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
{isConnected ? (
<Wifi className="w-4 h-4 text-green-500" />
) : (
<WifiOff className="w-4 h-4 text-yellow-500" />
)}
<span className="text-sm">
{isConnected ? t('Connected') : t('Connecting...')}
</span>
</div>
{activeSessions > 0 && (
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span className="text-sm">
{activeSessions} {t('active session(s)')}
</span>
</div>
)}
</div>
)}
{/* Rendezvous Relay */}
<div className="space-y-2">
<Label htmlFor="rendezvous-url" className="flex items-center gap-2">
<Server className="w-4 h-4" />
{t('Rendezvous Relay')}
</Label>
<Input
id="rendezvous-url"
value={rendezvousUrl}
onChange={(e) => setRendezvousUrl(e.target.value)}
placeholder="wss://relay.example.com"
disabled={isEnabled}
/>
{isEnabled && (
<p className="text-xs text-muted-foreground">
{t('Disable NRC to change the relay')}
</p>
)}
</div>
{/* Connections List */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Link2 className="w-4 h-4" />
{t('Authorized Devices')}
</Label>
<Button
variant="outline"
size="sm"
onClick={() => setIsAddDialogOpen(true)}
className="gap-1"
>
<Plus className="w-4 h-4" />
{t('Add')}
</Button>
</div>
{connections.length === 0 ? (
<div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
{t('No devices connected yet')}
</div>
) : (
<div className="space-y-2">
{connections.map((connection) => (
<div
key={connection.id}
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{connection.label}</div>
<div className="text-xs text-muted-foreground">
{new Date(connection.createdAt).toLocaleDateString()}
{connection.useCat && (
<span className="ml-2 px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px]">
CAT
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleShowQR(connection)}
title={t('Show QR Code')}
>
<QrCode className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
title={t('Remove')}
>
<Trash2 className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Remove Device?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('This will revoke access for "{{label}}". The device will no longer be able to sync.', {
label: connection.label
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleRemoveConnection(connection.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('Remove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
</div>
{/* Add Connection Dialog */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Add Device')}</DialogTitle>
<DialogDescription>
{t('Create a connection URI to link another device')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="device-label">{t('Device Name')}</Label>
<Input
id="device-label"
value={newConnectionLabel}
onChange={(e) => setNewConnectionLabel(e.target.value)}
placeholder={t('e.g., Phone, Laptop')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddConnection()
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
{t('Cancel')}
</Button>
<Button
onClick={handleAddConnection}
disabled={!newConnectionLabel.trim() || isLoading}
>
{t('Create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* QR Code Dialog */}
<Dialog open={isQRDialogOpen} onOpenChange={setIsQRDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('Connection QR Code')}</DialogTitle>
<DialogDescription>
{currentQRConnection && (
<>
{t('Scan this code with "{{label}}" to connect', {
label: currentQRConnection.label
})}
</>
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
{qrDataUrl && (
<div className="p-4 bg-white rounded-lg">
<img src={qrDataUrl} alt="Connection QR Code" className="w-64 h-64" />
</div>
)}
<div className="w-full">
<div className="flex items-center gap-2">
<Input
value={currentQRUri}
readOnly
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
onClick={handleCopyUri}
title={t('Copy')}
>
{copiedUri ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setIsQRDialogOpen(false)}>{t('Done')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import EmojiPackList from '@/components/EmojiPackList'
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import MailboxSetting from '@/components/MailboxSetting'
import NRCSettings from '@/components/NRCSettings'
import NoteList from '@/components/NoteList'
import Tabs from '@/components/Tabs'
import {
@@ -73,6 +74,7 @@ import {
PencilLine,
RotateCcw,
ScanLine,
RefreshCw,
Server,
Settings2,
Smile,
@@ -105,7 +107,7 @@ const NOTIFICATION_STYLES = [
] as const
// Accordion item values for keyboard navigation
const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system']
const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'sync', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system']
export default function Settings() {
const { t, i18n } = useTranslation()
@@ -123,7 +125,7 @@ export default function Settings() {
// Get the visible accordion items based on pubkey availability
const visibleAccordionItems = pubkey
? ACCORDION_ITEMS
: ACCORDION_ITEMS.filter((item) => !['wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
: ACCORDION_ITEMS.filter((item) => !['sync', 'wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
// Register as a navigation region - Settings decides what "up/down" means
const handleSettingsIntent = useCallback(
@@ -548,6 +550,23 @@ export default function Settings() {
</AccordionItem>
</NavigableAccordionItem>
{/* Sync (NRC) */}
{!!pubkey && (
<NavigableAccordionItem ref={setAccordionRef('sync')} isSelected={isAccordionSelected('sync')}>
<AccordionItem value="sync">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<RefreshCw className="size-4" />
<span>{t('Device Sync')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4">
<NRCSettings />
</AccordionContent>
</AccordionItem>
</NavigableAccordionItem>
)}
{/* Wallet */}
{!!pubkey && (
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>

View File

@@ -0,0 +1,310 @@
/**
* NRC (Nostr Relay Connect) Provider
*
* Manages NRC listener state and connections for cross-device sync.
*/
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
import { useNostr } from './NostrProvider'
import client from '@/services/client.service'
import {
NRCConnection,
CATConfig,
NRCListenerConfig,
generateConnectionURI,
generateCATConnectionURI,
getNRCListenerService
} from '@/services/nrc'
// Storage keys
const STORAGE_KEY_ENABLED = 'nrc:enabled'
const STORAGE_KEY_CONNECTIONS = 'nrc:connections'
const STORAGE_KEY_RENDEZVOUS_URL = 'nrc:rendezvousUrl'
const STORAGE_KEY_CAT_CONFIG = 'nrc:catConfig'
// Default rendezvous relay
const DEFAULT_RENDEZVOUS_URL = 'wss://relay.damus.io'
interface NRCContextType {
// State
isEnabled: boolean
isListening: boolean
isConnected: boolean
connections: NRCConnection[]
activeSessions: number
catConfig: CATConfig | null
rendezvousUrl: string
// Actions
enable: () => Promise<void>
disable: () => void
addConnection: (label: string, useCat?: boolean) => Promise<{ uri: string; connection: NRCConnection }>
removeConnection: (id: string) => Promise<void>
getConnectionURI: (connection: NRCConnection) => string
setRendezvousUrl: (url: string) => void
setCATConfig: (config: CATConfig | null) => void
}
const NRCContext = createContext<NRCContextType | undefined>(undefined)
export const useNRC = () => {
const context = useContext(NRCContext)
if (!context) {
throw new Error('useNRC must be used within an NRCProvider')
}
return context
}
interface NRCProviderProps {
children: ReactNode
}
export function NRCProvider({ children }: NRCProviderProps) {
const { pubkey } = useNostr()
// Load initial state from storage
const [isEnabled, setIsEnabled] = useState<boolean>(() => {
const stored = localStorage.getItem(STORAGE_KEY_ENABLED)
return stored === 'true'
})
const [connections, setConnections] = useState<NRCConnection[]>(() => {
const stored = localStorage.getItem(STORAGE_KEY_CONNECTIONS)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return []
}
}
return []
})
const [rendezvousUrl, setRendezvousUrlState] = useState<string>(() => {
return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL
})
const [catConfig, setCATConfigState] = useState<CATConfig | null>(() => {
const stored = localStorage.getItem(STORAGE_KEY_CAT_CONFIG)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return null
}
}
return null
})
const [isListening, setIsListening] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const [activeSessions, setActiveSessions] = useState(0)
const listenerService = getNRCListenerService()
// Persist state to storage
useEffect(() => {
localStorage.setItem(STORAGE_KEY_ENABLED, String(isEnabled))
}, [isEnabled])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_CONNECTIONS, JSON.stringify(connections))
}, [connections])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl)
}, [rendezvousUrl])
useEffect(() => {
if (catConfig) {
localStorage.setItem(STORAGE_KEY_CAT_CONFIG, JSON.stringify(catConfig))
} else {
localStorage.removeItem(STORAGE_KEY_CAT_CONFIG)
}
}, [catConfig])
// Build authorized secrets map from connections
const buildAuthorizedSecrets = useCallback((): Map<string, string> => {
const map = new Map<string, string>()
for (const conn of connections) {
if (conn.secret && conn.clientPubkey) {
map.set(conn.clientPubkey, conn.label)
}
}
return map
}, [connections])
// Start/stop listener based on enabled state
useEffect(() => {
if (!isEnabled || !client.signer || !pubkey) {
if (listenerService.isRunning()) {
listenerService.stop()
setIsListening(false)
setIsConnected(false)
setActiveSessions(0)
}
return
}
const startListener = async () => {
try {
const config: NRCListenerConfig = {
rendezvousUrl,
signer: client.signer!,
authorizedSecrets: buildAuthorizedSecrets(),
catConfig: catConfig || undefined
}
listenerService.setOnSessionChange((count) => {
setActiveSessions(count)
})
await listenerService.start(config)
setIsListening(true)
setIsConnected(listenerService.isConnected())
// Poll connection status
const statusInterval = setInterval(() => {
setIsConnected(listenerService.isConnected())
setActiveSessions(listenerService.getActiveSessionCount())
}, 5000)
return () => {
clearInterval(statusInterval)
}
} catch (error) {
console.error('[NRC] Failed to start listener:', error)
setIsListening(false)
setIsConnected(false)
}
}
const cleanup = startListener()
return () => {
cleanup?.then((fn) => fn?.())
}
}, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets, catConfig])
// Restart listener when connections change (to update authorized secrets)
useEffect(() => {
if (!isEnabled || !client.signer || !pubkey) return
// Update authorized secrets without full restart
// Note: The current implementation requires a full restart
// A future optimization could update secrets dynamically
}, [connections, isEnabled, pubkey])
const enable = useCallback(async () => {
if (!client.signer) {
throw new Error('Signer required to enable NRC')
}
setIsEnabled(true)
}, [])
const disable = useCallback(() => {
setIsEnabled(false)
listenerService.stop()
setIsListening(false)
setIsConnected(false)
setActiveSessions(0)
}, [])
const addConnection = useCallback(
async (label: string, useCat = false): Promise<{ uri: string; connection: NRCConnection }> => {
if (!pubkey) {
throw new Error('Not logged in')
}
const id = crypto.randomUUID()
const createdAt = Date.now()
let connection: NRCConnection
let uri: string
if (useCat && catConfig) {
// CAT-based connection
uri = generateCATConnectionURI(pubkey, rendezvousUrl, catConfig.mintUrl)
connection = {
id,
label,
useCat: true,
createdAt
}
} else {
// Secret-based connection
const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label)
uri = result.uri
connection = {
id,
label,
secret: result.secret,
clientPubkey: result.clientPubkey,
useCat: false,
createdAt
}
}
setConnections((prev) => [...prev, connection])
return { uri, connection }
},
[pubkey, rendezvousUrl, catConfig]
)
const removeConnection = useCallback(async (id: string) => {
setConnections((prev) => prev.filter((c) => c.id !== id))
}, [])
const getConnectionURI = useCallback(
(connection: NRCConnection): string => {
if (!pubkey) {
throw new Error('Not logged in')
}
if (connection.useCat && catConfig) {
return generateCATConnectionURI(pubkey, rendezvousUrl, catConfig.mintUrl)
}
if (connection.secret) {
const result = generateConnectionURI(
pubkey,
rendezvousUrl,
connection.secret,
connection.label
)
return result.uri
}
throw new Error('Connection has no secret or CAT config')
},
[pubkey, rendezvousUrl, catConfig]
)
const setRendezvousUrl = useCallback((url: string) => {
setRendezvousUrlState(url)
// Listener will restart automatically via effect
}, [])
const setCATConfig = useCallback((config: CATConfig | null) => {
setCATConfigState(config)
}, [])
const value: NRCContextType = {
isEnabled,
isListening,
isConnected,
connections,
activeSessions,
catConfig,
rendezvousUrl,
enable,
disable,
addConnection,
removeConnection,
getConnectionURI,
setRendezvousUrl,
setCATConfig
}
return <NRCContext.Provider value={value}>{children}</NRCContext.Provider>
}

View File

@@ -1,7 +1,7 @@
import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import { TDMDeletedState, TRelayInfo } from '@/types'
import { Event, kinds } from 'nostr-tools'
import { Event, Filter, kinds, matchFilters } from 'nostr-tools'
type TValue<T = any> = {
key: string
@@ -1014,6 +1014,84 @@ class IndexedDbService {
}
}
/**
* Query all events across all stores for NRC sync.
* Returns events matching the provided filters.
*
* Note: This method queries all event-containing stores and filters
* client-side using matchFilters. Device-specific event filtering
* should be done by the caller.
*/
async queryEventsForNRC(filters: Filter[]): Promise<Event[]> {
await this.initPromise
if (!this.db) {
return []
}
// List of stores that contain Event objects
const eventStores = [
StoreNames.PROFILE_EVENTS,
StoreNames.RELAY_LIST_EVENTS,
StoreNames.FOLLOW_LIST_EVENTS,
StoreNames.MUTE_LIST_EVENTS,
StoreNames.BOOKMARK_LIST_EVENTS,
StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
StoreNames.USER_EMOJI_LIST_EVENTS,
StoreNames.EMOJI_SET_EVENTS,
StoreNames.PIN_LIST_EVENTS,
StoreNames.PINNED_USERS_EVENTS,
StoreNames.FAVORITE_RELAYS,
StoreNames.RELAY_SETS,
StoreNames.DM_EVENTS
]
const allEvents: Event[] = []
// Query each store
const transaction = this.db.transaction(eventStores, 'readonly')
await Promise.all(
eventStores.map(
(storeName) =>
new Promise<void>((resolve) => {
const store = transaction.objectStore(storeName)
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
const value = cursor.value as TValue<Event | null>
if (value.value) {
// Check if event matches any of the filters
if (matchFilters(filters, value.value)) {
allEvents.push(value.value)
}
}
cursor.continue()
} else {
resolve()
}
}
request.onerror = () => {
resolve() // Continue even if one store fails
}
})
)
)
// Sort by created_at descending (newest first)
allEvents.sort((a, b) => b.created_at - a.created_at)
// Apply limit from filters if specified
const limit = Math.min(...filters.map((f) => f.limit ?? Infinity))
if (limit !== Infinity && limit > 0) {
return allEvents.slice(0, limit)
}
return allEvents
}
private async cleanUp() {
await this.initPromise
if (!this.db) {

View File

@@ -0,0 +1,4 @@
export * from './nrc-types'
export * from './nrc-uri'
export * from './nrc-session'
export { NRCListenerService, getNRCListenerService, default as nrcListenerService } from './nrc-listener.service'

View File

@@ -0,0 +1,558 @@
/**
* NRC (Nostr Relay Connect) Listener Service
*
* Listens for NRC requests (kind 24891) on a rendezvous relay and responds
* with events from the local IndexedDB. This allows other user clients to
* sync their data through this client.
*
* Protocol:
* - Client sends kind 24891 request with encrypted REQ/CLOSE message
* - This listener decrypts, queries local storage, and responds with kind 24892
* - All content is NIP-44 encrypted end-to-end
*/
import { Event, Filter } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44'
import * as utils from '@noble/curves/abstract/utils'
import indexedDb from '@/services/indexed-db.service'
import {
KIND_NRC_REQUEST,
KIND_NRC_RESPONSE,
NRCListenerConfig,
RequestMessage,
ResponseMessage,
AuthResult,
NRCSession,
isDeviceSpecificEvent
} from './nrc-types'
import { NRCSessionManager } from './nrc-session'
import { deriveConversationKey } from './nrc-uri'
/**
* Generate a random subscription ID
*/
function generateSubId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(8))
return utils.bytesToHex(bytes)
}
/**
* NRC Listener Service
*
* Listens for incoming NRC requests and responds with local events.
*/
export class NRCListenerService {
private config: NRCListenerConfig | null = null
private sessions: NRCSessionManager
private ws: WebSocket | null = null
private subId: string | null = null
private connected = false
private running = false
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
private reconnectDelay = 1000 // Start with 1 second
private maxReconnectDelay = 30000 // Max 30 seconds
private listenerPubkey: string | null = null
// Event callbacks
private onSessionChange?: (count: number) => void
constructor() {
this.sessions = new NRCSessionManager()
}
/**
* Set callback for session count changes
*/
setOnSessionChange(callback: (count: number) => void): void {
this.onSessionChange = callback
}
/**
* Start listening for NRC requests
*/
async start(config: NRCListenerConfig): Promise<void> {
if (this.running) {
console.warn('[NRC] Listener already running')
return
}
this.config = config
this.running = true
// Get our public key
this.listenerPubkey = await config.signer.getPublicKey()
// Start session cleanup
this.sessions.start()
// Connect to rendezvous relay
await this.connectToRelay()
}
/**
* Stop listening
*/
stop(): void {
this.running = false
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
if (this.ws) {
// Unsubscribe
if (this.subId) {
try {
this.ws.send(JSON.stringify(['CLOSE', this.subId]))
} catch {
// Ignore errors when closing
}
}
this.ws.close()
this.ws = null
}
this.sessions.stop()
this.connected = false
this.subId = null
console.log('[NRC] Listener stopped')
}
/**
* Check if listener is running
*/
isRunning(): boolean {
return this.running
}
/**
* Check if connected to rendezvous relay
*/
isConnected(): boolean {
return this.connected
}
/**
* Get active session count
*/
getActiveSessionCount(): number {
return this.sessions.getActiveSessionCount()
}
/**
* Connect to the rendezvous relay
*/
private async connectToRelay(): Promise<void> {
if (!this.config || !this.running) return Promise.resolve()
const relayUrl = this.config.rendezvousUrl
return new Promise<void>((resolve, reject) => {
// Normalize WebSocket URL
let wsUrl = relayUrl
if (relayUrl.startsWith('http://')) {
wsUrl = 'ws://' + relayUrl.slice(7)
} else if (relayUrl.startsWith('https://')) {
wsUrl = 'wss://' + relayUrl.slice(8)
} else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
wsUrl = 'wss://' + relayUrl
}
console.log(`[NRC] Connecting to rendezvous relay: ${wsUrl}`)
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Connection timeout'))
}, 10000)
ws.onopen = () => {
clearTimeout(timeout)
this.ws = ws
this.connected = true
this.reconnectDelay = 1000 // Reset reconnect delay on success
// Subscribe to NRC requests for our pubkey
this.subId = generateSubId()
ws.send(
JSON.stringify([
'REQ',
this.subId,
{
kinds: [KIND_NRC_REQUEST],
'#p': [this.listenerPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
])
)
console.log(`[NRC] Connected and subscribed with subId: ${this.subId}`)
resolve()
}
ws.onerror = (error) => {
clearTimeout(timeout)
console.error('[NRC] WebSocket error:', error)
reject(new Error('WebSocket error'))
}
ws.onclose = () => {
this.connected = false
this.ws = null
this.subId = null
console.log('[NRC] WebSocket closed')
// Attempt reconnection if still running
if (this.running) {
this.scheduleReconnect()
}
}
ws.onmessage = (event) => {
this.handleMessage(event.data)
}
}).catch((error) => {
console.error('[NRC] Failed to connect:', error)
if (this.running) {
this.scheduleReconnect()
}
})
}
/**
* Schedule reconnection with exponential backoff
*/
private scheduleReconnect(): void {
if (this.reconnectTimeout || !this.running) return
console.log(`[NRC] Scheduling reconnect in ${this.reconnectDelay}ms`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectToRelay()
}, this.reconnectDelay)
// Exponential backoff
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
}
/**
* Handle incoming WebSocket message
*/
private handleMessage(data: string): void {
try {
const msg = JSON.parse(data)
if (!Array.isArray(msg)) return
const [type, ...rest] = msg
if (type === 'EVENT') {
const [, event] = rest as [string, Event]
if (event.kind === KIND_NRC_REQUEST) {
this.handleRequest(event).catch((err) => {
console.error('[NRC] Error handling request:', err)
})
}
} else if (type === 'EOSE') {
// End of stored events, listener is now live
console.log('[NRC] Received EOSE, now listening for live events')
} else if (type === 'NOTICE') {
console.log('[NRC] Relay notice:', rest[0])
} else if (type === 'OK') {
// Event published successfully
} else if (type === 'CLOSED') {
console.log('[NRC] Subscription closed:', rest)
}
} catch (err) {
console.error('[NRC] Failed to parse message:', err)
}
}
/**
* Handle an NRC request event
*/
private async handleRequest(event: Event): Promise<void> {
if (!this.config) return
// Extract session ID from tags (used for correlation but we use pubkey-based sessions)
const sessionTag = event.tags.find((t) => t[0] === 'session')
const _sessionId = sessionTag?.[1]
void _sessionId // Suppress unused variable warning
try {
// Authorize the request
const authResult = await this.authorize(event)
// Get or create session
const session = this.sessions.getOrCreateSession(
event.pubkey,
authResult.conversationKey,
authResult.mode,
authResult.deviceName
)
// Notify session change
this.onSessionChange?.(this.sessions.getActiveSessionCount())
// Decrypt the content
const plaintext = nip44.v2.decrypt(event.content, authResult.conversationKey)
const request: RequestMessage = JSON.parse(plaintext)
// Handle the request based on type
switch (request.type) {
case 'REQ':
await this.handleREQ(event, session, request.payload)
break
case 'CLOSE':
await this.handleCLOSE(session, request.payload)
break
case 'EVENT':
// Read-only mode - reject EVENT requests
await this.sendError(event, session, 'This NRC endpoint is read-only')
break
case 'COUNT':
// Not implemented
await this.sendError(event, session, 'COUNT not supported')
break
default:
await this.sendError(event, session, `Unknown message type: ${request.type}`)
}
} catch (err) {
console.error('[NRC] Request handling failed:', err)
// Try to send error response (best effort)
try {
await this.sendErrorBestEffort(event, `Request failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
} catch {
// Ignore errors when sending error response
}
}
}
/**
* Authorize an incoming request
*/
private async authorize(event: Event): Promise<AuthResult> {
if (!this.config) {
throw new Error('Listener not configured')
}
// Check for CAT token in cashu tag
const cashuTag = event.tags.find((t) => t[0] === 'cashu')
if (cashuTag && this.config.catConfig) {
// TODO: Implement CAT token verification
// For now, we'll use the client's Nostr pubkey for ECDH
const ourPrivkey = await this.getPrivateKey()
const conversationKey = deriveConversationKey(ourPrivkey, event.pubkey)
return {
mode: 'cat',
conversationKey,
deviceName: 'cat-client'
}
}
// Secret-based auth: check if pubkey is authorized
const deviceName = this.config.authorizedSecrets.get(event.pubkey)
if (!deviceName) {
throw new Error('Unauthorized: unknown client pubkey')
}
// Derive conversation key via ECDH
const ourPrivkey = await this.getPrivateKey()
const conversationKey = deriveConversationKey(ourPrivkey, event.pubkey)
return {
mode: 'secret',
conversationKey,
deviceName
}
}
/**
* Get our private key from the signer
* Note: This requires the signer to expose the private key, which not all signers do
*/
private async getPrivateKey(): Promise<Uint8Array> {
if (!this.config) {
throw new Error('Listener not configured')
}
// Try to get private key from signer if it's an NsecSigner
const signer = this.config.signer as { privkey?: Uint8Array }
if (signer.privkey) {
return signer.privkey
}
throw new Error('Signer does not expose private key - NRC requires direct key access')
}
/**
* Handle REQ message - query local storage and respond
*/
private async handleREQ(
reqEvent: Event,
session: NRCSession,
payload: unknown[]
): Promise<void> {
// Parse REQ: ["REQ", subId, filter1, filter2, ...]
if (payload.length < 2) {
await this.sendError(reqEvent, session, 'Invalid REQ: missing subscription ID or filters')
return
}
const [, subId, ...filterObjs] = payload as [string, string, ...Filter[]]
// Add subscription to session
const subscription = this.sessions.addSubscription(session.id, subId, filterObjs)
if (!subscription) {
await this.sendError(reqEvent, session, 'Too many subscriptions')
return
}
// Query local events matching the filters
const events = await this.queryLocalEvents(filterObjs)
// Send each matching event
for (const evt of events) {
const response: ResponseMessage = {
type: 'EVENT',
payload: ['EVENT', subId, evt]
}
await this.sendResponse(reqEvent, session, response)
this.sessions.incrementEventCount(session.id, subId)
}
// Send EOSE
const eoseResponse: ResponseMessage = {
type: 'EOSE',
payload: ['EOSE', subId]
}
await this.sendResponse(reqEvent, session, eoseResponse)
this.sessions.markEOSE(session.id, subId)
}
/**
* Handle CLOSE message
*/
private async handleCLOSE(session: NRCSession, payload: unknown[]): Promise<void> {
// Parse CLOSE: ["CLOSE", subId]
const [, subId] = payload as [string, string]
if (subId) {
this.sessions.removeSubscription(session.id, subId)
}
}
/**
* Query local IndexedDB for events matching filters
*/
private async queryLocalEvents(filters: Filter[]): Promise<Event[]> {
// Get all events from IndexedDB and filter
const allEvents = await indexedDb.queryEventsForNRC(filters)
// Filter out device-specific events
return allEvents.filter((evt) => !isDeviceSpecificEvent(evt))
}
/**
* Send an encrypted response
*/
private async sendResponse(
reqEvent: Event,
session: NRCSession,
response: ResponseMessage
): Promise<void> {
if (!this.ws || !this.config || !this.listenerPubkey) {
throw new Error('Not connected')
}
// Encrypt the response
const plaintext = JSON.stringify(response)
const encrypted = nip44.v2.encrypt(plaintext, session.conversationKey)
// Build the response event
const unsignedEvent = {
kind: KIND_NRC_RESPONSE,
content: encrypted,
tags: [
['p', reqEvent.pubkey],
['encryption', 'nip44_v2'],
['session', session.id],
['e', reqEvent.id]
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign with our signer
const signedEvent = await this.config.signer.signEvent(unsignedEvent)
// Publish to rendezvous relay
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
}
/**
* Send an error response
*/
private async sendError(
reqEvent: Event,
session: NRCSession,
message: string
): Promise<void> {
const response: ResponseMessage = {
type: 'NOTICE',
payload: ['NOTICE', message]
}
await this.sendResponse(reqEvent, session, response)
}
/**
* Send error response with best-effort ECDH
*/
private async sendErrorBestEffort(reqEvent: Event, message: string): Promise<void> {
if (!this.ws || !this.config || !this.listenerPubkey) {
return
}
try {
const ourPrivkey = await this.getPrivateKey()
const conversationKey = deriveConversationKey(ourPrivkey, reqEvent.pubkey)
const response: ResponseMessage = {
type: 'NOTICE',
payload: ['NOTICE', message]
}
const plaintext = JSON.stringify(response)
const encrypted = nip44.v2.encrypt(plaintext, conversationKey)
const unsignedEvent = {
kind: KIND_NRC_RESPONSE,
content: encrypted,
tags: [
['p', reqEvent.pubkey],
['encryption', 'nip44_v2'],
['e', reqEvent.id]
],
created_at: Math.floor(Date.now() / 1000)
}
const signedEvent = await this.config.signer.signEvent(unsignedEvent)
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
} catch {
// Best effort - ignore errors
}
}
}
// Singleton instance
let instance: NRCListenerService | null = null
export function getNRCListenerService(): NRCListenerService {
if (!instance) {
instance = new NRCListenerService()
}
return instance
}
export default getNRCListenerService()

View File

@@ -0,0 +1,240 @@
import { Filter } from 'nostr-tools'
import { NRCSession, NRCSubscription, AuthMode } from './nrc-types'
// Default session timeout: 30 minutes
const DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1000
// Default max subscriptions per session
const DEFAULT_MAX_SUBSCRIPTIONS = 100
/**
* Generate a unique session ID
*/
function generateSessionId(): string {
return crypto.randomUUID()
}
/**
* Session manager for tracking NRC client sessions
*/
export class NRCSessionManager {
private sessions: Map<string, NRCSession> = new Map()
private sessionTimeout: number
private maxSubscriptions: number
private cleanupInterval: ReturnType<typeof setInterval> | null = null
constructor(
sessionTimeout: number = DEFAULT_SESSION_TIMEOUT,
maxSubscriptions: number = DEFAULT_MAX_SUBSCRIPTIONS
) {
this.sessionTimeout = sessionTimeout
this.maxSubscriptions = maxSubscriptions
}
/**
* Start the cleanup interval for expired sessions
*/
start(): void {
if (this.cleanupInterval) return
// Run cleanup every 5 minutes
this.cleanupInterval = setInterval(() => {
this.cleanupExpiredSessions()
}, 5 * 60 * 1000)
}
/**
* Stop the cleanup interval
*/
stop(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
this.sessions.clear()
}
/**
* Get or create a session for a client
*/
getOrCreateSession(
clientPubkey: string,
conversationKey: Uint8Array,
authMode: AuthMode,
deviceName?: string
): NRCSession {
// Check if session exists for this client
for (const session of this.sessions.values()) {
if (session.clientPubkey === clientPubkey) {
// Update last activity and return existing session
session.lastActivity = Date.now()
return session
}
}
// Create new session
const session: NRCSession = {
id: generateSessionId(),
clientPubkey,
conversationKey,
deviceName,
authMode,
createdAt: Date.now(),
lastActivity: Date.now(),
subscriptions: new Map()
}
this.sessions.set(session.id, session)
return session
}
/**
* Get a session by ID
*/
getSession(sessionId: string): NRCSession | undefined {
return this.sessions.get(sessionId)
}
/**
* Get a session by client pubkey
*/
getSessionByClientPubkey(clientPubkey: string): NRCSession | undefined {
for (const session of this.sessions.values()) {
if (session.clientPubkey === clientPubkey) {
return session
}
}
return undefined
}
/**
* Touch a session to update last activity
*/
touchSession(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (session) {
session.lastActivity = Date.now()
}
}
/**
* Add a subscription to a session
*/
addSubscription(
sessionId: string,
subId: string,
filters: Filter[]
): NRCSubscription | null {
const session = this.sessions.get(sessionId)
if (!session) return null
// Check subscription limit
if (session.subscriptions.size >= this.maxSubscriptions) {
return null
}
const subscription: NRCSubscription = {
id: subId,
filters,
createdAt: Date.now(),
eventCount: 0,
eoseSent: false
}
session.subscriptions.set(subId, subscription)
session.lastActivity = Date.now()
return subscription
}
/**
* Get a subscription from a session
*/
getSubscription(sessionId: string, subId: string): NRCSubscription | undefined {
const session = this.sessions.get(sessionId)
return session?.subscriptions.get(subId)
}
/**
* Remove a subscription from a session
*/
removeSubscription(sessionId: string, subId: string): boolean {
const session = this.sessions.get(sessionId)
if (!session) return false
const deleted = session.subscriptions.delete(subId)
if (deleted) {
session.lastActivity = Date.now()
}
return deleted
}
/**
* Mark EOSE sent for a subscription
*/
markEOSE(sessionId: string, subId: string): void {
const subscription = this.getSubscription(sessionId, subId)
if (subscription) {
subscription.eoseSent = true
}
}
/**
* Increment event count for a subscription
*/
incrementEventCount(sessionId: string, subId: string): void {
const subscription = this.getSubscription(sessionId, subId)
if (subscription) {
subscription.eventCount++
}
}
/**
* Remove a session
*/
removeSession(sessionId: string): boolean {
return this.sessions.delete(sessionId)
}
/**
* Get the count of active sessions
*/
getActiveSessionCount(): number {
return this.sessions.size
}
/**
* Get all active sessions
*/
getAllSessions(): NRCSession[] {
return Array.from(this.sessions.values())
}
/**
* Clean up expired sessions
*/
private cleanupExpiredSessions(): void {
const now = Date.now()
const expiredSessionIds: string[] = []
for (const [sessionId, session] of this.sessions) {
if (now - session.lastActivity > this.sessionTimeout) {
expiredSessionIds.push(sessionId)
}
}
for (const sessionId of expiredSessionIds) {
this.sessions.delete(sessionId)
console.log(`[NRC] Cleaned up expired session: ${sessionId}`)
}
}
/**
* Check if a session is expired
*/
isSessionExpired(sessionId: string): boolean {
const session = this.sessions.get(sessionId)
if (!session) return true
return Date.now() - session.lastActivity > this.sessionTimeout
}
}

View File

@@ -0,0 +1,108 @@
import { Filter, Event } from 'nostr-tools'
import { ISigner } from '@/types'
// NRC Event Kinds
export const KIND_NRC_REQUEST = 24891
export const KIND_NRC_RESPONSE = 24892
// Authentication modes
export type AuthMode = 'secret' | 'cat'
// Session types
export interface NRCSession {
id: string
clientPubkey: string
conversationKey: Uint8Array
deviceName?: string
authMode: AuthMode
createdAt: number
lastActivity: number
subscriptions: Map<string, NRCSubscription>
}
export interface NRCSubscription {
id: string
filters: Filter[]
createdAt: number
eventCount: number
eoseSent: boolean
}
// Message types (encrypted content)
export interface RequestMessage {
type: 'REQ' | 'CLOSE' | 'EVENT' | 'COUNT'
payload: unknown[]
}
export interface ResponseMessage {
type: 'EVENT' | 'EOSE' | 'OK' | 'NOTICE' | 'CLOSED' | 'COUNT'
payload: unknown[]
}
// Connection management
export interface NRCConnection {
id: string
label: string
secret?: string // For secret-based auth
clientPubkey?: string // Derived from secret
useCat: boolean // Whether to use CAT auth
createdAt: number
lastUsed?: number
}
// CAT (Cashu Access Token) configuration
export interface CATConfig {
mintUrl: string // Cashu mint URL
scope: string // Token scope (e.g., "nrc")
}
// Listener configuration
export interface NRCListenerConfig {
rendezvousUrl: string
signer: ISigner
authorizedSecrets: Map<string, string> // clientPubkey → deviceName
catConfig?: CATConfig // For CAT verification
sessionTimeout?: number // Session inactivity timeout in ms (default 30 min)
maxSubscriptionsPerSession?: number // Max subscriptions per session (default 100)
}
// Authorization result
export interface AuthResult {
mode: AuthMode
conversationKey: Uint8Array
deviceName: string
}
// Parsed connection URI
export interface ParsedConnectionURI {
relayPubkey: string // Hex pubkey of the listening relay/client
rendezvousUrl: string // URL of the rendezvous relay
authMode: AuthMode
// For secret-based auth
secret?: string // 32-byte hex secret
clientPubkey?: string // Derived pubkey from secret
clientPrivkey?: Uint8Array // Derived private key from secret
// For CAT auth
mintUrl?: string
// Optional
deviceName?: string
}
// Listener state for React context
export interface NRCListenerState {
isEnabled: boolean
isListening: boolean
connections: NRCConnection[]
activeSessions: number
catConfig: CATConfig | null
rendezvousUrl: string
}
// Event with simplified typing for storage queries
export type StoredEvent = Event
// Device-specific event check
export function isDeviceSpecificEvent(event: Event): boolean {
const dTag = event.tags.find((t) => t[0] === 'd')?.[1]
return dTag?.startsWith('device:') ?? false
}

189
src/services/nrc/nrc-uri.ts Normal file
View File

@@ -0,0 +1,189 @@
import * as utils from '@noble/curves/abstract/utils'
import { getPublicKey } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44'
import { ParsedConnectionURI, AuthMode } from './nrc-types'
/**
* Generate a random 32-byte secret as hex string
*/
export function generateSecret(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return utils.bytesToHex(bytes)
}
/**
* Derive a keypair from a 32-byte secret
* Returns the private key bytes and public key hex
*/
export function deriveKeypairFromSecret(secretHex: string): {
privkey: Uint8Array
pubkey: string
} {
const privkey = utils.hexToBytes(secretHex)
const pubkey = getPublicKey(privkey)
return { privkey, pubkey }
}
/**
* Derive conversation key for NIP-44 encryption
*/
export function deriveConversationKey(
ourPrivkey: Uint8Array,
theirPubkey: string
): Uint8Array {
return nip44.v2.utils.getConversationKey(ourPrivkey, theirPubkey)
}
/**
* Generate a secret-based NRC connection URI
*
* @param relayPubkey - The public key of the listening client/relay
* @param rendezvousUrl - The URL of the rendezvous relay
* @param secret - Optional 32-byte hex secret (generated if not provided)
* @param deviceName - Optional device name for identification
* @returns The connection URI and the secret used
*/
export function generateConnectionURI(
relayPubkey: string,
rendezvousUrl: string,
secret?: string,
deviceName?: string
): { uri: string; secret: string; clientPubkey: string } {
const secretHex = secret || generateSecret()
const { pubkey: clientPubkey } = deriveKeypairFromSecret(secretHex)
// Build URI
const params = new URLSearchParams()
params.set('relay', rendezvousUrl)
params.set('secret', secretHex)
if (deviceName) {
params.set('name', deviceName)
}
const uri = `nostr+relayconnect://${relayPubkey}?${params.toString()}`
return { uri, secret: secretHex, clientPubkey }
}
/**
* Generate a CAT-based NRC connection URI
*
* @param relayPubkey - The public key of the listening client/relay
* @param rendezvousUrl - The URL of the rendezvous relay
* @param mintUrl - The URL of the Cashu mint for token verification
* @returns The connection URI
*/
export function generateCATConnectionURI(
relayPubkey: string,
rendezvousUrl: string,
mintUrl: string
): string {
const params = new URLSearchParams()
params.set('relay', rendezvousUrl)
params.set('auth', 'cat')
params.set('mint', mintUrl)
return `nostr+relayconnect://${relayPubkey}?${params.toString()}`
}
/**
* Parse an NRC connection URI
*
* @param uri - The nostr+relayconnect:// URI to parse
* @returns Parsed connection parameters
* @throws Error if URI is invalid
*/
export function parseConnectionURI(uri: string): ParsedConnectionURI {
// Parse as URL
let url: URL
try {
url = new URL(uri)
} catch {
throw new Error('Invalid URI format')
}
// Validate scheme
if (url.protocol !== 'nostr+relayconnect:') {
throw new Error('Invalid URI scheme, expected nostr+relayconnect://')
}
// Extract relay pubkey from host (should be 64 hex chars)
const relayPubkey = url.hostname
if (!/^[0-9a-fA-F]{64}$/.test(relayPubkey)) {
throw new Error('Invalid relay pubkey in URI')
}
// Extract rendezvous relay URL
const rendezvousUrl = url.searchParams.get('relay')
if (!rendezvousUrl) {
throw new Error('Missing relay parameter in URI')
}
// Validate rendezvous URL
try {
new URL(rendezvousUrl)
} catch {
throw new Error('Invalid rendezvous relay URL')
}
// Determine auth mode
const authParam = url.searchParams.get('auth')
const authMode: AuthMode = authParam === 'cat' ? 'cat' : 'secret'
// Extract device name (optional)
const deviceName = url.searchParams.get('name') || undefined
if (authMode === 'cat') {
// CAT-based auth
const mintUrl = url.searchParams.get('mint')
if (!mintUrl) {
throw new Error('CAT auth requires mint parameter')
}
return {
relayPubkey,
rendezvousUrl,
authMode,
mintUrl,
deviceName
}
} else {
// Secret-based auth
const secret = url.searchParams.get('secret')
if (!secret) {
throw new Error('Secret auth requires secret parameter')
}
// Validate secret format (64 hex chars = 32 bytes)
if (!/^[0-9a-fA-F]{64}$/.test(secret)) {
throw new Error('Invalid secret format, expected 64 hex characters')
}
// Derive keypair from secret
const { privkey, pubkey } = deriveKeypairFromSecret(secret)
return {
relayPubkey,
rendezvousUrl,
authMode,
secret,
clientPubkey: pubkey,
clientPrivkey: privkey,
deviceName
}
}
}
/**
* Validate a connection URI without fully parsing it
* Returns true if the URI appears valid, false otherwise
*/
export function isValidConnectionURI(uri: string): boolean {
try {
parseConnectionURI(uri)
return true
} catch {
return false
}
}