Add NRC (Nostr Relay Connect) protocol and web UI (v0.48.9)
Some checks failed
Go / build-and-release (push) Has been cancelled

- Implement NIP-NRC protocol for remote relay access through public relay tunnel
- Add NRC bridge service with NIP-44 encrypted message tunneling
- Add NRC client library for applications
- Add session management with subscription tracking and expiry
- Add URI parsing for nostr+relayconnect:// scheme with secret and CAT auth
- Add NRC API endpoints for connection management (create/list/delete/get-uri)
- Add RelayConnectView.svelte component for managing NRC connections in web UI
- Add NRC database storage for connection secrets and labels
- Add NRC CLI commands (generate, list, revoke)
- Add support for Cashu Access Tokens (CAT) in NRC URIs
- Add ScopeNRC constant for Cashu token scope
- Add wasm build infrastructure and stub files

Files modified:
- app/config/config.go: NRC configuration options
- app/handle-nrc.go: New API handlers for NRC connections
- app/main.go: NRC bridge startup integration
- app/server.go: Register NRC API routes
- app/web/src/App.svelte: Add Relay Connect tab
- app/web/src/RelayConnectView.svelte: New NRC management component
- app/web/src/api.js: NRC API client functions
- main.go: NRC CLI command handlers
- pkg/bunker/acl_adapter.go: Add NRC scope mapping
- pkg/cashu/token/token.go: Add ScopeNRC constant
- pkg/database/nrc.go: NRC connection storage
- pkg/protocol/nrc/: New NRC protocol implementation
- docs/NIP-NRC.md: NIP specification document

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
woikos
2026-01-07 03:40:12 +01:00
parent 0dac41e35e
commit d41c332d06
31 changed files with 5982 additions and 16 deletions

View File

@@ -0,0 +1,49 @@
//go:build js && wasm
// Package bufpool provides buffer pools for reducing GC pressure in hot paths.
// This is the WASM version which uses simple allocations since sync.Pool
// behavior differs in WASM environments.
package bufpool
import (
"bytes"
)
const (
// SmallBufferSize for index keys (8-64 bytes typical)
SmallBufferSize = 64
// MediumBufferSize for event encoding (300-1000 bytes typical)
MediumBufferSize = 1024
)
// GetSmall returns a small buffer (64 bytes).
// In WASM, we simply allocate new buffers as sync.Pool is less effective.
func GetSmall() *bytes.Buffer {
return bytes.NewBuffer(make([]byte, 0, SmallBufferSize))
}
// PutSmall is a no-op in WASM; the buffer will be garbage collected.
func PutSmall(buf *bytes.Buffer) {
// No-op in WASM
}
// GetMedium returns a medium buffer (1KB).
func GetMedium() *bytes.Buffer {
return bytes.NewBuffer(make([]byte, 0, MediumBufferSize))
}
// PutMedium is a no-op in WASM; the buffer will be garbage collected.
func PutMedium(buf *bytes.Buffer) {
// No-op in WASM
}
// CopyBytes copies the buffer contents to a new slice.
func CopyBytes(buf *bytes.Buffer) []byte {
if buf == nil || buf.Len() == 0 {
return nil
}
result := make([]byte, buf.Len())
copy(result, buf.Bytes())
return result
}

206
pkg/database/nrc.go Normal file
View File

