Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd7c36400 |
@@ -16,6 +16,7 @@ import { SocialGraphFilterProvider } from '@/providers/SocialGraphFilterProvider
|
|||||||
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
||||||
import { MuteListProvider } from '@/providers/MuteListProvider'
|
import { MuteListProvider } from '@/providers/MuteListProvider'
|
||||||
import { NostrProvider } from '@/providers/NostrProvider'
|
import { NostrProvider } from '@/providers/NostrProvider'
|
||||||
|
import { NRCProvider } from '@/providers/NRCProvider'
|
||||||
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
|
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
|
||||||
import { PinListProvider } from '@/providers/PinListProvider'
|
import { PinListProvider } from '@/providers/PinListProvider'
|
||||||
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
||||||
@@ -38,6 +39,7 @@ export default function App(): JSX.Element {
|
|||||||
<DeletedEventProvider>
|
<DeletedEventProvider>
|
||||||
<PasswordPromptProvider>
|
<PasswordPromptProvider>
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
|
<NRCProvider>
|
||||||
<RepositoryProvider>
|
<RepositoryProvider>
|
||||||
<SettingsSyncProvider>
|
<SettingsSyncProvider>
|
||||||
<ZapProvider>
|
<ZapProvider>
|
||||||
@@ -72,6 +74,7 @@ export default function App(): JSX.Element {
|
|||||||
</ZapProvider>
|
</ZapProvider>
|
||||||
</SettingsSyncProvider>
|
</SettingsSyncProvider>
|
||||||
</RepositoryProvider>
|
</RepositoryProvider>
|
||||||
|
</NRCProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</PasswordPromptProvider>
|
</PasswordPromptProvider>
|
||||||
</DeletedEventProvider>
|
</DeletedEventProvider>
|
||||||
|
|||||||
409
src/components/NRCSettings/index.tsx
Normal file
409
src/components/NRCSettings/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import EmojiPackList from '@/components/EmojiPackList'
|
|||||||
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
|
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
|
||||||
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
|
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
|
||||||
import MailboxSetting from '@/components/MailboxSetting'
|
import MailboxSetting from '@/components/MailboxSetting'
|
||||||
|
import NRCSettings from '@/components/NRCSettings'
|
||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
import Tabs from '@/components/Tabs'
|
import Tabs from '@/components/Tabs'
|
||||||
import {
|
import {
|
||||||
@@ -73,6 +74,7 @@ import {
|
|||||||
PencilLine,
|
PencilLine,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ScanLine,
|
ScanLine,
|
||||||
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
Smile,
|
Smile,
|
||||||
@@ -105,7 +107,7 @@ const NOTIFICATION_STYLES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
// Accordion item values for keyboard navigation
|
// 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() {
|
export default function Settings() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
@@ -123,7 +125,7 @@ export default function Settings() {
|
|||||||
// Get the visible accordion items based on pubkey availability
|
// Get the visible accordion items based on pubkey availability
|
||||||
const visibleAccordionItems = pubkey
|
const visibleAccordionItems = pubkey
|
||||||
? ACCORDION_ITEMS
|
? 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
|
// Register as a navigation region - Settings decides what "up/down" means
|
||||||
const handleSettingsIntent = useCallback(
|
const handleSettingsIntent = useCallback(
|
||||||
@@ -548,6 +550,23 @@ export default function Settings() {
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</NavigableAccordionItem>
|
</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 */}
|
{/* Wallet */}
|
||||||
{!!pubkey && (
|
{!!pubkey && (
|
||||||
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
|
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
|
||||||
|
|||||||
310
src/providers/NRCProvider.tsx
Normal file
310
src/providers/NRCProvider.tsx
Normal 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>
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { TDMDeletedState, TRelayInfo } from '@/types'
|
import { TDMDeletedState, TRelayInfo } from '@/types'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, Filter, kinds, matchFilters } from 'nostr-tools'
|
||||||
|
|
||||||
type TValue<T = any> = {
|
type TValue<T = any> = {
|
||||||
key: string
|
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() {
|
private async cleanUp() {
|
||||||
await this.initPromise
|
await this.initPromise
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
|
|||||||
4
src/services/nrc/index.ts
Normal file
4
src/services/nrc/index.ts
Normal 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'
|
||||||
558
src/services/nrc/nrc-listener.service.ts
Normal file
558
src/services/nrc/nrc-listener.service.ts
Normal 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()
|
||||||
240
src/services/nrc/nrc-session.ts
Normal file
240
src/services/nrc/nrc-session.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/services/nrc/nrc-types.ts
Normal file
108
src/services/nrc/nrc-types.ts
Normal 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
189
src/services/nrc/nrc-uri.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user