1 Commits

Author SHA1 Message Date
woikos
5b23ea04d0 Add QR scanner to bunker login and enhance NRC functionality
- Add QR scanner button to bunker URL paste input for easier mobile login
- Enhance NRC (Nostr Relay Connect) with improved connection handling
- Update NRC settings UI with better status display
- Improve bunker signer reliability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:41:00 +01:00
18 changed files with 2525 additions and 311 deletions

11
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "smesh",
"version": "0.3.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smesh",
"version": "0.3.0",
"version": "0.4.1",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -56,6 +56,7 @@
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43",
"franc-min": "^6.2.0",
"html5-qrcode": "^2.3.8",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"jotai": "^2.15.0",
@@ -8932,6 +8933,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/i18next": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",

View File

@@ -70,6 +70,7 @@
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43",
"franc-min": "^6.2.0",
"html5-qrcode": "^2.3.8",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"jotai": "^2.15.0",

View File

@@ -1,9 +1,10 @@
import QrScannerModal from '@/components/QrScannerModal'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { BunkerSigner } from '@/providers/NostrProvider/bunker.signer'
import { ArrowLeft, Loader2, QrCode, Server, Copy, Check } from 'lucide-react'
import { ArrowLeft, Loader2, QrCode, Server, Copy, Check, ScanLine } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import QRCode from 'qrcode'
@@ -28,6 +29,7 @@ export default function BunkerLogin({
const [connectUrl, setConnectUrl] = useState<string | null>(null)
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [showScanner, setShowScanner] = useState(false)
// Generate QR code when in scan mode
useEffect(() => {
@@ -88,6 +90,11 @@ export default function BunkerLogin({
}
}, [mode, relayUrl, bunkerLoginWithSigner, onLoginSuccess])
const handleScan = (result: string) => {
setBunkerUrl(result)
setError(null)
}
const handlePasteSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!bunkerUrl.trim()) {
@@ -263,49 +270,66 @@ export default function BunkerLogin({
// Paste mode
return (
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
<ArrowLeft className="size-4" />
</Button>
<>
{showScanner && (
<QrScannerModal onScan={handleScan} onClose={() => setShowScanner(false)} />
)}
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<Server className="size-5" />
<span className="font-semibold">{t('Paste Bunker URL')}</span>
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
<ArrowLeft className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Server className="size-5" />
<span className="font-semibold">{t('Paste Bunker URL')}</span>
</div>
</div>
</div>
<form onSubmit={handlePasteSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
<Input
id="bunkerUrl"
type="text"
placeholder="bunker://pubkey?relay=wss://..."
value={bunkerUrl}
onChange={(e) => setBunkerUrl(e.target.value)}
disabled={loading}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{t(
'Enter the bunker connection URL. This is typically provided by your signing device or service.'
<form onSubmit={handlePasteSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
<div className="flex gap-2">
<Input
id="bunkerUrl"
type="text"
placeholder="bunker://pubkey?relay=wss://..."
value={bunkerUrl}
onChange={(e) => setBunkerUrl(e.target.value)}
disabled={loading}
className="font-mono text-sm"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowScanner(true)}
disabled={loading}
title={t('Scan QR code')}
>
<ScanLine className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t(
'Enter the bunker connection URL. This is typically provided by your signing device or service.'
)}
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button type="submit" className="w-full" disabled={loading || !bunkerUrl.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('Connecting...')}
</>
) : (
t('Connect to Bunker')
)}
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button type="submit" className="w-full" disabled={loading || !bunkerUrl.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('Connecting...')}
</>
) : (
t('Connect to Bunker')
)}
</Button>
</form>
</div>
</Button>
</form>
</div>
</>
)
}

View File

@@ -2,16 +2,21 @@
* NRC Settings Component
*
* UI for managing Nostr Relay Connect (NRC) connections and listener settings.
* Includes both:
* - Listener mode: Allow other devices to connect to this one
* - Client mode: Connect to and sync from other devices
*/
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useNRC } from '@/providers/NRCProvider'
import { useNostr } from '@/providers/NostrProvider'
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
@@ -41,28 +46,47 @@ import {
Wifi,
WifiOff,
Users,
Server
Server,
RefreshCw,
Smartphone,
Download,
Camera,
Zap,
Coins
} from 'lucide-react'
import { NRCConnection } from '@/services/nrc'
import { NRCConnection, RemoteConnection } from '@/services/nrc'
import QRCode from 'qrcode'
import { Html5Qrcode } from 'html5-qrcode'
export default function NRCSettings() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const {
// Listener state
isEnabled,
isConnected,
connections,
activeSessions,
rendezvousUrl,
relaySupportsCat,
enable,
disable,
addConnection,
removeConnection,
getConnectionURI,
setRendezvousUrl
setRendezvousUrl,
// Client state
remoteConnections,
isSyncing,
syncProgress,
addRemoteConnection,
removeRemoteConnection,
testRemoteConnection,
syncFromDevice,
syncAllRemotes
} = useNRC()
// Listener state
const [newConnectionLabel, setNewConnectionLabel] = useState('')
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isQRDialogOpen, setIsQRDialogOpen] = useState(false)
@@ -72,6 +96,24 @@ export default function NRCSettings() {
const [copiedUri, setCopiedUri] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Client state
const [connectionUri, setConnectionUri] = useState('')
const [newRemoteLabel, setNewRemoteLabel] = useState('')
const [isConnectDialogOpen, setIsConnectDialogOpen] = useState(false)
const [isScannerOpen, setIsScannerOpen] = useState(false)
const [scannerError, setScannerError] = useState('')
const scannerRef = useRef<Html5Qrcode | null>(null)
const scannerContainerRef = useRef<HTMLDivElement>(null)
// Private config sync setting
const [nrcOnlyConfigSync, setNrcOnlyConfigSync] = useState(storage.getNrcOnlyConfigSync())
const handleToggleNrcOnlyConfig = useCallback((checked: boolean) => {
storage.setNrcOnlyConfigSync(checked)
setNrcOnlyConfigSync(checked)
dispatchSettingsChanged()
}, [])
// Generate QR code when URI changes
const generateQRCode = useCallback(async (uri: string) => {
try {
@@ -158,6 +200,118 @@ export default function NRCSettings() {
[removeConnection]
)
// ===== Client Handlers =====
const handleAddRemoteConnection = useCallback(async () => {
if (!connectionUri.trim() || !newRemoteLabel.trim()) return
setIsLoading(true)
try {
await addRemoteConnection(connectionUri.trim(), newRemoteLabel.trim())
setIsConnectDialogOpen(false)
setConnectionUri('')
setNewRemoteLabel('')
} catch (error) {
console.error('Failed to add remote connection:', error)
} finally {
setIsLoading(false)
}
}, [connectionUri, newRemoteLabel, addRemoteConnection])
const handleRemoveRemoteConnection = useCallback(
async (id: string) => {
try {
await removeRemoteConnection(id)
} catch (error) {
console.error('Failed to remove remote connection:', error)
}
},
[removeRemoteConnection]
)
const handleSyncDevice = useCallback(
async (id: string) => {
try {
await syncFromDevice(id)
} catch (error) {
console.error('Failed to sync from device:', error)
}
},
[syncFromDevice]
)
const handleTestConnection = useCallback(
async (id: string) => {
try {
await testRemoteConnection(id)
} catch (error) {
console.error('Failed to test connection:', error)
}
},
[testRemoteConnection]
)
const handleSyncAll = useCallback(async () => {
try {
await syncAllRemotes()
} catch (error) {
console.error('Failed to sync all remotes:', error)
}
}, [syncAllRemotes])
const startScanner = useCallback(async () => {
if (!scannerContainerRef.current) return
setScannerError('')
try {
const scanner = new Html5Qrcode('qr-scanner-container')
scannerRef.current = scanner
await scanner.start(
{ facingMode: 'environment' },
{
fps: 10,
qrbox: { width: 250, height: 250 }
},
(decodedText) => {
// Found a QR code
if (decodedText.startsWith('nostr+relayconnect://')) {
setConnectionUri(decodedText)
stopScanner()
setIsScannerOpen(false)
setIsConnectDialogOpen(true)
}
},
() => {
// Ignore errors while scanning
}
)
} catch (error) {
console.error('Failed to start scanner:', error)
setScannerError(error instanceof Error ? error.message : 'Failed to start camera')
}
}, [])
const stopScanner = useCallback(() => {
if (scannerRef.current) {
scannerRef.current.stop().catch(() => {
// Ignore errors when stopping
})
scannerRef.current = null
}
}, [])
const handleOpenScanner = useCallback(() => {
setIsScannerOpen(true)
// Start scanner after dialog renders
setTimeout(startScanner, 100)
}, [startScanner])
const handleCloseScanner = useCallback(() => {
stopScanner()
setIsScannerOpen(false)
setScannerError('')
}, [stopScanner])
if (!pubkey) {
return (
<div className="text-muted-foreground text-sm">
@@ -168,156 +322,364 @@ export default function NRCSettings() {
return (
<div className="space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
{/* Private Configuration Sync Toggle */}
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
<div className="space-y-1">
<Label htmlFor="nrc-enabled" className="text-base font-medium">
{t('Enable Relay Connect')}
<Label htmlFor="nrc-only-config" className="text-base font-medium">
{t('Private Configuration Sync')}
</Label>
<p className="text-sm text-muted-foreground">
{t('Allow other devices to sync with this client')}
{t('Only sync configurations between paired devices, not to public relays')}
</p>
</div>
<Switch
id="nrc-enabled"
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
disabled={isLoading}
id="nrc-only-config"
checked={nrcOnlyConfigSync}
onCheckedChange={handleToggleNrcOnlyConfig}
/>
</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>
<Tabs defaultValue="listener" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="listener" className="gap-2">
<Server className="w-4 h-4" />
{t('Share')}
</TabsTrigger>
<TabsTrigger value="client" className="gap-2">
<Smartphone className="w-4 h-4" />
{t('Connect')}
</TabsTrigger>
</TabsList>
{/* ===== LISTENER TAB ===== */}
<TabsContent value="listener" className="space-y-6 mt-4">
{/* 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>
{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>
{/* 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>
)}
</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>
) : (
{/* Rendezvous Relay */}
<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"
<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>
{/* CAT (Cashu Access Token) Status */}
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
<div className="flex items-center gap-2">
<Coins className="w-4 h-4" />
<span className="text-sm">{t('CAT Authentication')}</span>
</div>
{relaySupportsCat ? (
<span className="px-2 py-1 bg-primary/10 text-primary rounded text-xs">
{t('Available')}
</span>
) : (
<span className="px-2 py-1 bg-muted text-muted-foreground rounded text-xs">
{t('Not Available')}
</span>
)}
</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"
>
<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')}
<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"
>
<QrCode className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<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"
className="text-destructive hover:text-destructive"
title={t('Remove')}
onClick={() => handleShowQR(connection)}
title={t('Show QR Code')}
>
<Trash2 className="w-4 h-4" />
<QrCode 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>
<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>
)}
</div>
</TabsContent>
{/* Add Connection Dialog */}
{/* ===== CLIENT TAB ===== */}
<TabsContent value="client" className="space-y-6 mt-4">
{/* Sync Progress */}
{isSyncing && syncProgress && (
<div className="p-3 bg-muted/50 rounded-lg space-y-2">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin" />
<span className="text-sm font-medium">
{syncProgress.phase === 'connecting' && t('Connecting...')}
{syncProgress.phase === 'requesting' && t('Requesting events...')}
{syncProgress.phase === 'receiving' && t('Receiving events...')}
{syncProgress.phase === 'complete' && t('Sync complete')}
{syncProgress.phase === 'error' && t('Error')}
</span>
</div>
{syncProgress.eventsReceived > 0 && (
<div className="text-xs text-muted-foreground">
{t('{{count}} events received', { count: syncProgress.eventsReceived })}
</div>
)}
{syncProgress.message && syncProgress.phase === 'error' && (
<div className="text-xs text-destructive">{syncProgress.message}</div>
)}
</div>
)}
{/* Connect to Device */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Download className="w-4 h-4" />
{t('Remote Devices')}
</Label>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleOpenScanner}
className="gap-1"
>
<Camera className="w-4 h-4" />
{t('Scan')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsConnectDialogOpen(true)}
className="gap-1"
>
<Plus className="w-4 h-4" />
{t('Add')}
</Button>
</div>
</div>
{remoteConnections.length === 0 ? (
<div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
{t('No remote devices configured')}
</div>
) : (
<div className="space-y-2">
{/* Sync All Button */}
{remoteConnections.length > 1 && (
<Button
variant="secondary"
size="sm"
onClick={handleSyncAll}
disabled={isSyncing}
className="w-full gap-2"
>
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
{t('Sync All Devices')}
</Button>
)}
{remoteConnections.map((remote: RemoteConnection) => (
<div
key={remote.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">{remote.label}</div>
<div className="text-xs text-muted-foreground">
{remote.lastSync ? (
<>
{t('Last sync')}: {new Date(remote.lastSync).toLocaleString()}
{remote.eventCount !== undefined && (
<span className="ml-2">({remote.eventCount} {t('events')})</span>
)}
</>
) : (
t('Never synced')
)}
</div>
</div>
<div className="flex items-center gap-1">
{/* Show Test button if never synced, Sync button otherwise */}
{!remote.lastSync ? (
<Button
variant="ghost"
size="icon"
onClick={() => handleTestConnection(remote.id)}
disabled={isSyncing}
title={t('Test Connection')}
>
<Zap className={`w-4 h-4 ${isSyncing ? 'animate-pulse' : ''}`} />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
onClick={() => handleSyncDevice(remote.id)}
disabled={isSyncing}
title={t('Sync')}
>
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
</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 Remote Device?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('This will remove "{{label}}" from your remote devices list.', {
label: remote.label
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleRemoveRemoteConnection(remote.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('Remove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
</div>
</TabsContent>
</Tabs>
{/* ===== DIALOGS ===== */}
{/* Add Connection Dialog (Listener) */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent>
<DialogHeader>
@@ -404,6 +766,82 @@ export default function NRCSettings() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Connect to Remote Dialog (Client) */}
<Dialog open={isConnectDialogOpen} onOpenChange={setIsConnectDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Connect to Device')}</DialogTitle>
<DialogDescription>
{t('Enter a connection URI from another device to sync with it')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="connection-uri">{t('Connection URI')}</Label>
<Input
id="connection-uri"
value={connectionUri}
onChange={(e) => setConnectionUri(e.target.value)}
placeholder="nostr+relayconnect://..."
className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-label">{t('Device Name')}</Label>
<Input
id="remote-label"
value={newRemoteLabel}
onChange={(e) => setNewRemoteLabel(e.target.value)}
placeholder={t('e.g., Desktop, Main Phone')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddRemoteConnection()
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsConnectDialogOpen(false)}>
{t('Cancel')}
</Button>
<Button
onClick={handleAddRemoteConnection}
disabled={!connectionUri.trim() || !newRemoteLabel.trim() || isLoading}
>
{t('Connect')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* QR Scanner Dialog */}
<Dialog open={isScannerOpen} onOpenChange={handleCloseScanner}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('Scan QR Code')}</DialogTitle>
<DialogDescription>
{t('Point your camera at a connection QR code')}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div
id="qr-scanner-container"
ref={scannerContainerRef}
className="w-full aspect-square bg-muted rounded-lg overflow-hidden"
/>
{scannerError && (
<div className="mt-2 text-sm text-destructive">{scannerError}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseScanner}>
{t('Cancel')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -50,6 +50,7 @@ export const StorageKey = {
GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled',
SOCIAL_GRAPH_PROXIMITY: 'socialGraphProximity',
SOCIAL_GRAPH_INCLUDE_MODE: 'socialGraphIncludeMode',
NRC_ONLY_CONFIG_SYNC: 'nrcOnlyConfigSync',
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated

View File

@@ -1,48 +1,77 @@
/**
* NRC (Nostr Relay Connect) Provider
*
* Manages NRC listener state and connections for cross-device sync.
* Manages NRC state for both:
* - Listener mode: Accept connections from other devices
* - Client mode: Connect to and sync from other devices
*/
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
import { Filter, Event } from 'nostr-tools'
import * as utils from '@noble/curves/abstract/utils'
import { useNostr } from './NostrProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import cashuTokenService, { TokenScope } from '@/services/cashu-token.service'
import {
NRCConnection,
CATConfig,
NRCListenerConfig,
generateConnectionURI,
generateCATConnectionURI,
getNRCListenerService
getNRCListenerService,
syncFromRemote,
testConnection,
parseConnectionURI,
relaySupportsCat,
deriveMintUrlFromRelay,
requestRemoteIDs,
sendEventsToRemote,
EventManifestEntry
} from '@/services/nrc'
import type { SyncProgress, RemoteConnection } from '@/services/nrc'
// Kinds to sync bidirectionally
const SYNC_KINDS = [0, 3, 10000, 10001, 10002, 10003, 10012, 30002]
// Storage keys
const STORAGE_KEY_ENABLED = 'nrc:enabled'
const STORAGE_KEY_CONNECTIONS = 'nrc:connections'
const STORAGE_KEY_REMOTE_CONNECTIONS = 'nrc:remoteConnections'
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
// Listener State (this device accepts connections)
isEnabled: boolean
isListening: boolean
isConnected: boolean
connections: NRCConnection[]
connections: NRCConnection[] // Devices authorized to connect to us
activeSessions: number
catConfig: CATConfig | null
relaySupportsCat: boolean // Auto-detected CAT support
rendezvousUrl: string
// Actions
// Client State (this device connects to others)
remoteConnections: RemoteConnection[] // Devices we connect to
isSyncing: boolean
syncProgress: SyncProgress | null
// Listener 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
// Client Actions
addRemoteConnection: (uri: string, label: string) => Promise<RemoteConnection>
removeRemoteConnection: (id: string) => Promise<void>
testRemoteConnection: (id: string) => Promise<boolean>
syncFromDevice: (id: string, filters?: Filter[]) => Promise<Event[]>
syncAllRemotes: (filters?: Filter[]) => Promise<Event[]>
}
const NRCContext = createContext<NRCContextType | undefined>(undefined)
@@ -62,7 +91,7 @@ interface NRCProviderProps {
export function NRCProvider({ children }: NRCProviderProps) {
const { pubkey } = useNostr()
// Load initial state from storage
// ===== Listener State =====
const [isEnabled, setIsEnabled] = useState<boolean>(() => {
const stored = localStorage.getItem(STORAGE_KEY_ENABLED)
return stored === 'true'
@@ -84,25 +113,32 @@ export function NRCProvider({ children }: NRCProviderProps) {
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
})
// Auto-detected CAT support for the rendezvous relay
const [relaySupportsCatState, setRelaySupportsCatState] = useState(false)
const [isListening, setIsListening] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const [activeSessions, setActiveSessions] = useState(0)
// ===== Client State =====
const [remoteConnections, setRemoteConnections] = useState<RemoteConnection[]>(() => {
const stored = localStorage.getItem(STORAGE_KEY_REMOTE_CONNECTIONS)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return []
}
}
return []
})
const [isSyncing, setIsSyncing] = useState(false)
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
const listenerService = getNRCListenerService()
// Persist state to storage
// ===== Persist State =====
useEffect(() => {
localStorage.setItem(STORAGE_KEY_ENABLED, String(isEnabled))
}, [isEnabled])
@@ -111,19 +147,41 @@ export function NRCProvider({ children }: NRCProviderProps) {
localStorage.setItem(STORAGE_KEY_CONNECTIONS, JSON.stringify(connections))
}, [connections])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_REMOTE_CONNECTIONS, JSON.stringify(remoteConnections))
}, [remoteConnections])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl)
}, [rendezvousUrl])
// Auto-detect CAT support when rendezvous URL changes
useEffect(() => {
if (catConfig) {
localStorage.setItem(STORAGE_KEY_CAT_CONFIG, JSON.stringify(catConfig))
} else {
localStorage.removeItem(STORAGE_KEY_CAT_CONFIG)
}
}, [catConfig])
let cancelled = false
// Build authorized secrets map from connections
const checkCatSupport = async () => {
try {
const supported = await relaySupportsCat(rendezvousUrl)
if (!cancelled) {
setRelaySupportsCatState(supported)
console.log(`[NRC] Relay ${rendezvousUrl} CAT support:`, supported)
}
} catch (err) {
if (!cancelled) {
setRelaySupportsCatState(false)
console.log(`[NRC] Failed to check CAT support for ${rendezvousUrl}:`, err)
}
}
}
checkCatSupport()
return () => {
cancelled = true
}
}, [rendezvousUrl])
// ===== Listener Logic =====
const buildAuthorizedSecrets = useCallback((): Map<string, string> => {
const map = new Map<string, string>()
for (const conn of connections) {
@@ -134,7 +192,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
return map
}, [connections])
// Start/stop listener based on enabled state
useEffect(() => {
if (!isEnabled || !client.signer || !pubkey) {
if (listenerService.isRunning()) {
@@ -146,15 +203,29 @@ export function NRCProvider({ children }: NRCProviderProps) {
return
}
// Stop existing listener before starting with new config
if (listenerService.isRunning()) {
listenerService.stop()
}
let statusInterval: ReturnType<typeof setInterval> | null = null
const startListener = async () => {
try {
// Build CAT config if relay supports it
const catConfig: CATConfig | undefined = relaySupportsCatState
? { mintUrl: deriveMintUrlFromRelay(rendezvousUrl), scope: 'nrc' }
: undefined
const config: NRCListenerConfig = {
rendezvousUrl,
signer: client.signer!,
authorizedSecrets: buildAuthorizedSecrets(),
catConfig: catConfig || undefined
catConfig
}
console.log('[NRC] Starting listener with', config.authorizedSecrets.size, 'authorized clients')
listenerService.setOnSessionChange((count) => {
setActiveSessions(count)
})
@@ -163,15 +234,10 @@ export function NRCProvider({ children }: NRCProviderProps) {
setIsListening(true)
setIsConnected(listenerService.isConnected())
// Poll connection status
const statusInterval = setInterval(() => {
statusInterval = setInterval(() => {
setIsConnected(listenerService.isConnected())
setActiveSessions(listenerService.getActiveSessionCount())
}, 5000)
return () => {
clearInterval(statusInterval)
}
} catch (error) {
console.error('[NRC] Failed to start listener:', error)
setIsListening(false)
@@ -179,21 +245,305 @@ export function NRCProvider({ children }: NRCProviderProps) {
}
}
const cleanup = startListener()
return () => {
cleanup?.then((fn) => fn?.())
}
}, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets, catConfig])
startListener()
return () => {
if (statusInterval) {
clearInterval(statusInterval)
}
listenerService.stop()
setIsListening(false)
setIsConnected(false)
setActiveSessions(0)
}
}, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets, relaySupportsCatState])
// 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])
// ===== Auto-sync remote connections (bidirectional) =====
// Sync interval: 15 minutes
const AUTO_SYNC_INTERVAL = 15 * 60 * 1000
// Minimum time between syncs for the same connection: 5 minutes
const MIN_SYNC_INTERVAL = 5 * 60 * 1000
/**
* Get a CAT token for authentication
*/
const getCATToken = async (mintUrl: string, userPubkey: string): Promise<string | undefined> => {
if (!client.signer) return undefined
try {
cashuTokenService.setMint(mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkeyBytes = utils.hexToBytes(userPubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NRC,
userPubkeyBytes,
async (url: string, method: string) => {
const authEvent = await client.signer!.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: ''
})
return `Nostr ${btoa(JSON.stringify(authEvent))}`
}
)
return cashuTokenService.encodeToken(token)
} catch (err) {
console.error('[NRC] Failed to get CAT token:', err)
return undefined
}
}
/**
* Get local events for sync kinds and build manifest
*/
const getLocalEventsAndManifest = async (): Promise<{
events: Event[]
manifest: EventManifestEntry[]
}> => {
const events = await indexedDb.queryEventsForNRC([{ kinds: SYNC_KINDS, limit: 1000 }])
const manifest: EventManifestEntry[] = events.map((e) => ({
kind: e.kind,
id: e.id,
created_at: e.created_at,
d: e.tags.find((t) => t[0] === 'd')?.[1]
}))
return { events, manifest }
}
/**
* Diff manifests to find what each side needs
* For replaceable events: compare by (kind, pubkey, d) and use newer created_at
*/
const diffManifests = (
local: EventManifestEntry[],
remote: EventManifestEntry[],
localEvents: Event[]
): { toSend: Event[]; toFetch: string[] } => {
// Build maps keyed by (kind, d) for replaceable events
const localMap = new Map<string, EventManifestEntry>()
const localEventsMap = new Map<string, Event>()
for (let i = 0; i < local.length; i++) {
const entry = local[i]
const key = `${entry.kind}:${entry.d || ''}`
const existing = localMap.get(key)
// Keep the newer one
if (!existing || entry.created_at > existing.created_at) {
localMap.set(key, entry)
localEventsMap.set(entry.id, localEvents[i])
}
}
const remoteMap = new Map<string, EventManifestEntry>()
for (const entry of remote) {
const key = `${entry.kind}:${entry.d || ''}`
const existing = remoteMap.get(key)
if (!existing || entry.created_at > existing.created_at) {
remoteMap.set(key, entry)
}
}
const toSend: Event[] = []
const toFetch: string[] = []
// Find events we have that are newer than remote's (or remote doesn't have)
for (const [key, localEntry] of localMap) {
const remoteEntry = remoteMap.get(key)
if (!remoteEntry || localEntry.created_at > remoteEntry.created_at) {
const event = localEventsMap.get(localEntry.id)
if (event) {
toSend.push(event)
}
}
}
// Find events remote has that are newer than ours (or we don't have)
for (const [key, remoteEntry] of remoteMap) {
const localEntry = localMap.get(key)
if (!localEntry || remoteEntry.created_at > localEntry.created_at) {
toFetch.push(remoteEntry.id)
}
}
return { toSend, toFetch }
}
useEffect(() => {
// Only auto-sync if we have remote connections and a signer
if (remoteConnections.length === 0 || !client.signer || !pubkey) {
return
}
// Don't auto-sync if already syncing
if (isSyncing) {
return
}
const bidirectionalSync = async () => {
const now = Date.now()
// Find connections that need syncing
const needsSync = remoteConnections.filter(
(c) => !c.lastSync || (now - c.lastSync) > MIN_SYNC_INTERVAL
)
if (needsSync.length === 0) {
return
}
console.log(`[NRC] Bidirectional sync: ${needsSync.length} connection(s) need syncing`)
for (const remote of needsSync) {
if (isSyncing) break
try {
console.log(`[NRC] Bidirectional sync with ${remote.label}...`)
setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
// Get CAT token if needed
let catToken: string | undefined
if (remote.authMode === 'cat' && remote.mintUrl) {
catToken = await getCATToken(remote.mintUrl, pubkey)
if (!catToken) {
console.error(`[NRC] Failed to get CAT token for ${remote.label}, skipping`)
continue
}
}
const signer = remote.authMode === 'cat' ? client.signer : undefined
// Step 1: Get remote's event IDs
setSyncProgress({ phase: 'requesting', eventsReceived: 0, message: 'Getting remote event list...' })
const remoteManifest = await requestRemoteIDs(
remote.uri,
[{ kinds: SYNC_KINDS, limit: 1000 }],
undefined,
signer,
catToken
)
console.log(`[NRC] Remote has ${remoteManifest.length} events`)
// Step 2: Get our local events and manifest
const { events: localEvents, manifest: localManifest } = await getLocalEventsAndManifest()
console.log(`[NRC] Local has ${localManifest.length} events`)
// Step 3: Diff to find what each side needs
const { toSend, toFetch } = diffManifests(localManifest, remoteManifest, localEvents)
console.log(`[NRC] Diff: sending ${toSend.length}, fetching ${toFetch.length}`)
let eventsSent = 0
let eventsReceived = 0
// Step 4: Send events remote needs (need new CAT token for new connection)
if (toSend.length > 0) {
setSyncProgress({ phase: 'sending', eventsReceived: 0, eventsSent: 0, message: `Sending ${toSend.length} events...` })
// Get fresh CAT token for sending
let sendCatToken = catToken
if (remote.authMode === 'cat' && remote.mintUrl) {
sendCatToken = await getCATToken(remote.mintUrl, pubkey)
}
eventsSent = await sendEventsToRemote(
remote.uri,
toSend,
(progress) => setSyncProgress({ ...progress, message: `Sending events... (${progress.eventsSent || 0}/${toSend.length})` }),
signer,
sendCatToken
)
console.log(`[NRC] Sent ${eventsSent} events to ${remote.label}`)
}
// Step 5: Fetch events we need using regular filter queries
if (toFetch.length > 0) {
setSyncProgress({ phase: 'receiving', eventsReceived: 0, eventsSent, message: `Fetching ${toFetch.length} events...` })
// Get fresh CAT token for fetching
let fetchCatToken = catToken
if (remote.authMode === 'cat' && remote.mintUrl) {
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
}
// Fetch by ID in batches (relay may limit number of IDs per filter)
const BATCH_SIZE = 50
const fetchedEvents: Event[] = []
for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
const batch = toFetch.slice(i, i + BATCH_SIZE)
const events = await syncFromRemote(
remote.uri,
[{ ids: batch }],
(progress) => setSyncProgress({
...progress,
eventsSent,
message: `Fetching events... (${fetchedEvents.length + progress.eventsReceived}/${toFetch.length})`
}),
signer,
fetchCatToken
)
fetchedEvents.push(...events)
// Get new CAT token for next batch if needed
if (remote.authMode === 'cat' && remote.mintUrl && i + BATCH_SIZE < toFetch.length) {
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
}
}
// Store fetched events
for (const event of fetchedEvents) {
try {
await indexedDb.putReplaceableEvent(event)
} catch {
// Ignore storage errors
}
}
eventsReceived = fetchedEvents.length
console.log(`[NRC] Received ${eventsReceived} events from ${remote.label}`)
}
// Update last sync time
setRemoteConnections((prev) =>
prev.map((c) =>
c.id === remote.id
? { ...c, lastSync: Date.now(), eventCount: eventsReceived }
: c
)
)
console.log(`[NRC] Bidirectional sync complete with ${remote.label}: sent ${eventsSent}, received ${eventsReceived}`)
} catch (err) {
console.error(`[NRC] Bidirectional sync failed for ${remote.label}:`, err)
} finally {
setIsSyncing(false)
setSyncProgress(null)
}
}
}
// Run initial sync after a short delay
const initialTimer = setTimeout(bidirectionalSync, 3000)
// Set up periodic sync
const intervalTimer = setInterval(bidirectionalSync, AUTO_SYNC_INTERVAL)
return () => {
clearTimeout(initialTimer)
clearInterval(intervalTimer)
}
}, [remoteConnections.length, pubkey, isSyncing])
// ===== Listener Actions =====
const enable = useCallback(async () => {
if (!client.signer) {
throw new Error('Signer required to enable NRC')
@@ -221,9 +571,9 @@ export function NRCProvider({ children }: NRCProviderProps) {
let connection: NRCConnection
let uri: string
if (useCat && catConfig) {
// CAT-based connection
uri = generateCATConnectionURI(pubkey, rendezvousUrl, catConfig.mintUrl)
// Use CAT if requested AND relay supports it, otherwise fall back to secret-based
if (useCat && relaySupportsCatState) {
uri = generateCATConnectionURI(pubkey, rendezvousUrl)
connection = {
id,
label,
@@ -231,7 +581,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
createdAt
}
} else {
// Secret-based connection
const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label)
uri = result.uri
connection = {
@@ -248,7 +597,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
return { uri, connection }
},
[pubkey, rendezvousUrl, catConfig]
[pubkey, rendezvousUrl, relaySupportsCatState]
)
const removeConnection = useCallback(async (id: string) => {
@@ -261,8 +610,8 @@ export function NRCProvider({ children }: NRCProviderProps) {
throw new Error('Not logged in')
}
if (connection.useCat && catConfig) {
return generateCATConnectionURI(pubkey, rendezvousUrl, catConfig.mintUrl)
if (connection.useCat && relaySupportsCatState) {
return generateCATConnectionURI(pubkey, rendezvousUrl)
}
if (connection.secret) {
@@ -275,27 +624,219 @@ export function NRCProvider({ children }: NRCProviderProps) {
return result.uri
}
throw new Error('Connection has no secret or CAT config')
throw new Error('Connection has no secret and relay does not support CAT')
},
[pubkey, rendezvousUrl, catConfig]
[pubkey, rendezvousUrl, relaySupportsCatState]
)
const setRendezvousUrl = useCallback((url: string) => {
setRendezvousUrlState(url)
// Listener will restart automatically via effect
}, [])
const setCATConfig = useCallback((config: CATConfig | null) => {
setCATConfigState(config)
// ===== Client Actions =====
const addRemoteConnection = useCallback(
async (uri: string, label: string): Promise<RemoteConnection> => {
// Validate and parse the URI
const parsed = parseConnectionURI(uri)
const remoteConnection: RemoteConnection = {
id: crypto.randomUUID(),
uri,
label,
relayPubkey: parsed.relayPubkey,
rendezvousUrl: parsed.rendezvousUrl,
authMode: parsed.authMode,
mintUrl: parsed.mintUrl
}
setRemoteConnections((prev) => [...prev, remoteConnection])
return remoteConnection
},
[]
)
const removeRemoteConnection = useCallback(async (id: string) => {
setRemoteConnections((prev) => prev.filter((c) => c.id !== id))
}, [])
const syncFromDevice = useCallback(
async (id: string, filters?: Filter[]): Promise<Event[]> => {
const remote = remoteConnections.find((c) => c.id === id)
if (!remote) {
throw new Error('Remote connection not found')
}
setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
try {
// Default filters: sync everything
const syncFilters = filters || [
{ kinds: [0, 3, 10000, 10001, 10002, 10003, 10012, 30002], limit: 1000 }
]
let catToken: string | undefined
// For CAT mode, obtain a token from the mint
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
console.log('[NRC] CAT mode: obtaining token from mint', remote.mintUrl)
try {
cashuTokenService.setMint(remote.mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkeyBytes = utils.hexToBytes(pubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NRC,
userPubkeyBytes,
async (url: string, method: string) => {
// NIP-98 HTTP auth signing
const authEvent = await client.signer!.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: ''
})
return `Nostr ${btoa(JSON.stringify(authEvent))}`
}
)
catToken = cashuTokenService.encodeToken(token)
console.log('[NRC] CAT token obtained successfully')
} catch (err) {
console.error('[NRC] Failed to obtain CAT token:', err)
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
const events = await syncFromRemote(
remote.uri,
syncFilters,
(progress) => setSyncProgress(progress),
remote.authMode === 'cat' ? client.signer : undefined,
catToken
)
// Store synced events in IndexedDB
for (const event of events) {
try {
await indexedDb.putReplaceableEvent(event)
} catch (err) {
console.warn('[NRC] Failed to store event:', err)
}
}
// Update last sync time
setRemoteConnections((prev) =>
prev.map((c) =>
c.id === id ? { ...c, lastSync: Date.now(), eventCount: events.length } : c
)
)
return events
} finally {
setIsSyncing(false)
setSyncProgress(null)
}
},
[remoteConnections, pubkey]
)
const syncAllRemotes = useCallback(
async (filters?: Filter[]): Promise<Event[]> => {
const allEvents: Event[] = []
for (const remote of remoteConnections) {
try {
const events = await syncFromDevice(remote.id, filters)
allEvents.push(...events)
} catch (error) {
console.error(`[NRC] Failed to sync from ${remote.label}:`, error)
}
}
return allEvents
},
[remoteConnections, syncFromDevice]
)
const testRemoteConnection = useCallback(
async (id: string): Promise<boolean> => {
const remote = remoteConnections.find((c) => c.id === id)
if (!remote) {
throw new Error('Remote connection not found')
}
setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0, message: 'Testing connection...' })
try {
let catToken: string | undefined
// For CAT mode, obtain a token from the mint
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
console.log('[NRC] CAT mode: obtaining token for test from mint', remote.mintUrl)
try {
cashuTokenService.setMint(remote.mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkeyBytes = utils.hexToBytes(pubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NRC,
userPubkeyBytes,
async (url: string, method: string) => {
const authEvent = await client.signer!.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: ''
})
return `Nostr ${btoa(JSON.stringify(authEvent))}`
}
)
catToken = cashuTokenService.encodeToken(token)
} catch (err) {
console.error('[NRC] Failed to obtain CAT token for test:', err)
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
const result = await testConnection(
remote.uri,
(progress) => setSyncProgress(progress),
remote.authMode === 'cat' ? client.signer : undefined,
catToken
)
// Update connection to mark it as tested
setRemoteConnections((prev) =>
prev.map((c) =>
c.id === id ? { ...c, lastSync: Date.now(), eventCount: 0 } : c
)
)
return result
} finally {
setIsSyncing(false)
setSyncProgress(null)
}
},
[remoteConnections, pubkey]
)
const value: NRCContextType = {
// Listener
isEnabled,
isListening,
isConnected,
connections,
activeSessions,
catConfig,
relaySupportsCat: relaySupportsCatState,
rendezvousUrl,
enable,
disable,
@@ -303,7 +844,15 @@ export function NRCProvider({ children }: NRCProviderProps) {
removeConnection,
getConnectionURI,
setRendezvousUrl,
setCATConfig
// Client
remoteConnections,
isSyncing,
syncProgress,
addRemoteConnection,
removeRemoteConnection,
testRemoteConnection,
syncFromDevice,
syncAllRemotes
}
return <NRCContext.Provider value={value}>{children}</NRCContext.Provider>

View File

@@ -13,6 +13,7 @@
import { ISigner, TDraftEvent } from '@/types'
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
import { relaySupportsCat, deriveMintUrlFromRelay } from '@/services/nrc'
import * as utils from '@noble/curves/abstract/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
@@ -397,35 +398,28 @@ export class BunkerSigner implements ISigner {
}
/**
* Check if relay requires Cashu token and acquire one if needed.
* Check if relay supports Cashu tokens and acquire one if so.
* Falls back gracefully to regular connection if CAT is not supported.
*/
private async acquireTokenIfNeeded(relayUrl: string): Promise<void> {
// Convert to HTTP URL for mint endpoints
let mintUrl = relayUrl
if (relayUrl.startsWith('ws://')) {
mintUrl = 'http://' + relayUrl.slice(5)
} else if (relayUrl.startsWith('wss://')) {
mintUrl = 'https://' + relayUrl.slice(6)
} else if (!relayUrl.startsWith('http://') && !relayUrl.startsWith('https://')) {
mintUrl = 'https://' + relayUrl
}
mintUrl = mintUrl.replace(/\/$/, '')
try {
// Check if relay has Cashu mint endpoints
const infoResponse = await fetch(`${mintUrl}/cashu/info`)
if (!infoResponse.ok) {
console.log(`Relay ${relayUrl} does not support Cashu tokens`)
// First check if relay supports CAT using the NRC helper
const supportsCat = await relaySupportsCat(relayUrl)
if (!supportsCat) {
console.log(`[Bunker] Relay ${relayUrl} does not support CAT, using regular connection`)
return
}
await infoResponse.json() // Validate JSON response
console.log(`Relay ${relayUrl} requires Cashu token, acquiring...`)
console.log(`[Bunker] Relay ${relayUrl} supports CAT, acquiring token...`)
// Configure the mint
// Derive mint URL from relay URL
const mintUrl = deriveMintUrlFromRelay(relayUrl)
this.mintUrl = mintUrl
cashuTokenService.setMint(mintUrl)
// Fetch mint info to initialize the service
await cashuTokenService.fetchMintInfo()
// Create NIP-98 auth signer using our local ephemeral key
const signHttpAuth = async (url: string, method: string): Promise<string> => {
const authEvent: TDraftEvent = {
@@ -457,10 +451,10 @@ export class BunkerSigner implements ISigner {
this.token = token
cashuTokenService.storeTokens(this.bunkerPubkey, token)
console.log(`Acquired Cashu token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`)
console.log(`[Bunker] Acquired CAT token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`)
} catch (err) {
// Relay doesn't support Cashu or request failed - continue without token
console.warn(`Could not acquire Cashu token for ${relayUrl}:`, err)
// Token acquisition failed - continue without token
console.log(`[Bunker] CAT token acquisition failed for ${relayUrl}, using regular connection:`, err instanceof Error ? err.message : err)
}
}

View File

@@ -45,7 +45,8 @@ function getCurrentSettings(): TSyncSettings {
filterOutOnionRelays: storage.getFilterOutOnionRelays(),
quickReaction: storage.getQuickReaction(),
quickReactionEmoji: storage.getQuickReactionEmoji(),
noteListMode: storage.getNoteListMode()
noteListMode: storage.getNoteListMode(),
nrcOnlyConfigSync: storage.getNrcOnlyConfigSync()
}
}
@@ -113,6 +114,9 @@ function applySettings(settings: TSyncSettings) {
if (settings.noteListMode !== undefined) {
storage.setNoteListMode(settings.noteListMode)
}
if (settings.nrcOnlyConfigSync !== undefined) {
storage.setNrcOnlyConfigSync(settings.nrcOnlyConfigSync)
}
}
export function SettingsSyncProvider({ children }: { children: React.ReactNode }) {
@@ -155,6 +159,9 @@ export function SettingsSyncProvider({ children }: { children: React.ReactNode }
const syncSettings = useCallback(async () => {
if (!pubkey || !account) return
// Skip relay-based settings sync if NRC-only config sync is enabled
if (storage.getNrcOnlyConfigSync()) return
const currentSettings = getCurrentSettings()
const settingsJson = JSON.stringify(currentSettings)
@@ -192,6 +199,13 @@ export function SettingsSyncProvider({ children }: { children: React.ReactNode }
return
}
// Skip relay-based settings sync if NRC-only config sync is enabled
// (settings will sync via NRC instead)
if (storage.getNrcOnlyConfigSync()) {
lastSyncedSettingsRef.current = JSON.stringify(getCurrentSettings())
return
}
const loadRemoteSettings = async () => {
setIsLoading(true)
try {

View File

@@ -22,7 +22,8 @@ export const TokenScope = {
RELAY: 'relay',
NIP46: 'nip46',
BLOSSOM: 'blossom',
API: 'api'
API: 'api',
NRC: 'nrc'
} as const
export type TTokenScope = (typeof TokenScope)[keyof typeof TokenScope]

View File

@@ -95,6 +95,19 @@ class ClientService extends EventTarget {
}
}
// NRC-only config sync: don't publish config events to relays, only sync via NRC
const CONFIG_KINDS = [
kinds.Contacts, // 3
kinds.Mutelist, // 10000
kinds.RelayList, // 10002
30002, // Relay sets
ExtendedKind.FAVORITE_RELAYS, // 10012
30078 // Application data (settings sync)
]
if (storage.getNrcOnlyConfigSync() && CONFIG_KINDS.includes(event.kind)) {
return [] // No relays - NRC will sync this event to paired devices
}
const relaySet = new Set<string>()
if (specifiedRelayUrls?.length) {
specifiedRelayUrls.forEach((url) => relaySet.add(url))

View File

@@ -63,6 +63,7 @@ class LocalStorageService {
private graphQueriesEnabled: boolean = true
private socialGraphProximity: number | null = null
private socialGraphIncludeMode: boolean = true // true = include only, false = exclude
private nrcOnlyConfigSync: boolean = false
constructor() {
if (!LocalStorageService.instance) {
@@ -264,6 +265,9 @@ class LocalStorageService {
this.socialGraphIncludeMode =
window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE) !== 'false'
this.nrcOnlyConfigSync =
window.localStorage.getItem(StorageKey.NRC_ONLY_CONFIG_SYNC) === 'true'
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
@@ -686,6 +690,15 @@ class LocalStorageService {
this.socialGraphIncludeMode = include
window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE, include.toString())
}
getNrcOnlyConfigSync() {
return this.nrcOnlyConfigSync
}
setNrcOnlyConfigSync(nrcOnly: boolean) {
this.nrcOnlyConfigSync = nrcOnly
window.localStorage.setItem(StorageKey.NRC_ONLY_CONFIG_SYNC, nrcOnly.toString())
}
}
const instance = new LocalStorageService()

View File

@@ -2,3 +2,5 @@ export * from './nrc-types'
export * from './nrc-uri'
export * from './nrc-session'
export { NRCListenerService, getNRCListenerService, default as nrcListenerService } from './nrc-listener.service'
export { NRCClient, syncFromRemote, testConnection, requestRemoteIDs, sendEventsToRemote } from './nrc-client.service'
export type { SyncProgress, RemoteConnection } from './nrc-client.service'

View File

@@ -0,0 +1,877 @@
/**
* NRC (Nostr Relay Connect) Client Service
*
* Connects to a remote NRC listener and syncs events.
* Uses the nostr+relayconnect:// URI scheme to establish encrypted
* communication through a rendezvous relay.
*/
import { Event, Filter } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44'
import * as utils from '@noble/curves/abstract/utils'
import { finalizeEvent } from 'nostr-tools'
import { ISigner } from '@/types'
import {
KIND_NRC_REQUEST,
KIND_NRC_RESPONSE,
RequestMessage,
ResponseMessage,
ParsedConnectionURI,
EventManifestEntry
} from './nrc-types'
import { parseConnectionURI, deriveConversationKey } from './nrc-uri'
/**
* Generate a random subscription ID
*/
function generateSubId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(8))
return utils.bytesToHex(bytes)
}
/**
* Generate a random session ID
*/
function generateSessionId(): string {
return crypto.randomUUID()
}
/**
* Sync progress callback
*/
export interface SyncProgress {
phase: 'connecting' | 'requesting' | 'receiving' | 'sending' | 'complete' | 'error'
eventsReceived: number
eventsSent?: number
message?: string
}
/**
* Remote connection state
*/
export interface RemoteConnection {
id: string
uri: string
label: string
relayPubkey: string
rendezvousUrl: string
authMode: 'secret' | 'cat'
mintUrl?: string // For CAT mode
lastSync?: number
eventCount?: number
}
// Chunk buffer for reassembling large messages
interface ChunkBuffer {
chunks: Map<number, string>
total: number
receivedAt: number
}
// Default sync timeout: 60 seconds
const DEFAULT_SYNC_TIMEOUT = 60000
/**
* NRC Client for connecting to remote devices
*/
export class NRCClient {
private uri: ParsedConnectionURI
private ws: WebSocket | null = null
private sessionId: string
private connected = false
private subId: string | null = null
private pendingEvents: Event[] = []
private onProgress?: (progress: SyncProgress) => void
private resolveSync?: (events: Event[]) => void
private rejectSync?: (error: Error) => void
private chunkBuffers: Map<string, ChunkBuffer> = new Map()
private syncTimeout: ReturnType<typeof setTimeout> | null = null
private lastActivityTime: number = 0
// CAT mode fields
private signer?: ISigner
private catToken?: string
private clientPubkey?: string
constructor(connectionUri: string, signer?: ISigner, catToken?: string) {
this.uri = parseConnectionURI(connectionUri)
this.sessionId = generateSessionId()
this.signer = signer
this.catToken = catToken
}
/**
* Get the relay pubkey this client connects to
*/
getRelayPubkey(): string {
return this.uri.relayPubkey
}
/**
* Get the rendezvous URL
*/
getRendezvousUrl(): string {
return this.uri.rendezvousUrl
}
/**
* Connect to the rendezvous relay and sync events
*/
async sync(
filters: Filter[],
onProgress?: (progress: SyncProgress) => void,
timeout: number = DEFAULT_SYNC_TIMEOUT
): Promise<Event[]> {
this.onProgress = onProgress
this.pendingEvents = []
this.chunkBuffers.clear()
this.lastActivityTime = Date.now()
// For CAT mode, get our pubkey from the signer
if (this.uri.authMode === 'cat' && this.signer) {
this.clientPubkey = await this.signer.getPublicKey()
console.log(`[NRC Client] CAT mode, our pubkey: ${this.clientPubkey?.slice(0, 8)}...`)
}
return new Promise<Event[]>((resolve, reject) => {
this.resolveSync = resolve
this.rejectSync = reject
// Set up sync timeout
this.syncTimeout = setTimeout(() => {
const timeSinceActivity = Date.now() - this.lastActivityTime
if (timeSinceActivity > 30000) {
// No activity for 30s, likely stalled
console.error('[NRC Client] Sync timeout - no activity for 30s')
this.disconnect()
reject(new Error('Sync timeout - connection stalled'))
} else {
// Still receiving data, extend timeout
console.log('[NRC Client] Sync still active, extending timeout')
this.syncTimeout = setTimeout(() => {
this.disconnect()
reject(new Error('Sync timeout'))
}, timeout)
}
}, timeout)
this.connect()
.then(() => {
this.sendREQ(filters)
})
.catch((err) => {
this.clearSyncTimeout()
reject(err)
})
})
}
// State for IDS request
private idsMode = false
private resolveIDs?: (manifest: EventManifestEntry[]) => void
private rejectIDs?: (error: Error) => void
// State for sending events
private sendingEvents = false
private eventsSentCount = 0
private eventsToSend: Event[] = []
private resolveSend?: (count: number) => void
/**
* Request event IDs from remote (for diffing)
*/
async requestIDs(
filters: Filter[],
onProgress?: (progress: SyncProgress) => void,
timeout: number = DEFAULT_SYNC_TIMEOUT
): Promise<EventManifestEntry[]> {
this.onProgress = onProgress
this.chunkBuffers.clear()
this.lastActivityTime = Date.now()
this.idsMode = true
// For CAT mode, get our pubkey from the signer
if (this.uri.authMode === 'cat' && this.signer) {
this.clientPubkey = await this.signer.getPublicKey()
}
return new Promise<EventManifestEntry[]>((resolve, reject) => {
this.resolveIDs = resolve
this.rejectIDs = reject
this.syncTimeout = setTimeout(() => {
this.disconnect()
reject(new Error('IDS request timeout'))
}, timeout)
this.connect()
.then(() => {
this.sendIDSRequest(filters)
})
.catch((err) => {
this.clearSyncTimeout()
reject(err)
})
})
}
/**
* Send IDS request
*/
private sendIDSRequest(filters: Filter[]): void {
if (!this.ws || !this.connected) {
this.rejectIDs?.(new Error('Not connected'))
return
}
this.onProgress?.({
phase: 'requesting',
eventsReceived: 0,
message: 'Requesting event IDs...'
})
this.subId = generateSubId()
const request: RequestMessage = {
type: 'IDS',
payload: ['IDS', this.subId, ...filters]
}
this.sendEncryptedRequest(request).catch((err) => {
console.error('[NRC Client] Failed to send IDS:', err)
this.rejectIDs?.(err)
})
}
/**
* Send events to remote device
*/
async sendEvents(
events: Event[],
onProgress?: (progress: SyncProgress) => void,
timeout: number = DEFAULT_SYNC_TIMEOUT
): Promise<number> {
if (events.length === 0) return 0
this.onProgress = onProgress
this.chunkBuffers.clear()
this.lastActivityTime = Date.now()
this.sendingEvents = true
this.eventsSentCount = 0
this.eventsToSend = [...events]
// For CAT mode, get our pubkey from the signer
if (this.uri.authMode === 'cat' && this.signer) {
this.clientPubkey = await this.signer.getPublicKey()
}
return new Promise<number>((resolve, reject) => {
this.resolveSend = resolve
this.syncTimeout = setTimeout(() => {
this.disconnect()
reject(new Error('Send events timeout'))
}, timeout)
this.connect()
.then(() => {
this.sendNextEvent()
})
.catch((err) => {
this.clearSyncTimeout()
reject(err)
})
})
}
/**
* Send the next event in the queue
*/
private sendNextEvent(): void {
if (this.eventsToSend.length === 0) {
// All done
this.clearSyncTimeout()
this.onProgress?.({
phase: 'complete',
eventsReceived: 0,
eventsSent: this.eventsSentCount,
message: `Sent ${this.eventsSentCount} events`
})
this.resolveSend?.(this.eventsSentCount)
this.disconnect()
return
}
const event = this.eventsToSend.shift()!
this.onProgress?.({
phase: 'sending',
eventsReceived: 0,
eventsSent: this.eventsSentCount,
message: `Sending event ${this.eventsSentCount + 1}...`
})
const request: RequestMessage = {
type: 'EVENT',
payload: ['EVENT', event]
}
this.sendEncryptedRequest(request).catch((err) => {
console.error('[NRC Client] Failed to send EVENT:', err)
// Continue with next event even if this one failed
this.sendNextEvent()
})
}
/**
* Clear the sync timeout
*/
private clearSyncTimeout(): void {
if (this.syncTimeout) {
clearTimeout(this.syncTimeout)
this.syncTimeout = null
}
}
/**
* Update last activity time (called when receiving data)
*/
private updateActivity(): void {
this.lastActivityTime = Date.now()
}
/**
* Connect to the rendezvous relay
*/
private async connect(): Promise<void> {
if (this.connected) return
this.onProgress?.({
phase: 'connecting',
eventsReceived: 0,
message: 'Connecting to rendezvous relay...'
})
const relayUrl = this.uri.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 Client] Connecting to: ${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
// Subscribe to responses for our client pubkey
const responseSubId = generateSubId()
// Use CAT-mode pubkey if available, otherwise use secret-derived pubkey
const clientPubkey = this.clientPubkey || this.uri.clientPubkey
if (!clientPubkey) {
reject(new Error('Client pubkey not available'))
return
}
ws.send(
JSON.stringify([
'REQ',
responseSubId,
{
kinds: [KIND_NRC_RESPONSE],
'#p': [clientPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
])
)
console.log(`[NRC Client] Connected, subscribed for responses to ${clientPubkey.slice(0, 8)}...`)
resolve()
}
ws.onerror = (error) => {
clearTimeout(timeout)
console.error('[NRC Client] WebSocket error:', error)
reject(new Error('WebSocket error'))
}
ws.onclose = () => {
this.connected = false
this.ws = null
console.log('[NRC Client] WebSocket closed')
}
ws.onmessage = (event) => {
this.handleMessage(event.data)
}
})
}
/**
* Send a REQ message to the remote listener
*/
private sendREQ(filters: Filter[]): void {
if (!this.ws || !this.connected) {
this.rejectSync?.(new Error('Not connected'))
return
}
console.log(`[NRC Client] Sending REQ to listener pubkey: ${this.uri.relayPubkey?.slice(0, 8)}...`)
console.log(`[NRC Client] Our client pubkey: ${this.uri.clientPubkey?.slice(0, 8)}...`)
console.log(`[NRC Client] Filters:`, JSON.stringify(filters))
this.onProgress?.({
phase: 'requesting',
eventsReceived: 0,
message: 'Requesting events...'
})
this.subId = generateSubId()
const request: RequestMessage = {
type: 'REQ',
payload: ['REQ', this.subId, ...filters]
}
this.sendEncryptedRequest(request).catch((err) => {
console.error('[NRC Client] Failed to send request:', err)
this.rejectSync?.(err)
})
}
/**
* Send an encrypted request to the remote listener
*/
private async sendEncryptedRequest(request: RequestMessage): Promise<void> {
if (!this.ws) {
throw new Error('Not connected')
}
const plaintext = JSON.stringify(request)
let encrypted: string
let signedEvent: Event
if (this.uri.authMode === 'cat' && this.signer && this.clientPubkey) {
// CAT mode: use signer for encryption and signing
if (!this.signer.nip44Encrypt) {
throw new Error('Signer does not support NIP-44 encryption')
}
encrypted = await this.signer.nip44Encrypt(this.uri.relayPubkey, plaintext)
// Build the request event with CAT token
const tags: string[][] = [
['p', this.uri.relayPubkey],
['encryption', 'nip44_v2'],
['session', this.sessionId]
]
// Add CAT token if available
if (this.catToken) {
tags.push(['cashu', this.catToken])
}
const unsignedEvent = {
kind: KIND_NRC_REQUEST,
content: encrypted,
tags,
created_at: Math.floor(Date.now() / 1000)
}
signedEvent = await this.signer.signEvent(unsignedEvent)
console.log(`[NRC Client] CAT mode: Sent encrypted REQ with CAT token`)
} else {
// Secret mode: use derived keys
if (!this.uri.clientPrivkey || !this.uri.clientPubkey) {
throw new Error('Missing keys for secret mode')
}
// Derive conversation key
const conversationKey = deriveConversationKey(
this.uri.clientPrivkey,
this.uri.relayPubkey
)
encrypted = nip44.v2.encrypt(plaintext, conversationKey)
// Build the request event
const unsignedEvent = {
kind: KIND_NRC_REQUEST,
content: encrypted,
tags: [
['p', this.uri.relayPubkey],
['encryption', 'nip44_v2'],
['session', this.sessionId]
],
created_at: Math.floor(Date.now() / 1000),
pubkey: this.uri.clientPubkey
}
signedEvent = finalizeEvent(unsignedEvent, this.uri.clientPrivkey)
}
// Send to rendezvous relay
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
console.log(`[NRC Client] Sent encrypted REQ, event id: ${signedEvent.id?.slice(0, 8)}..., p-tag: ${this.uri.relayPubkey?.slice(0, 8)}...`)
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(data: string): void {
try {
const msg = JSON.parse(data)
if (!Array.isArray(msg)) return
const [type, ...rest] = msg
if (type === 'EVENT') {
const [subId, event] = rest as [string, Event]
console.log(`[NRC Client] Received EVENT on sub ${subId}, kind ${event.kind}, from ${event.pubkey?.slice(0, 8)}...`)
if (event.kind === KIND_NRC_RESPONSE) {
// Check p-tag to see who it's addressed to
const pTag = event.tags.find(t => t[0] === 'p')?.[1]
console.log(`[NRC Client] Response p-tag: ${pTag?.slice(0, 8)}..., our pubkey: ${this.uri.clientPubkey?.slice(0, 8)}...`)
this.handleResponse(event)
} else {
console.log(`[NRC Client] Ignoring event kind ${event.kind}`)
}
} else if (type === 'EOSE') {
console.log('[NRC Client] Received EOSE from relay subscription')
} else if (type === 'OK') {
console.log('[NRC Client] Event published:', rest)
} else if (type === 'NOTICE') {
console.log('[NRC Client] Relay notice:', rest[0])
}
} catch (err) {
console.error('[NRC Client] Failed to parse message:', err)
}
}
/**
* Handle a response event from the remote listener
*/
private handleResponse(event: Event): void {
console.log(`[NRC Client] Attempting to decrypt response from ${event.pubkey?.slice(0, 8)}...`)
this.decryptAndProcessResponse(event).catch((err) => {
console.error('[NRC Client] Failed to handle response:', err)
})
}
/**
* Decrypt and process a response event
*/
private async decryptAndProcessResponse(event: Event): Promise<void> {
let plaintext: string
if (this.uri.authMode === 'cat' && this.signer) {
// CAT mode: use signer for decryption
if (!this.signer.nip44Decrypt) {
throw new Error('Signer does not support NIP-44 decryption')
}
plaintext = await this.signer.nip44Decrypt(event.pubkey, event.content)
} else {
// Secret mode: use derived key
if (!this.uri.clientPrivkey) {
throw new Error('Missing private key for decryption')
}
const conversationKey = deriveConversationKey(
this.uri.clientPrivkey,
this.uri.relayPubkey
)
plaintext = nip44.v2.decrypt(event.content, conversationKey)
}
const response: ResponseMessage = JSON.parse(plaintext)
console.log(`[NRC Client] Received response: ${response.type}`)
// Handle chunked messages
if (response.type === 'CHUNK') {
this.handleChunk(response)
return
}
this.processResponse(response)
}
/**
* Handle a chunk message and reassemble when complete
*/
private handleChunk(response: ResponseMessage): void {
const chunk = response.payload[0] as {
type: 'CHUNK'
messageId: string
index: number
total: number
data: string
}
if (!chunk || chunk.type !== 'CHUNK') {
console.error('[NRC Client] Invalid chunk message')
return
}
const { messageId, index, total, data } = chunk
// Get or create buffer for this message
let buffer = this.chunkBuffers.get(messageId)
if (!buffer) {
buffer = {
chunks: new Map(),
total,
receivedAt: Date.now()
}
this.chunkBuffers.set(messageId, buffer)
}
// Store the chunk
buffer.chunks.set(index, data)
this.updateActivity()
console.log(`[NRC Client] Received chunk ${index + 1}/${total} for message ${messageId.slice(0, 8)}`)
// Check if we have all chunks
if (buffer.chunks.size === buffer.total) {
// Reassemble the message
const parts: string[] = []
for (let i = 0; i < buffer.total; i++) {
const part = buffer.chunks.get(i)
if (!part) {
console.error(`[NRC Client] Missing chunk ${i} for message ${messageId}`)
this.chunkBuffers.delete(messageId)
return
}
parts.push(part)
}
// Decode from base64
const encoded = parts.join('')
try {
const plaintext = decodeURIComponent(escape(atob(encoded)))
const reassembled: ResponseMessage = JSON.parse(plaintext)
console.log(`[NRC Client] Reassembled chunked message: ${reassembled.type}`)
this.processResponse(reassembled)
} catch (err) {
console.error('[NRC Client] Failed to reassemble chunked message:', err)
}
// Clean up buffer
this.chunkBuffers.delete(messageId)
}
// Clean up old buffers (older than 60 seconds)
const now = Date.now()
for (const [id, buf] of this.chunkBuffers) {
if (now - buf.receivedAt > 60000) {
console.warn(`[NRC Client] Discarding stale chunk buffer: ${id}`)
this.chunkBuffers.delete(id)
}
}
}
/**
* Process a complete response message
*/
private processResponse(response: ResponseMessage): void {
this.updateActivity()
switch (response.type) {
case 'EVENT': {
// Extract the event from payload: ["EVENT", subId, eventObject]
const [, , syncedEvent] = response.payload as [string, string, Event]
if (syncedEvent) {
this.pendingEvents.push(syncedEvent)
this.onProgress?.({
phase: 'receiving',
eventsReceived: this.pendingEvents.length,
message: `Received ${this.pendingEvents.length} events...`
})
}
break
}
case 'EOSE': {
console.log(`[NRC Client] EOSE received, got ${this.pendingEvents.length} events`)
this.complete()
break
}
case 'NOTICE': {
const [, message] = response.payload as [string, string]
console.log(`[NRC Client] Notice: ${message}`)
this.onProgress?.({
phase: 'error',
eventsReceived: this.pendingEvents.length,
message: message
})
break
}
case 'OK': {
// Response to EVENT publish
if (this.sendingEvents) {
const [, eventId, success, message] = response.payload as [string, string, boolean, string]
if (success) {
this.eventsSentCount++
console.log(`[NRC Client] Event ${eventId?.slice(0, 8)} stored successfully`)
} else {
console.warn(`[NRC Client] Event ${eventId?.slice(0, 8)} failed: ${message}`)
}
// Send next event
this.sendNextEvent()
}
break
}
case 'IDS': {
// Response to IDS request - contains event manifest
if (this.idsMode) {
const [, , manifest] = response.payload as [string, string, EventManifestEntry[]]
console.log(`[NRC Client] Received IDS response with ${manifest?.length || 0} entries`)
this.clearSyncTimeout()
this.resolveIDs?.(manifest || [])
this.disconnect()
}
break
}
default:
console.log(`[NRC Client] Unknown response type: ${response.type}`)
}
}
/**
* Complete the sync operation
*/
private complete(): void {
this.clearSyncTimeout()
this.onProgress?.({
phase: 'complete',
eventsReceived: this.pendingEvents.length,
message: `Synced ${this.pendingEvents.length} events`
})
this.resolveSync?.(this.pendingEvents)
this.disconnect()
}
/**
* Disconnect from the rendezvous relay
*/
disconnect(): void {
this.clearSyncTimeout()
if (this.ws) {
this.ws.close()
this.ws = null
}
this.connected = false
}
}
/**
* Sync events from a remote device
*
* @param connectionUri - The nostr+relayconnect:// URI
* @param filters - Nostr filters for events to sync
* @param onProgress - Optional progress callback
* @param signer - Optional signer for CAT mode
* @param catToken - Optional CAT token for CAT mode
* @returns Array of synced events
*/
export async function syncFromRemote(
connectionUri: string,
filters: Filter[],
onProgress?: (progress: SyncProgress) => void,
signer?: ISigner,
catToken?: string
): Promise<Event[]> {
const client = new NRCClient(connectionUri, signer, catToken)
return client.sync(filters, onProgress)
}
/**
* Test connection to a remote device
* Performs a minimal sync (kind 0 with limit 1) to verify the connection works
*
* @param connectionUri - The nostr+relayconnect:// URI
* @param onProgress - Optional progress callback
* @param signer - Optional signer for CAT mode
* @param catToken - Optional CAT token for CAT mode
* @returns true if connection successful
*/
export async function testConnection(
connectionUri: string,
onProgress?: (progress: SyncProgress) => void,
signer?: ISigner,
catToken?: string
): Promise<boolean> {
const client = new NRCClient(connectionUri, signer, catToken)
try {
// Request just one profile event to test the full round-trip
const events = await client.sync(
[{ kinds: [0], limit: 1 }],
onProgress,
15000 // 15 second timeout for test
)
console.log(`[NRC] Test connection successful, received ${events.length} events`)
return true
} catch (err) {
console.error('[NRC] Test connection failed:', err)
throw err
}
}
/**
* Request event IDs from a remote device (for diffing)
*
* @param connectionUri - The nostr+relayconnect:// URI
* @param filters - Filters to match events
* @param onProgress - Optional progress callback
* @param signer - Optional signer for CAT mode
* @param catToken - Optional CAT token for CAT mode
* @returns Array of event manifest entries (id, kind, created_at, d)
*/
export async function requestRemoteIDs(
connectionUri: string,
filters: Filter[],
onProgress?: (progress: SyncProgress) => void,
signer?: ISigner,
catToken?: string
): Promise<EventManifestEntry[]> {
const client = new NRCClient(connectionUri, signer, catToken)
return client.requestIDs(filters, onProgress)
}
/**
* Send events to a remote device
*
* @param connectionUri - The nostr+relayconnect:// URI
* @param events - Events to send
* @param onProgress - Optional progress callback
* @param signer - Optional signer for CAT mode
* @param catToken - Optional CAT token for CAT mode
* @returns Number of events successfully stored
*/
export async function sendEventsToRemote(
connectionUri: string,
events: Event[],
onProgress?: (progress: SyncProgress) => void,
signer?: ISigner,
catToken?: string
): Promise<number> {
const client = new NRCClient(connectionUri, signer, catToken)
return client.sendEvents(events, onProgress)
}

View File

@@ -12,9 +12,9 @@
*/
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 cashuTokenService, { decodeToken, TCashuToken } from '@/services/cashu-token.service'
import {
KIND_NRC_REQUEST,
KIND_NRC_RESPONSE,
@@ -23,10 +23,10 @@ import {
ResponseMessage,
AuthResult,
NRCSession,
isDeviceSpecificEvent
isDeviceSpecificEvent,
EventManifestEntry
} from './nrc-types'
import { NRCSessionManager } from './nrc-session'
import { deriveConversationKey } from './nrc-uri'
/**
* Generate a random subscription ID
@@ -189,7 +189,7 @@ export class NRCListenerService {
])
)
console.log(`[NRC] Connected and subscribed with subId: ${this.subId}`)
console.log(`[NRC] Connected and subscribed with subId: ${this.subId}, listening for pubkey: ${this.listenerPubkey}`)
resolve()
}
@@ -252,6 +252,7 @@ export class NRCListenerService {
if (type === 'EVENT') {
const [, event] = rest as [string, Event]
if (event.kind === KIND_NRC_REQUEST) {
console.log('[NRC] Received NRC request from pubkey:', event.pubkey)
this.handleRequest(event).catch((err) => {
console.error('[NRC] Error handling request:', err)
})
@@ -289,7 +290,7 @@ export class NRCListenerService {
// Get or create session
const session = this.sessions.getOrCreateSession(
event.pubkey,
authResult.conversationKey,
undefined, // We use signer's nip44 methods instead of conversationKey
authResult.mode,
authResult.deviceName
)
@@ -297,9 +298,10 @@ export class NRCListenerService {
// Notify session change
this.onSessionChange?.(this.sessions.getActiveSessionCount())
// Decrypt the content
const plaintext = nip44.v2.decrypt(event.content, authResult.conversationKey)
// Decrypt the content using signer
const plaintext = await this.decrypt(event.pubkey, event.content)
const request: RequestMessage = JSON.parse(plaintext)
console.log('[NRC] Received request:', request.type)
// Handle the request based on type
switch (request.type) {
@@ -310,8 +312,11 @@ export class NRCListenerService {
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')
await this.handleEVENT(event, session, request.payload)
break
case 'IDS':
// Return just event IDs matching filters (for diffing)
await this.handleIDS(event, session, request.payload)
break
case 'COUNT':
// Not implemented
@@ -341,53 +346,125 @@ export class NRCListenerService {
// 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'
if (cashuTag && cashuTag[1] && this.config.catConfig) {
const catResult = await this.verifyCATToken(cashuTag[1], event.pubkey)
if (catResult) {
return catResult
}
// CAT verification failed, fall through to check other auth methods
console.log('[NRC] CAT verification failed, checking other auth methods')
}
// Secret-based auth: check if pubkey is authorized
const deviceName = this.config.authorizedSecrets.get(event.pubkey)
if (!deviceName) {
console.log('[NRC] Unauthorized pubkey:', event.pubkey)
console.log('[NRC] Authorized pubkeys:', Array.from(this.config.authorizedSecrets.keys()))
console.log('[NRC] Authorized pubkeys (full):', JSON.stringify(Array.from(this.config.authorizedSecrets.entries())))
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
* Verify a CAT (Cashu Access Token) for NRC authentication
*/
private async getPrivateKey(): Promise<Uint8Array> {
private async verifyCATToken(encodedToken: string, clientPubkey: string): Promise<AuthResult | null> {
if (!this.config?.catConfig) {
return null
}
try {
// Decode the token
const token: TCashuToken = decodeToken(encodedToken)
console.log('[NRC] Verifying CAT token, scope:', token.scope, 'expiry:', new Date(token.expiry * 1000))
// Check expiry
const now = Math.floor(Date.now() / 1000)
if (token.expiry < now) {
console.log('[NRC] CAT token expired')
return null
}
// Check scope - must be 'nrc' or 'relay' for NRC auth
if (token.scope !== 'nrc' && token.scope !== 'relay') {
console.log('[NRC] CAT token has wrong scope:', token.scope)
return null
}
// Verify the token pubkey matches the event pubkey
const tokenPubkeyHex = utils.bytesToHex(token.pubkey)
if (tokenPubkeyHex !== clientPubkey) {
console.log('[NRC] CAT token pubkey mismatch:', tokenPubkeyHex, '!=', clientPubkey)
return null
}
// Initialize the cashu service with the mint URL if not already done
cashuTokenService.setMint(this.config.catConfig.mintUrl)
// Verify token signature with mint
// Note: This requires the mint info to be fetched first
try {
await cashuTokenService.fetchMintInfo()
} catch (err) {
console.warn('[NRC] Could not fetch mint info for CAT verification:', err)
// Continue anyway - we've done basic validation
}
if (!cashuTokenService.verifyToken(token)) {
console.log('[NRC] CAT token signature verification failed')
return null
}
console.log('[NRC] CAT token verified successfully')
return {
mode: 'cat',
deviceName: `cat:${clientPubkey.slice(0, 8)}`
}
} catch (err) {
console.error('[NRC] CAT token verification error:', err)
return null
}
}
/**
* Decrypt content using the signer's NIP-44 implementation
*/
private async decrypt(clientPubkey: string, ciphertext: string): Promise<string> {
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
if (!this.config.signer.nip44Decrypt) {
throw new Error('Signer does not support NIP-44 decryption')
}
throw new Error('Signer does not expose private key - NRC requires direct key access')
return this.config.signer.nip44Decrypt(clientPubkey, ciphertext)
}
/**
* Encrypt content using the signer's NIP-44 implementation
*/
private async encrypt(clientPubkey: string, plaintext: string): Promise<string> {
if (!this.config) {
throw new Error('Listener not configured')
}
if (!this.config.signer.nip44Encrypt) {
throw new Error('Signer does not support NIP-44 encryption')
}
return this.config.signer.nip44Encrypt(clientPubkey, plaintext)
}
// Max chunk size (accounting for encryption overhead and event wrapper)
// NIP-44 adds ~100 bytes overhead, plus base64 encoding increases size by ~33%
private static readonly MAX_CHUNK_SIZE = 40000 // ~40KB chunks to stay safely under 65KB limit
/**
* Handle REQ message - query local storage and respond
*/
@@ -413,6 +490,7 @@ export class NRCListenerService {
// Query local events matching the filters
const events = await this.queryLocalEvents(filterObjs)
console.log(`[NRC] Found ${events.length} events matching filters`)
// Send each matching event
for (const evt of events) {
@@ -420,8 +498,13 @@ export class NRCListenerService {
type: 'EVENT',
payload: ['EVENT', subId, evt]
}
await this.sendResponse(reqEvent, session, response)
this.sessions.incrementEventCount(session.id, subId)
try {
await this.sendResponseChunked(reqEvent, session, response)
this.sessions.incrementEventCount(session.id, subId)
} catch (err) {
console.error(`[NRC] Failed to send event ${evt.id?.slice(0, 8)}:`, err)
}
}
// Send EOSE
@@ -431,6 +514,7 @@ export class NRCListenerService {
}
await this.sendResponse(reqEvent, session, eoseResponse)
this.sessions.markEOSE(session.id, subId)
console.log(`[NRC] Sent EOSE for subscription ${subId}`)
}
/**
@@ -444,6 +528,81 @@ export class NRCListenerService {
}
}
/**
* Handle EVENT message - store an event from the remote device
*/
private async handleEVENT(
reqEvent: Event,
session: NRCSession,
payload: unknown[]
): Promise<void> {
// Parse EVENT: ["EVENT", eventObject]
const [, eventToStore] = payload as [string, Event]
if (!eventToStore || !eventToStore.id || !eventToStore.sig) {
await this.sendError(reqEvent, session, 'Invalid EVENT: missing event data')
return
}
try {
// Store the event in IndexedDB
await indexedDb.putReplaceableEvent(eventToStore)
console.log(`[NRC] Stored event ${eventToStore.id.slice(0, 8)} kind ${eventToStore.kind} from ${session.deviceName}`)
// Send OK response
const response: ResponseMessage = {
type: 'OK',
payload: ['OK', eventToStore.id, true, '']
}
await this.sendResponse(reqEvent, session, response)
} catch (err) {
console.error('[NRC] Failed to store event:', err)
const response: ResponseMessage = {
type: 'OK',
payload: ['OK', eventToStore.id, false, `Failed to store: ${err instanceof Error ? err.message : 'Unknown error'}`]
}
await this.sendResponse(reqEvent, session, response)
}
}
/**
* Handle IDS message - return event IDs matching filters (for diffing)
* Similar to REQ but returns only IDs, not full events
*/
private async handleIDS(
reqEvent: Event,
session: NRCSession,
payload: unknown[]
): Promise<void> {
// Parse IDS: ["IDS", subId, filter1, filter2, ...]
if (payload.length < 2) {
await this.sendError(reqEvent, session, 'Invalid IDS: missing subscription ID or filters')
return
}
const [, subId, ...filterObjs] = payload as [string, string, ...Filter[]]
// Query local events matching the filters
const events = await this.queryLocalEvents(filterObjs)
console.log(`[NRC] Found ${events.length} events for IDS request`)
// Build manifest of event IDs with metadata for diffing
const manifest: EventManifestEntry[] = events.map((evt) => ({
kind: evt.kind,
id: evt.id,
created_at: evt.created_at,
d: evt.tags.find((t) => t[0] === 'd')?.[1]
}))
// Send IDS response with the manifest
const response: ResponseMessage = {
type: 'IDS',
payload: ['IDS', subId, manifest]
}
await this.sendResponseChunked(reqEvent, session, response)
console.log(`[NRC] Sent IDS response with ${manifest.length} entries`)
}
/**
* Query local IndexedDB for events matching filters
*/
@@ -467,9 +626,9 @@ export class NRCListenerService {
throw new Error('Not connected')
}
// Encrypt the response
// Encrypt the response using signer
const plaintext = JSON.stringify(response)
const encrypted = nip44.v2.encrypt(plaintext, session.conversationKey)
const encrypted = await this.encrypt(session.clientPubkey, plaintext)
// Build the response event
const unsignedEvent = {
@@ -491,6 +650,50 @@ export class NRCListenerService {
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
}
/**
* Send a response, chunking if necessary for large payloads
*/
private async sendResponseChunked(
reqEvent: Event,
session: NRCSession,
response: ResponseMessage
): Promise<void> {
const plaintext = JSON.stringify(response)
// If small enough, send directly
if (plaintext.length <= NRCListenerService.MAX_CHUNK_SIZE) {
await this.sendResponse(reqEvent, session, response)
return
}
// Need to chunk - convert to base64 for safe transmission
const encoded = btoa(unescape(encodeURIComponent(plaintext)))
const chunks: string[] = []
// Split into chunks
for (let i = 0; i < encoded.length; i += NRCListenerService.MAX_CHUNK_SIZE) {
chunks.push(encoded.slice(i, i + NRCListenerService.MAX_CHUNK_SIZE))
}
const messageId = crypto.randomUUID()
console.log(`[NRC] Chunking large message (${plaintext.length} bytes) into ${chunks.length} chunks`)
// Send each chunk
for (let i = 0; i < chunks.length; i++) {
const chunkResponse: ResponseMessage = {
type: 'CHUNK',
payload: [{
type: 'CHUNK',
messageId,
index: i,
total: chunks.length,
data: chunks[i]
}]
}
await this.sendResponse(reqEvent, session, chunkResponse)
}
}
/**
* Send an error response
*/
@@ -507,7 +710,7 @@ export class NRCListenerService {
}
/**
* Send error response with best-effort ECDH
* Send error response with best-effort encryption
*/
private async sendErrorBestEffort(reqEvent: Event, message: string): Promise<void> {
if (!this.ws || !this.config || !this.listenerPubkey) {
@@ -515,16 +718,13 @@ export class NRCListenerService {
}
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 encrypted = await this.encrypt(reqEvent.pubkey, plaintext)
const unsignedEvent = {
kind: KIND_NRC_RESPONSE,

View File

@@ -59,7 +59,7 @@ export class NRCSessionManager {
*/
getOrCreateSession(
clientPubkey: string,
conversationKey: Uint8Array,
conversationKey: Uint8Array | undefined,
authMode: AuthMode,
deviceName?: string
): NRCSession {

View File

@@ -12,7 +12,7 @@ export type AuthMode = 'secret' | 'cat'
export interface NRCSession {
id: string
clientPubkey: string
conversationKey: Uint8Array
conversationKey?: Uint8Array // Optional - only set when using direct key access
deviceName?: string
authMode: AuthMode
createdAt: number
@@ -30,15 +30,42 @@ export interface NRCSubscription {
// Message types (encrypted content)
export interface RequestMessage {
type: 'REQ' | 'CLOSE' | 'EVENT' | 'COUNT'
type: 'REQ' | 'CLOSE' | 'EVENT' | 'COUNT' | 'IDS'
payload: unknown[]
}
export interface ResponseMessage {
type: 'EVENT' | 'EOSE' | 'OK' | 'NOTICE' | 'CLOSED' | 'COUNT'
type: 'EVENT' | 'EOSE' | 'OK' | 'NOTICE' | 'CLOSED' | 'COUNT' | 'CHUNK' | 'IDS'
payload: unknown[]
}
// ===== Sync Types =====
/**
* Event manifest entry - describes an event we have
* Used by IDS request/response for diffing
*/
export interface EventManifestEntry {
kind: number
id: string
created_at: number
d?: string // For parameterized replaceable events (kinds 30000-39999)
}
// Chunked message for large payloads
export interface ChunkMessage {
type: 'CHUNK'
messageId: string // Unique ID for this chunked message
index: number // 0-based chunk index
total: number // Total number of chunks
data: string // Base64 encoded chunk data
}
// Helper to check if a message is a chunk
export function isChunkMessage(msg: ResponseMessage): msg is ResponseMessage & { payload: [ChunkMessage] } {
return msg.type === 'CHUNK'
}
// Connection management
export interface NRCConnection {
id: string
@@ -69,7 +96,7 @@ export interface NRCListenerConfig {
// Authorization result
export interface AuthResult {
mode: AuthMode
conversationKey: Uint8Array
conversationKey?: Uint8Array // Optional - only set when using direct key access
deviceName: string
}

View File

@@ -3,6 +3,31 @@ import { getPublicKey } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44'
import { ParsedConnectionURI, AuthMode } from './nrc-types'
/**
* Derive the Cashu mint URL from a relay URL.
* The mint is always at {relay-root}/cashu
*
* @param relayUrl - WebSocket relay URL (ws:// or wss://)
* @returns HTTP(S) mint URL
*/
export function deriveMintUrlFromRelay(relayUrl: string): string {
let mintUrl = relayUrl
// Convert WebSocket URL to HTTP URL
if (relayUrl.startsWith('ws://')) {
mintUrl = 'http://' + relayUrl.slice(5)
} else if (relayUrl.startsWith('wss://')) {
mintUrl = 'https://' + relayUrl.slice(6)
} else if (!relayUrl.startsWith('http://') && !relayUrl.startsWith('https://')) {
mintUrl = 'https://' + relayUrl
}
// Remove trailing slash and append /cashu
mintUrl = mintUrl.replace(/\/$/, '')
return mintUrl
}
/**
* Generate a random 32-byte secret as hex string
*/
@@ -69,20 +94,21 @@ export function generateConnectionURI(
/**
* Generate a CAT-based NRC connection URI
*
* The mint URL is derived automatically from the rendezvous relay URL
* (mint is always at {relay-root}/cashu)
*
* @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
rendezvousUrl: string
): string {
const params = new URLSearchParams()
params.set('relay', rendezvousUrl)
params.set('auth', 'cat')
params.set('mint', mintUrl)
// Note: mint URL is derived from relay URL, not stored in URI
return `nostr+relayconnect://${relayPubkey}?${params.toString()}`
}
@@ -135,11 +161,9 @@ export function parseConnectionURI(uri: string): ParsedConnectionURI {
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')
}
// CAT-based auth - mint URL is derived from relay URL
// (mint is always at {relay-root}/cashu)
const mintUrl = deriveMintUrlFromRelay(rendezvousUrl)
return {
relayPubkey,
@@ -187,3 +211,31 @@ export function isValidConnectionURI(uri: string): boolean {
return false
}
}
/**
* Check if a relay supports CAT (Cashu Access Tokens)
* by probing the /cashu/info endpoint
*
* @param relayUrl - WebSocket relay URL
* @returns true if the relay has a Cashu mint
*/
export async function relaySupportsCat(relayUrl: string): Promise<boolean> {
const mintUrl = deriveMintUrlFromRelay(relayUrl)
try {
const response = await fetch(`${mintUrl}/cashu/info`, {
method: 'GET',
headers: { Accept: 'application/json' }
})
if (!response.ok) {
return false
}
// Check if response is valid mint info
const info = await response.json()
return info && typeof info === 'object' && 'name' in info
} catch {
return false
}
}

View File

@@ -236,6 +236,7 @@ export type TSyncSettings = {
quickReactionEmoji?: string | TEmoji
noteListMode?: TNoteListMode
preferNip44?: boolean
nrcOnlyConfigSync?: boolean
}
// DM types