@@ -0,0 +1,206 @@
//go:build !(js && wasm)
package database
import (
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/crypto/keys"
"git.mleku.dev/mleku/nostr/encoders/hex"
)
// Key prefixes for NRC data
const (
nrcConnectionPrefix = "nrc:conn:" // NRC connections by ID
)
// NRCConnection stores an NRC connection configuration in the database.
type NRCConnection struct {
ID string `json:"id"` // Unique identifier (hex of first 8 bytes of secret)
Label string `json:"label"` // Human-readable label (e.g., "Phone", "Laptop")
Secret []byte `json:"secret"` // 32-byte secret for client authentication
CreatedAt int64 `json:"created_at"` // Unix timestamp
LastUsed int64 `json:"last_used"` // Unix timestamp of last connection (0 if never)
UseCashu bool `json:"use_cashu"` // Whether to include CAT token in URI
}
// GetNRCConnection retrieves an NRC connection by ID.
func (d *D) GetNRCConnection(id string) (conn *NRCConnection, err error) {
key := []byte(nrcConnectionPrefix + id)
err = d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
conn = &NRCConnection{}
return json.Unmarshal(val, conn)
})
})
return
}
// SaveNRCConnection stores an NRC connection in the database.
func (d *D) SaveNRCConnection(conn *NRCConnection) error {
data, err := json.Marshal(conn)
if err != nil {
return fmt.Errorf("failed to marshal connection: %w", err)
}
key := []byte(nrcConnectionPrefix + conn.ID)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Set(key, data)
})
}
// DeleteNRCConnection removes an NRC connection from the database.
func (d *D) DeleteNRCConnection(id string) error {
key := []byte(nrcConnectionPrefix + id)
return d.DB.Update(func(txn *badger.Txn) error {
if err := txn.Delete(key); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
return nil
})
}
// GetAllNRCConnections returns all NRC connections.
func (d *D) GetAllNRCConnections() (conns []*NRCConnection, err error) {
prefix := []byte(nrcConnectionPrefix)
err = d.DB.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
it := txn.NewIterator(opts)
defer it.Close()
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
item := it.Item()
err := item.Value(func(val []byte) error {
conn := &NRCConnection{}
if err := json.Unmarshal(val, conn); err != nil {
return err
}
conns = append(conns, conn)
return nil
})
if err != nil {
return err
}
}
return nil
})
return
}
// CreateNRCConnection generates a new NRC connection with a random secret.
func (d *D) CreateNRCConnection(label string, useCashu bool) (*NRCConnection, error) {
// Generate random 32-byte secret
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("failed to generate random secret: %w", err)
}
// Use first 8 bytes of secret as ID (hex encoded = 16 chars)
id := string(hex.Enc(secret[:8]))
conn := &NRCConnection{
ID: id,
Label: label,
Secret: secret,
CreatedAt: time.Now().Unix(),
LastUsed: 0,
UseCashu: useCashu,
}
if err := d.SaveNRCConnection(conn); chk.E(err) {
return nil, err
}
log.I.F("created NRC connection: id=%s label=%s cashu=%v", id, label, useCashu)
return conn, nil
}
// GetNRCConnectionURI generates the full connection URI for a connection.
// relayPubkey is the relay's public key (32 bytes).
// rendezvousURL is the public relay URL.
// mintURL is the CAT mint URL (required if useCashu is true).
func (d *D) GetNRCConnectionURI(conn *NRCConnection, relayPubkey []byte, rendezvousURL, mintURL string) (string, error) {
if len(relayPubkey) != 32 {
return "", fmt.Errorf("invalid relay pubkey length: %d", len(relayPubkey))
}
if rendezvousURL == "" {
return "", fmt.Errorf("rendezvous URL is required")
}
relayPubkeyHex := hex.Enc(relayPubkey)
secretHex := hex.Enc(conn.Secret)
var uri string
if conn.UseCashu {
if mintURL == "" {
return "", fmt.Errorf("mint URL is required for CAT authentication")
}
// CAT-based URI includes both secret (for non-CAT relays) and CAT auth
uri = fmt.Sprintf("nostr+relayconnect://%s?relay=%s&secret=%s&auth=cat&mint=%s",
relayPubkeyHex, rendezvousURL, secretHex, mintURL)
} else {
// Secret-only URI
uri = fmt.Sprintf("nostr+relayconnect://%s?relay=%s&secret=%s",
relayPubkeyHex, rendezvousURL, secretHex)
}
if conn.Label != "" {
uri += fmt.Sprintf("&name=%s", conn.Label)
}
return uri, nil
}
// GetNRCAuthorizedSecrets returns a map of derived pubkeys to labels for all connections.
// This is used by the NRC bridge to authorize incoming connections.
func (d *D) GetNRCAuthorizedSecrets() (map[string]string, error) {
conns, err := d.GetAllNRCConnections()
if err != nil {
return nil, err
}
result := make(map[string]string)
for _, conn := range conns {
// Derive pubkey from secret
pubkey, err := keys.SecretBytesToPubKeyBytes(conn.Secret)
if err != nil {
log.W.F("failed to derive pubkey for NRC connection %s: %v", conn.ID, err)
continue
}
pubkeyHex := string(hex.Enc(pubkey))
result[pubkeyHex] = conn.Label
}
return result, nil
}
// UpdateNRCConnectionLastUsed updates the last used timestamp for a connection.
func (d *D) UpdateNRCConnectionLastUsed(id string) error {
conn, err := d.GetNRCConnection(id)
if err != nil {
return err
}
conn.LastUsed = time.Now().Unix()
return d.SaveNRCConnection(conn)
}

View File

@@ -0,0 +1,202 @@
//go:build js && wasm
package database
import (
"crypto/sha256"
"strings"
"unicode"
)
// TokenHashes extracts unique word hashes (8-byte truncated sha256) from content.
// Rules:
// - Unicode-aware: words are sequences of letters or numbers.
// - Lowercased using unicode case mapping.
// - Ignore URLs (starting with http://, https://, www., or containing "://").
// - Ignore nostr: URIs and #[n] mentions.
// - Ignore words shorter than 2 runes.
// - Exclude 64-character hexadecimal strings (likely IDs/pubkeys).
func TokenHashes(content []byte) [][]byte {
s := string(content)
var out [][]byte
seen := make(map[string]struct{})
i := 0
for i < len(s) {
r, size := rune(s[i]), 1
if r >= 0x80 {
r, size = utf8DecodeRuneInString(s[i:])
}
// Skip whitespace
if unicode.IsSpace(r) {
i += size
continue
}
// Skip URLs and schemes
if hasPrefixFold(s[i:], "http://") || hasPrefixFold(s[i:], "https://") || hasPrefixFold(s[i:], "nostr:") || hasPrefixFold(s[i:], "www.") {
i = skipUntilSpace(s, i)
continue
}
// If token contains "://" ahead, treat as URL and skip to space
if j := strings.Index(s[i:], "://"); j == 0 || (j > 0 && isWordStart(r)) {
// Only if it's at start of token
before := s[i : i+j]
if len(before) == 0 || allAlphaNum(before) {
i = skipUntilSpace(s, i)
continue
}
}
// Skip #[n] mentions
if r == '#' && i+size < len(s) && s[i+size] == '[' {
end := strings.IndexByte(s[i:], ']')
if end >= 0 {
i += end + 1
continue
}
}
// Collect a word
start := i
var runes []rune
for i < len(s) {
r2, size2 := rune(s[i]), 1
if r2 >= 0x80 {
r2, size2 = utf8DecodeRuneInString(s[i:])
}
if unicode.IsLetter(r2) || unicode.IsNumber(r2) {
// Normalize decorative unicode (small caps, fraktur) to ASCII
// before lowercasing for consistent indexing
runes = append(runes, unicode.ToLower(normalizeRune(r2)))
i += size2
continue
}
break
}
// If we didn't consume any rune for a word, advance by one rune to avoid stalling
if i == start {
_, size2 := utf8DecodeRuneInString(s[i:])
i += size2
continue
}
if len(runes) >= 2 {
w := string(runes)
// Exclude 64-char hex strings
if isHex64(w) {
continue
}
if _, ok := seen[w]; !ok {
seen[w] = struct{}{}
h := sha256.Sum256([]byte(w))
out = append(out, h[:8])
}
}
}
return out
}
func hasPrefixFold(s, prefix string) bool {
if len(s) < len(prefix) {
return false
}
for i := 0; i < len(prefix); i++ {
c := s[i]
p := prefix[i]
if c == p {
continue
}
// ASCII case-insensitive
if 'A' <= c && c <= 'Z' {
c = c - 'A' + 'a'
}
if 'A' <= p && p <= 'Z' {
p = p - 'A' + 'a'
}
if c != p {
return false
}
}
return true
}
func skipUntilSpace(s string, i int) int {
for i < len(s) {
r, size := rune(s[i]), 1
if r >= 0x80 {
r, size = utf8DecodeRuneInString(s[i:])
}
if unicode.IsSpace(r) {
return i
}
i += size
}
return i
}
func allAlphaNum(s string) bool {
for _, r := range s {
if !(unicode.IsLetter(r) || unicode.IsNumber(r)) {
return false
}
}
return true
}
func isWordStart(r rune) bool { return unicode.IsLetter(r) || unicode.IsNumber(r) }
// utf8DecodeRuneInString decodes the first UTF-8 rune from s.
// Returns the rune and the number of bytes consumed.
func utf8DecodeRuneInString(s string) (r rune, size int) {
if len(s) == 0 {
return 0, 0
}
// ASCII fast path
b := s[0]
if b < 0x80 {
return rune(b), 1
}
// Multi-byte: determine expected length from first byte
var expectedLen int
switch {
case b&0xE0 == 0xC0: // 110xxxxx - 2 bytes
expectedLen = 2
case b&0xF0 == 0xE0: // 1110xxxx - 3 bytes
expectedLen = 3
case b&0xF8 == 0xF0: // 11110xxx - 4 bytes
expectedLen = 4
default:
// Invalid UTF-8 start byte
return 0xFFFD, 1
}
if len(s) < expectedLen {
return 0xFFFD, 1
}
// Decode using Go's built-in rune conversion (simple and correct)
runes := []rune(s[:expectedLen])
if len(runes) == 0 {
return 0xFFFD, 1
}
return runes[0], expectedLen
}
// isHex64 returns true if s is exactly 64 hex characters (0-9, a-f)
func isHex64(s string) bool {
if len(s) != 64 {
return false
}
for i := 0; i < 64; i++ {
c := s[i]
if c >= '0' && c <= '9' {
continue
}
if c >= 'a' && c <= 'f' {
continue
}
if c >= 'A' && c <= 'F' {
continue
}
return false
}
return true
}

View File

@@ -0,0 +1,135 @@
//go:build js && wasm
package database
// normalizeRune maps decorative unicode characters (small caps, fraktur) back to
// their ASCII equivalents for consistent word indexing. This ensures that text
// written with decorative alphabets (e.g., "ᴅᴇᴀᴛʜ" or "𝔇𝔢𝔞𝔱𝔥") indexes the same
// as regular ASCII ("death").
//
// Character sets normalized:
// - Small Caps (used for DEATH-style text in Terry Pratchett tradition)
// - Mathematical Fraktur lowercase (𝔞-𝔷)
// - Mathematical Fraktur uppercase (𝔄-, including Letterlike Symbols block exceptions)
func normalizeRune(r rune) rune {
// Check small caps first (scattered codepoints)
if mapped, ok := smallCapsToASCII[r]; ok {
return mapped
}
// Check fraktur lowercase: U+1D51E to U+1D537 (contiguous range)
if r >= 0x1D51E && r <= 0x1D537 {
return 'a' + (r - 0x1D51E)
}
// Check fraktur uppercase main range: U+1D504 to U+1D51C (with gaps)
if r >= 0x1D504 && r <= 0x1D51C {
if mapped, ok := frakturUpperToASCII[r]; ok {
return mapped
}
}
// Check fraktur uppercase exceptions from Letterlike Symbols block
if mapped, ok := frakturLetterlikeToASCII[r]; ok {
return mapped
}
return r
}
// smallCapsToASCII maps small capital letters to lowercase ASCII.
// These are scattered across multiple Unicode blocks (IPA Extensions,
// Phonetic Extensions, Latin Extended-D).
var smallCapsToASCII = map[rune]rune{
'ᴀ': 'a', // U+1D00 LATIN LETTER SMALL CAPITAL A
'ʙ': 'b', // U+0299 LATIN LETTER SMALL CAPITAL B
'': 'c', // U+1D04 LATIN LETTER SMALL CAPITAL C
'ᴅ': 'd', // U+1D05 LATIN LETTER SMALL CAPITAL D
'ᴇ': 'e', // U+1D07 LATIN LETTER SMALL CAPITAL E
'ꜰ': 'f', // U+A730 LATIN LETTER SMALL CAPITAL F
'ɢ': 'g', // U+0262 LATIN LETTER SMALL CAPITAL G
'ʜ': 'h', // U+029C LATIN LETTER SMALL CAPITAL H
'ɪ': 'i', // U+026A LATIN LETTER SMALL CAPITAL I
'ᴊ': 'j', // U+1D0A LATIN LETTER SMALL CAPITAL J
'ᴋ': 'k', // U+1D0B LATIN LETTER SMALL CAPITAL K
'ʟ': 'l', // U+029F LATIN LETTER SMALL CAPITAL L
'ᴍ': 'm', // U+1D0D LATIN LETTER SMALL CAPITAL M
'ɴ': 'n', // U+0274 LATIN LETTER SMALL CAPITAL N
'': 'o', // U+1D0F LATIN LETTER SMALL CAPITAL O
'ᴘ': 'p', // U+1D18 LATIN LETTER SMALL CAPITAL P
'ǫ': 'q', // U+01EB LATIN SMALL LETTER O WITH OGONEK (no true small cap Q)
'ʀ': 'r', // U+0280 LATIN LETTER SMALL CAPITAL R
'': 's', // U+A731 LATIN LETTER SMALL CAPITAL S
'ᴛ': 't', // U+1D1B LATIN LETTER SMALL CAPITAL T
'': 'u', // U+1D1C LATIN LETTER SMALL CAPITAL U
'': 'v', // U+1D20 LATIN LETTER SMALL CAPITAL V
'': 'w', // U+1D21 LATIN LETTER SMALL CAPITAL W
// Note: no small cap X exists in standard use
'ʏ': 'y', // U+028F LATIN LETTER SMALL CAPITAL Y
'': 'z', // U+1D22 LATIN LETTER SMALL CAPITAL Z
}
// frakturUpperToASCII maps Mathematical Fraktur uppercase letters to lowercase ASCII.
// The main range U+1D504-U+1D51C has gaps where C, H, I, R, Z use Letterlike Symbols.
var frakturUpperToASCII = map[rune]rune{
'𝔄': 'a', // U+1D504 MATHEMATICAL FRAKTUR CAPITAL A
'𝔅': 'b', // U+1D505 MATHEMATICAL FRAKTUR CAPITAL B
// C is at U+212D (Letterlike Symbols)
'𝔇': 'd', // U+1D507 MATHEMATICAL FRAKTUR CAPITAL D
'𝔈': 'e', // U+1D508 MATHEMATICAL FRAKTUR CAPITAL E
'𝔉': 'f', // U+1D509 MATHEMATICAL FRAKTUR CAPITAL F
'𝔊': 'g', // U+1D50A MATHEMATICAL FRAKTUR CAPITAL G
// H is at U+210C (Letterlike Symbols)
// I is at U+2111 (Letterlike Symbols)
'𝔍': 'j', // U+1D50D MATHEMATICAL FRAKTUR CAPITAL J
'𝔎': 'k', // U+1D50E MATHEMATICAL FRAKTUR CAPITAL K
'𝔏': 'l', // U+1D50F MATHEMATICAL FRAKTUR CAPITAL L
'𝔐': 'm', // U+1D510 MATHEMATICAL FRAKTUR CAPITAL M
'𝔑': 'n', // U+1D511 MATHEMATICAL FRAKTUR CAPITAL N
'𝔒': 'o', // U+1D512 MATHEMATICAL FRAKTUR CAPITAL O
'𝔓': 'p', // U+1D513 MATHEMATICAL FRAKTUR CAPITAL P
'𝔔': 'q', // U+1D514 MATHEMATICAL FRAKTUR CAPITAL Q
// R is at U+211C (Letterlike Symbols)
'𝔖': 's', // U+1D516 MATHEMATICAL FRAKTUR CAPITAL S
'𝔗': 't', // U+1D517 MATHEMATICAL FRAKTUR CAPITAL T
'𝔘': 'u', // U+1D518 MATHEMATICAL FRAKTUR CAPITAL U
'𝔙': 'v', // U+1D519 MATHEMATICAL FRAKTUR CAPITAL V
'𝔚': 'w', // U+1D51A MATHEMATICAL FRAKTUR CAPITAL W
'𝔛': 'x', // U+1D51B MATHEMATICAL FRAKTUR CAPITAL X
'𝔜': 'y', // U+1D51C MATHEMATICAL FRAKTUR CAPITAL Y
// Z is at U+2128 (Letterlike Symbols)
}
// frakturLetterlikeToASCII maps the Fraktur characters that live in the
// Letterlike Symbols block (U+2100-U+214F) rather than Mathematical Alphanumeric Symbols.
var frakturLetterlikeToASCII = map[rune]rune{
'': 'c', // U+212D BLACK-LETTER CAPITAL C
'': 'h', // U+210C BLACK-LETTER CAPITAL H
'': 'i', // U+2111 BLACK-LETTER CAPITAL I
'': 'r', // U+211C BLACK-LETTER CAPITAL R
'': 'z', // U+2128 BLACK-LETTER CAPITAL Z
}
// hasDecorativeUnicode checks if text contains any small caps or fraktur characters
// that would need normalization. Used by migration to identify events needing re-indexing.
func hasDecorativeUnicode(s string) bool {
for _, r := range s {
// Check small caps
if _, ok := smallCapsToASCII[r]; ok {
return true
}
// Check fraktur lowercase range
if r >= 0x1D51E && r <= 0x1D537 {
return true
}
// Check fraktur uppercase range
if r >= 0x1D504 && r <= 0x1D51C {
return true
}
// Check letterlike symbols fraktur
if _, ok := frakturLetterlikeToASCII[r]; ok {
return true
}
}
return false
}