Browse Source

Refactor project to modularize constants and utilities.

Moved reusable constants and helper functions to dedicated modules for improved maintainability and reusability. Improved build configuration to differentiate output directories for development and production. Enhanced server error handling and added safeguards for disabled web UI scenarios.
main
mleku 1 month ago
parent
commit
8ef3114f5c
No known key found for this signature in database
  1. 3
      .claude/settings.local.json
  2. 373
      README.md
  3. 21
      app/server.go
  4. 3
      app/web/.gitignore
  5. 2
      app/web/package.json
  6. 10
      app/web/rollup.config.js
  7. 278
      app/web/src/App.svelte
  8. 12
      app/web/src/ComposeView.svelte
  9. 1
      app/web/src/EventsView.svelte
  10. 8
      app/web/src/ExportView.svelte
  11. 4
      app/web/src/ImportView.svelte
  12. 1
      app/web/src/PolicyView.svelte
  13. 1
      app/web/src/RecoveryView.svelte
  14. 34
      app/web/src/Sidebar.svelte
  15. 370
      app/web/src/api.js
  16. 190
      app/web/src/constants.js
  17. 88
      app/web/src/stores.js
  18. 119
      app/web/src/utils.js

3
.claude/settings.local.json

@ -13,7 +13,8 @@ @@ -13,7 +13,8 @@
"Bash(go build:*)",
"Bash(go test:*)",
"Bash(./scripts/test.sh:*)",
"Bash(./scripts/update-embedded-web.sh:*)"
"Bash(./scripts/update-embedded-web.sh:*)",
"Bash(bun run build:*)"
],
"deny": [],
"ask": []

373
readme.adoc → README.md

@ -1,63 +1,61 @@ @@ -1,63 +1,61 @@
go= next.orly.dev
:toc:
:note-caption: note 👉
# next.orly.dev
image:./docs/orly.png[orly.dev]
![orly.dev](./docs/orly.png)
image:https://img.shields.io/badge/version-v0.24.1-blue.svg[Version v0.24.1]
image:https://img.shields.io/badge/godoc-documentation-blue.svg[Documentation,link=https://pkg.go.dev/next.orly.dev]
image:https://img.shields.io/badge/donate-geyser_crowdfunding_project_page-orange.svg[Support this project,link=https://geyser.fund/project/orly]
zap me: ⚡mlekudev@getalby.com
follow me on link:https://jumble.social/users/npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku[nostr]
![Version v0.24.1](https://img.shields.io/badge/version-v0.24.1-blue.svg)
[![Documentation](https://img.shields.io/badge/godoc-documentation-blue.svg)](https://pkg.go.dev/next.orly.dev)
[![Support this project](https://img.shields.io/badge/donate-geyser_crowdfunding_project_page-orange.svg)](https://geyser.fund/project/orly)
== about
zap me: ¡mlekudev@getalby.com
ORLY is a nostr relay written from the ground up to be performant, low latency, and built with a number of features designed to make it well suited for
follow me on [nostr](https://jumble.social/users/npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku)
## About
ORLY is a nostr relay written from the ground up to be performant, low latency, and built with a number of features designed to make it well suited for:
- personal relays
- small community relays
- business deployments and RaaS (Relay as a Service) with a nostr-native NWC client to allow accepting payments through NWC capable lightning nodes
- high availability clusters for reliability and/or providing a unified data set across multiple regions
== performance & cryptography
## Performance & Cryptography
ORLY leverages high-performance libraries and custom optimizations for exceptional speed:
* **SIMD Libraries**: Uses link:https://github.com/minio/sha256-simd[minio/sha256-simd] for accelerated SHA256 hashing
* **p256k1 Cryptography**: Implements link:https://github.com/p256k1/p256k1[p256k1.mleku.dev] for fast elliptic curve operations optimized for nostr
* **Fast Message Encoders**: High-performance encoding/decoding with link:https://github.com/templexxx/xhex[templexxx/xhex] for SIMD-accelerated hex operations
- **SIMD Libraries**: Uses [minio/sha256-simd](https://github.com/minio/sha256-simd) for accelerated SHA256 hashing
- **p256k1 Cryptography**: Implements [p256k1.mleku.dev](https://github.com/p256k1/p256k1) for fast elliptic curve operations optimized for nostr
- **Fast Message Encoders**: High-performance encoding/decoding with [templexxx/xhex](https://github.com/templexxx/xhex) for SIMD-accelerated hex operations
The encoders achieve **24% faster JSON marshaling**, **16% faster canonical encoding**, and **54-91% reduction in memory allocations** through custom buffer pre-allocation and zero-allocation optimization techniques.
ORLY uses a fast embedded link:https://github.com/hypermodeinc/badger[badger] database with a database designed for high performance querying and event storage.
ORLY uses a fast embedded [badger](https://github.com/hypermodeinc/badger) database with a database designed for high performance querying and event storage.
== building
## Building
ORLY is a standard Go application that can be built using the Go toolchain.
=== prerequisites
### Prerequisites
- Go 1.25.3 or later
- Git
- For web UI: link:https://bun.sh/[Bun] JavaScript runtime
- For web UI: [Bun](https://bun.sh/) JavaScript runtime
=== basic build
### Basic Build
To build the relay binary only:
[source,bash]
----
```bash
git clone <repository-url>
cd next.orly.dev
go build -o orly
----
```
=== building with web UI
### Building with Web UI
To build with the embedded web interface:
[source,bash]
----
```bash
# Build the Svelte web application
cd app/web
bun install
@ -66,14 +64,13 @@ bun run build @@ -66,14 +64,13 @@ bun run build
# Build the Go binary from project root
cd ../../
go build -o orly
----
```
The recommended way to build and embed the web UI is using the provided script:
[source,bash]
----
```bash
./scripts/update-embedded-web.sh
----
```
This script will:
- Build the Svelte app in `app/web` to `app/web/dist` using Bun (preferred) or fall back to npm/yarn/pnpm
@ -82,8 +79,7 @@ This script will: @@ -82,8 +79,7 @@ This script will:
For manual builds, you can also use:
[source,bash]
----
```bash
#!/bin/bash
# build.sh
echo "Building Svelte app..."
@ -96,86 +92,139 @@ cd ../../ @@ -96,86 +92,139 @@ cd ../../
go build -o orly
echo "Build complete!"
----
```
Make it executable with `chmod +x build.sh` and run with `./build.sh`.
== core features
## Core Features
### Web UI
ORLY includes a modern web-based user interface built with [Svelte](https://svelte.dev/) for relay management and monitoring.
- **Secure Authentication**: Nostr key pair authentication with challenge-response
- **Event Management**: Browse, export, import, and search events
- **User Administration**: Role-based permissions (guest, user, admin, owner)
- **Sprocket Management**: Upload and monitor event processing scripts
- **Real-time Updates**: Live event streaming and system monitoring
- **Responsive Design**: Works on desktop and mobile devices
- **Dark/Light Themes**: Persistent theme preferences
The web UI is embedded in the relay binary and accessible at the relay's root path.
#### Web UI Development
For development with hot-reloading, ORLY can proxy web requests to a local dev server while still handling WebSocket relay connections and API requests.
**Environment Variables:**
- `ORLY_WEB_DISABLE` - Set to `true` to disable serving the embedded web UI
- `ORLY_WEB_DEV_PROXY_URL` - URL of the dev server to proxy web requests to (e.g., `localhost:8080`)
**Setup:**
1. Start the dev server (in one terminal):
```bash
cd app/web
bun install
bun run dev
```
Note the port sirv is listening on (e.g., `http://localhost:8080`).
2. Start the relay with dev proxy enabled (in another terminal):
```bash
export ORLY_WEB_DISABLE=true
export ORLY_WEB_DEV_PROXY_URL=localhost:8080
./orly
```
The relay will:
=== web UI
- Handle WebSocket connections at `/` for Nostr protocol
- Handle API requests at `/api/*`
- Proxy all other requests (HTML, JS, CSS, assets) to the dev server
ORLY includes a modern web-based user interface built with link:https://svelte.dev/[Svelte] for relay management and monitoring.
**With a reverse proxy/tunnel:**
* **Secure Authentication**: Nostr key pair authentication with challenge-response
* **Event Management**: Browse, export, import, and search events
* **User Administration**: Role-based permissions (guest, user, admin, owner)
* **Sprocket Management**: Upload and monitor event processing scripts
* **Real-time Updates**: Live event streaming and system monitoring
* **Responsive Design**: Works on desktop and mobile devices
* **Dark/Light Themes**: Persistent theme preferences
If you're running behind a reverse proxy or tunnel (e.g., Caddy, nginx, Cloudflare Tunnel), the setup is the same. The relay listens locally and your reverse proxy forwards traffic to it:
The web UI is embedded in the relay binary and accessible at the relay's root path. For development with hot-reloading:
```
Browser ’ Reverse Proxy ’ ORLY (port 3334) ’ Dev Server (port 8080)
WebSocket/API
```
[source,bash]
----
export ORLY_WEB_DISABLE_EMBEDDED=true
export ORLY_WEB_DEV_PROXY_URL=localhost:5000
./orly &
Example with the relay on port 3334 and sirv on port 8080:
```bash
# Terminal 1: Dev server
cd app/web && bun run dev
----
# Output: Your application is ready~!
# Local: http://localhost:8080
# Terminal 2: Relay
export ORLY_WEB_DISABLE=true
export ORLY_WEB_DEV_PROXY_URL=localhost:8080
export ORLY_PORT=3334
./orly
```
=== sprocket event processing
**Disabling the web UI without a proxy:**
If you only want to disable the embedded web UI (without proxying to a dev server), just set `ORLY_WEB_DISABLE=true` without setting `ORLY_WEB_DEV_PROXY_URL`. The relay will return 404 for web UI requests while still handling WebSocket and API requests.
### Sprocket Event Processing
ORLY includes a powerful sprocket system for external event processing scripts. Sprocket scripts enable custom filtering, validation, and processing logic for Nostr events before storage.
* **Real-time Processing**: Scripts receive events via stdin and respond with JSONL decisions
* **Three Actions**: `accept`, `reject`, or `shadowReject` events based on custom logic
* **Automatic Recovery**: Failed scripts are automatically disabled with periodic recovery attempts
* **Web UI Management**: Upload, configure, and monitor scripts through the admin interface
- **Real-time Processing**: Scripts receive events via stdin and respond with JSONL decisions
- **Three Actions**: `accept`, `reject`, or `shadowReject` events based on custom logic
- **Automatic Recovery**: Failed scripts are automatically disabled with periodic recovery attempts
- **Web UI Management**: Upload, configure, and monitor scripts through the admin interface
[source,bash]
----
```bash
export ORLY_SPROCKET_ENABLED=true
export ORLY_APP_NAME="ORLY"
# Place script at ~/.config/ORLY/sprocket.sh
----
```
For detailed configuration and examples, see the link:docs/sprocket/[sprocket documentation].
For detailed configuration and examples, see the [sprocket documentation](docs/sprocket/).
=== policy system
### Policy System
ORLY includes a comprehensive policy system for fine-grained control over event storage and retrieval. Configure custom validation rules, access controls, size limits, and age restrictions.
* **Access Control**: Allow/deny based on pubkeys, roles, or social relationships
* **Content Filtering**: Size limits, age validation, and custom rules
* **Script Integration**: Execute custom scripts for complex policy logic
* **Real-time Enforcement**: Policies applied to both read and write operations
- **Access Control**: Allow/deny based on pubkeys, roles, or social relationships
- **Content Filtering**: Size limits, age validation, and custom rules
- **Script Integration**: Execute custom scripts for complex policy logic
- **Real-time Enforcement**: Policies applied to both read and write operations
[source,bash]
----
```bash
export ORLY_POLICY_ENABLED=true
# Create policy file at ~/.config/ORLY/policy.json
----
```
For detailed configuration and examples, see the link:docs/POLICY_USAGE_GUIDE.md[Policy Usage Guide].
For detailed configuration and examples, see the [Policy Usage Guide](docs/POLICY_USAGE_GUIDE.md).
== deployment
## Deployment
ORLY includes an automated deployment script that handles Go installation, dependency setup, building, and systemd service configuration.
=== automated deployment
### Automated Deployment
The deployment script (`scripts/deploy.sh`) provides a complete setup solution:
[source,bash]
----
```bash
# Clone the repository
git clone <repository-url>
cd next.orly.dev
# Run the deployment script
./scripts/deploy.sh
----
```
The script will:
@ -188,17 +237,15 @@ The script will: @@ -188,17 +237,15 @@ The script will:
After deployment, reload your shell environment:
[source,bash]
----
```bash
source ~/.bashrc
----
```
=== TLS configuration
### TLS Configuration
ORLY supports automatic TLS certificate management with Let's Encrypt and custom certificates:
[source,bash]
----
```bash
# Enable TLS with Let's Encrypt for specific domains
export ORLY_TLS_DOMAINS=relay.example.com,backup.relay.example.com
@ -209,18 +256,17 @@ export ORLY_CERTS=/path/to/cert1,/path/to/cert2 @@ -209,18 +256,17 @@ export ORLY_CERTS=/path/to/cert1,/path/to/cert2
# - Listen on port 443 for HTTPS/WSS
# - Listen on port 80 for ACME challenges
# - Ignore ORLY_PORT setting
----
```
Certificate files should be named with `.pem` and `.key` extensions:
- `/path/to/cert1.pem` (certificate)
- `/path/to/cert1.key` (private key)
=== systemd service management
### systemd Service Management
The deployment script creates a systemd service for easy management:
[source,bash]
----
```bash
# Start the service
sudo systemctl start orly
@ -244,14 +290,13 @@ sudo journalctl -u orly -f @@ -244,14 +290,13 @@ sudo journalctl -u orly -f
# View recent logs
sudo journalctl -u orly --since "1 hour ago"
----
```
=== remote deployment
### Remote Deployment
You can deploy ORLY on a remote server using SSH:
[source,bash]
----
```bash
# Deploy to a VPS with SSH key authentication
ssh user@your-server.com << 'EOF'
# Clone and deploy
@ -269,35 +314,32 @@ EOF @@ -269,35 +314,32 @@ EOF
# Check deployment status
ssh user@your-server.com 'sudo systemctl status orly'
----
```
=== configuration
### Configuration
After deployment, configure your relay by setting environment variables in your shell profile:
[source,bash]
----
```bash
# Add to ~/.bashrc or ~/.profile
export ORLY_TLS_DOMAINS=relay.example.com
export ORLY_ADMINS=npub1your_admin_key
export ORLY_ACL_MODE=follows
export ORLY_APP_NAME="MyRelay"
----
```
Then restart the service:
[source,bash]
----
```bash
source ~/.bashrc
sudo systemctl restart orly
----
```
=== firewall configuration
### Firewall Configuration
Ensure your firewall allows the necessary ports:
[source,bash]
----
```bash
# For TLS-enabled relays
sudo ufw allow 80/tcp # HTTP (ACME challenges)
sudo ufw allow 443/tcp # HTTPS/WSS
@ -307,14 +349,13 @@ sudo ufw allow 3334/tcp # Default ORLY port @@ -307,14 +349,13 @@ sudo ufw allow 3334/tcp # Default ORLY port
# Enable firewall if not already enabled
sudo ufw enable
----
```
=== monitoring
### Monitoring
Monitor your relay using systemd and standard Linux tools:
[source,bash]
----
```bash
# Service status and logs
sudo systemctl status orly
sudo journalctl -u orly -f
@ -328,30 +369,29 @@ du -sh ~/.local/share/ORLY/ @@ -328,30 +369,29 @@ du -sh ~/.local/share/ORLY/
# Check TLS certificates (if using Let's Encrypt)
ls -la ~/.local/share/ORLY/autocert/
----
```
== testing
## Testing
ORLY includes comprehensive testing tools for protocol validation and performance testing.
* **Protocol Testing**: Use `relay-tester` for Nostr protocol compliance validation
* **Stress Testing**: Performance testing under various load conditions
* **Benchmark Suite**: Comparative performance testing across relay implementations
- **Protocol Testing**: Use `relay-tester` for Nostr protocol compliance validation
- **Stress Testing**: Performance testing under various load conditions
- **Benchmark Suite**: Comparative performance testing across relay implementations
For detailed testing instructions, multi-relay testing scenarios, and advanced usage, see the link:docs/RELAY_TESTING_GUIDE.md[Relay Testing Guide].
For detailed testing instructions, multi-relay testing scenarios, and advanced usage, see the [Relay Testing Guide](docs/RELAY_TESTING_GUIDE.md).
The benchmark suite provides comprehensive performance testing and comparison across multiple relay implementations, including throughput, latency, and memory usage metrics.
== command-line tools
## Command-Line Tools
ORLY includes several command-line utilities in the `cmd/` directory for testing, debugging, and administration.
=== relay-tester
### relay-tester
Nostr protocol compliance testing tool. Validates that a relay correctly implements the Nostr protocol specification.
[source,bash]
----
```bash
# Run all protocol compliance tests
go run ./cmd/relay-tester -url ws://localhost:3334
@ -363,14 +403,13 @@ go run ./cmd/relay-tester -url ws://localhost:3334 -test "Basic Event" @@ -363,14 +403,13 @@ go run ./cmd/relay-tester -url ws://localhost:3334 -test "Basic Event"
# Output results as JSON
go run ./cmd/relay-tester -url ws://localhost:3334 -json
----
```
=== benchmark
### benchmark
Comprehensive relay performance benchmarking tool. Tests event storage, queries, and subscription performance with detailed latency metrics (P90, P95, P99).
[source,bash]
----
```bash
# Run benchmarks against local database
go run ./cmd/benchmark -data-dir /tmp/bench-db -events 10000 -workers 4
@ -380,29 +419,27 @@ go run ./cmd/benchmark -relay ws://localhost:3334 -events 5000 @@ -380,29 +419,27 @@ go run ./cmd/benchmark -relay ws://localhost:3334 -events 5000
# Use different database backends
go run ./cmd/benchmark -dgraph -events 10000
go run ./cmd/benchmark -neo4j -events 10000
----
```
The `cmd/benchmark/` directory also includes Docker Compose configurations for comparative benchmarks across multiple relay implementations (strfry, nostr-rs-relay, khatru, etc.).
=== stresstest
### stresstest
Load testing tool for evaluating relay performance under sustained high-traffic conditions. Generates events with random content and tags to simulate realistic workloads.
[source,bash]
----
```bash
# Run stress test with 10 concurrent workers
go run ./cmd/stresstest -url ws://localhost:3334 -workers 10 -duration 60s
# Generate events with random p-tags (up to 100 per event)
go run ./cmd/stresstest -url ws://localhost:3334 -workers 5
----
```
=== blossomtest
### blossomtest
Tests the Blossom blob storage protocol (BUD-01/BUD-02) implementation. Validates upload, download, and authentication flows.
[source,bash]
----
```bash
# Test with generated key
go run ./cmd/blossomtest -url http://localhost:3334 -size 1024
@ -411,23 +448,21 @@ go run ./cmd/blossomtest -url http://localhost:3334 -nsec nsec1... @@ -411,23 +448,21 @@ go run ./cmd/blossomtest -url http://localhost:3334 -nsec nsec1...
# Test anonymous uploads (no authentication)
go run ./cmd/blossomtest -url http://localhost:3334 -no-auth
----
```
=== aggregator
### aggregator
Event aggregation utility that fetches events from multiple relays using bloom filters for deduplication. Useful for syncing events across relays with memory-efficient duplicate detection.
[source,bash]
----
```bash
go run ./cmd/aggregator -relays wss://relay1.com,wss://relay2.com -output events.jsonl
----
```
=== convert
### convert
Key format conversion utility. Converts between hex and bech32 (npub/nsec) formats for Nostr keys.
[source,bash]
----
```bash
# Convert npub to hex
go run ./cmd/convert npub1abc...
@ -436,14 +471,13 @@ go run ./cmd/convert 0123456789abcdef... @@ -436,14 +471,13 @@ go run ./cmd/convert 0123456789abcdef...
# Convert secret key (nsec or hex) - outputs both nsec and derived npub
go run ./cmd/convert --secret nsec1xyz...
----
```
=== FIND
### FIND
Free Internet Name Daemon - CLI tool for the distributed naming system. Manages name registration, transfers, and certificate issuance.
[source,bash]
----
```bash
# Validate a name format
go run ./cmd/FIND verify-name example.nostr
@ -455,91 +489,84 @@ go run ./cmd/FIND register myname.nostr @@ -455,91 +489,84 @@ go run ./cmd/FIND register myname.nostr
# Transfer a name to a new owner
go run ./cmd/FIND transfer myname.nostr npub1newowner...
----
```
=== policytest
### policytest
Tests the policy system for event write control. Validates that policy rules correctly allow or reject events based on kind, pubkey, and other criteria.
[source,bash]
----
```bash
go run ./cmd/policytest -url ws://localhost:3334 -type event -kind 4678
go run ./cmd/policytest -url ws://localhost:3334 -type req -kind 1
go run ./cmd/policytest -url ws://localhost:3334 -type publish-and-query -count 5
----
```
=== policyfiltertest
### policyfiltertest
Tests policy-based filtering with authorized and unauthorized pubkeys. Validates access control rules for specific users.
[source,bash]
----
```bash
go run ./cmd/policyfiltertest -url ws://localhost:3334 \
-allowed-pubkey <hex> -allowed-sec <hex> \
-unauthorized-pubkey <hex> -unauthorized-sec <hex>
----
```
=== subscription-test
### subscription-test
Tests WebSocket subscription stability over extended periods. Monitors for dropped subscriptions and connection issues.
[source,bash]
----
```bash
# Run subscription stability test for 60 seconds
go run ./cmd/subscription-test -url ws://localhost:3334 -duration 60 -kind 1
# With verbose output
go run ./cmd/subscription-test -url ws://localhost:3334 -duration 120 -v
----
```
=== subscription-test-simple
### subscription-test-simple
Simplified subscription stability test that verifies subscriptions remain active without dropping over the test duration.
[source,bash]
----
```bash
go run ./cmd/subscription-test-simple -url ws://localhost:3334 -duration 120
----
```
== access control
## Access Control
=== follows ACL
### Follows ACL
The follows ACL (Access Control List) system provides flexible relay access control based on social relationships in the Nostr network.
[source,bash]
----
```bash
export ORLY_ACL_MODE=follows
export ORLY_ADMINS=npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku
./orly
----
```
The system grants write access to users followed by designated admins, with read-only access for others. Follow lists update dynamically as admins modify their relationships.
=== cluster replication
### Cluster Replication
ORLY supports distributed relay clusters using active replication. When configured with peer relays, ORLY will automatically synchronize events between cluster members using efficient HTTP polling.
[source,bash]
----
```bash
export ORLY_RELAY_PEERS=https://peer1.example.com,https://peer2.example.com
export ORLY_CLUSTER_ADMINS=npub1cluster_admin_key
----
```
**Privacy Considerations:** By default, ORLY propagates all events including privileged events (DMs, gift wraps, etc.) to cluster peers for complete synchronization. This ensures no data loss but may expose private communications to other relay operators in your cluster.
To enhance privacy, you can disable propagation of privileged events:
[source,bash]
----
```bash
export ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS=false
----
```
**Important:** When disabled, privileged events will not be replicated to peer relays. This provides better privacy but means these events will only be available on the originating relay. Users should be aware that accessing their privileged events may require connecting directly to the relay where they were originally published.
== developer notes
## Developer Notes
=== binary-optimized tag storage
### Binary-Optimized Tag Storage
The nostr library (`git.mleku.dev/mleku/nostr/encoders/tag`) uses binary optimization for `e` and `p` tags to reduce memory usage and improve comparison performance.
@ -547,16 +574,14 @@ When events are unmarshaled from JSON, 64-character hex values in e/p tags are c @@ -547,16 +574,14 @@ When events are unmarshaled from JSON, 64-character hex values in e/p tags are c
**Important:** When working with e/p tag values in code:
* **DO NOT** use `tag.Value()` directly - it returns raw bytes which may be binary, not hex
* **ALWAYS** use `tag.ValueHex()` to get a hex string regardless of storage format
* **Use** `tag.ValueBinary()` to get raw 32-byte binary (returns nil if not binary-encoded)
- **DO NOT** use `tag.Value()` directly - it returns raw bytes which may be binary, not hex
- **ALWAYS** use `tag.ValueHex()` to get a hex string regardless of storage format
- **Use** `tag.ValueBinary()` to get raw 32-byte binary (returns nil if not binary-encoded)
[source,go]
----
```go
// CORRECT: Use ValueHex() for hex decoding
pt, err := hex.Dec(string(pTag.ValueHex()))
// WRONG: Value() may return binary bytes, not hex
pt, err := hex.Dec(string(pTag.Value())) // Will fail for binary-encoded tags!
----
```

21
app/server.go

@ -208,6 +208,15 @@ func (s *Server) UserInterface() { @@ -208,6 +208,15 @@ func (s *Server) UserInterface() {
origDirector(req)
req.Host = target.Host
}
// Suppress noisy "context canceled" errors from browser navigation
s.devProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
if r.Context().Err() == context.Canceled {
// Browser canceled the request - this is normal, don't log it
return
}
log.Printf("proxy error: %v", err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
}
}
}
@ -282,6 +291,12 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) { @@ -282,6 +291,12 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
return
}
// If web UI is disabled without a proxy, return 404
if s.Config != nil && s.Config.WebDisableEmbedded {
http.NotFound(w, r)
return
}
// Serve orly-favicon.png as favicon.ico from embedded web app
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day
@ -302,6 +317,12 @@ func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) { @@ -302,6 +317,12 @@ func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
return
}
// If web UI is disabled without a proxy, return 404
if s.Config != nil && s.Config.WebDisableEmbedded {
http.NotFound(w, r)
return
}
// Serve embedded web interface
ServeEmbeddedWeb(w, r)
}

3
app/web/.gitignore vendored

@ -1,5 +1,8 @@ @@ -1,5 +1,8 @@
node_modules/
dist/
public/bundle.js
public/bundle.js.map
public/bundle.css
.vite/
.tanstack/
.idea/

2
app/web/package.json

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"start": "sirv public --no-clear"
"start": "sirv public --no-clear --single"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.0",

10
app/web/rollup.config.js

@ -9,6 +9,10 @@ import copy from "rollup-plugin-copy"; @@ -9,6 +9,10 @@ import copy from "rollup-plugin-copy";
const production = !process.env.ROLLUP_WATCH;
// In dev mode, output to public/ so sirv can serve it
// In production, output to dist/ for embedding
const outputDir = production ? "dist" : "public";
function serve() {
let server;
@ -36,7 +40,7 @@ export default { @@ -36,7 +40,7 @@ export default {
sourcemap: true,
format: "iife",
name: "app",
file: "dist/bundle.js",
file: `${outputDir}/bundle.js`,
},
plugins: [
svelte({
@ -73,8 +77,8 @@ export default { @@ -73,8 +77,8 @@ export default {
// instead of npm run dev), minify
production && terser(),
// Copy static files from public to dist
copy({
// Copy static files from public to dist (only in production)
production && copy({
targets: [
{ src: 'public/index.html', dest: 'dist' },
{ src: 'public/global.css', dest: 'dist' },

278
app/web/src/App.svelte

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
<script>
// Svelte component imports
import LoginModal from "./LoginModal.svelte";
import ManagedACL from "./ManagedACL.svelte";
import Header from "./Header.svelte";
@ -13,7 +14,14 @@ @@ -13,7 +14,14 @@
import SearchResultsView from "./SearchResultsView.svelte";
import FilterBuilder from "./FilterBuilder.svelte";
import FilterDisplay from "./FilterDisplay.svelte";
// Utility imports
import { buildFilter } from "./helpers.tsx";
import { replaceableKinds, kindNames, CACHE_DURATION } from "./constants.js";
import { getKindName, truncatePubkey, truncateContent, formatTimestamp, escapeHtml, aboutToHtml, copyToClipboard, showCopyFeedback } from "./utils.js";
import * as api from "./api.js";
// Nostr library imports
import {
initializeNostrClient,
fetchUserProfile,
@ -73,7 +81,7 @@ @@ -73,7 +81,7 @@
// Global events cache system
let globalEventsCache = []; // All events cache
let globalCacheTimestamp = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// CACHE_DURATION is imported from constants.js
// Events filter toggle
let showOnlyMyEvents = false;
@ -120,213 +128,10 @@ @@ -120,213 +128,10 @@
let recoveryOldestTimestamp = null;
let recoveryNewestTimestamp = null;
// Replaceable kinds for the recovery dropdown
// Based on NIP-01: kinds 0, 3, and 10000-19999 are replaceable
// kinds 30000-39999 are addressable (parameterized replaceable)
const replaceableKinds = [
// Basic replaceable kinds (0, 3)
{ value: 0, label: "User Metadata (0)" },
{ value: 3, label: "Follows (3)" },
// Replaceable range 10000-19999
{ value: 10000, label: "Mute list (10000)" },
{ value: 10001, label: "Pin list (10001)" },
{ value: 10002, label: "Relay List Metadata (10002)" },
{ value: 10003, label: "Bookmark list (10003)" },
{ value: 10004, label: "Communities list (10004)" },
{ value: 10005, label: "Public chats list (10005)" },
{ value: 10006, label: "Blocked relays list (10006)" },
{ value: 10007, label: "Search relays list (10007)" },
{ value: 10009, label: "User groups (10009)" },
{ value: 10012, label: "Favorite relays list (10012)" },
{ value: 10013, label: "Private event relay list (10013)" },
{ value: 10015, label: "Interests list (10015)" },
{ value: 10019, label: "Nutzap Mint Recommendation (10019)" },
{ value: 10020, label: "Media follows (10020)" },
{ value: 10030, label: "User emoji list (10030)" },
{ value: 10050, label: "Relay list to receive DMs (10050)" },
{ value: 10051, label: "KeyPackage Relays List (10051)" },
{ value: 10063, label: "User server list (10063)" },
{ value: 10096, label: "File storage server list (10096)" },
{ value: 10166, label: "Relay Monitor Announcement (10166)" },
{ value: 10312, label: "Room Presence (10312)" },
{ value: 10377, label: "Proxy Announcement (10377)" },
{ value: 11111, label: "Transport Method Announcement (11111)" },
{ value: 13194, label: "Wallet Info (13194)" },
{ value: 17375, label: "Cashu Wallet Event (17375)" },
// Addressable range 30000-39999 (parameterized replaceable)
{ value: 30000, label: "Follow sets (30000)" },
{ value: 30001, label: "Generic lists (30001)" },
{ value: 30002, label: "Relay sets (30002)" },
{ value: 30003, label: "Bookmark sets (30003)" },
{ value: 30004, label: "Curation sets (30004)" },
{ value: 30005, label: "Video sets (30005)" },
{ value: 30007, label: "Kind mute sets (30007)" },
{ value: 30008, label: "Profile Badges (30008)" },
{ value: 30009, label: "Badge Definition (30009)" },
{ value: 30015, label: "Interest sets (30015)" },
{ value: 30017, label: "Create or update a stall (30017)" },
{ value: 30018, label: "Create or update a product (30018)" },
{ value: 30019, label: "Marketplace UI/UX (30019)" },
{ value: 30020, label: "Product sold as an auction (30020)" },
{ value: 30023, label: "Long-form Content (30023)" },
{ value: 30024, label: "Draft Long-form Content (30024)" },
{ value: 30030, label: "Emoji sets (30030)" },
{ value: 30040, label: "Curated Publication Index (30040)" },
{ value: 30041, label: "Curated Publication Content (30041)" },
{ value: 30063, label: "Release artifact sets (30063)" },
{ value: 30078, label: "Application-specific Data (30078)" },
{ value: 30166, label: "Relay Discovery (30166)" },
{ value: 30267, label: "App curation sets (30267)" },
{ value: 30311, label: "Live Event (30311)" },
{ value: 30312, label: "Interactive Room (30312)" },
{ value: 30313, label: "Conference Event (30313)" },
{ value: 30315, label: "User Statuses (30315)" },
{ value: 30388, label: "Slide Set (30388)" },
{ value: 30402, label: "Classified Listing (30402)" },
{ value: 30403, label: "Draft Classified Listing (30403)" },
{ value: 30617, label: "Repository announcements (30617)" },
{ value: 30618, label: "Repository state announcements (30618)" },
{ value: 30818, label: "Wiki article (30818)" },
{ value: 30819, label: "Redirects (30819)" },
{ value: 31234, label: "Draft Event (31234)" },
{ value: 31388, label: "Link Set (31388)" },
{ value: 31890, label: "Feed (31890)" },
{ value: 31922, label: "Date-Based Calendar Event (31922)" },
{ value: 31923, label: "Time-Based Calendar Event (31923)" },
{ value: 31924, label: "Calendar (31924)" },
{ value: 31925, label: "Calendar Event RSVP (31925)" },
{ value: 31989, label: "Handler recommendation (31989)" },
{ value: 31990, label: "Handler information (31990)" },
{ value: 32267, label: "Software Application (32267)" },
{ value: 34550, label: "Community Definition (34550)" },
{ value: 37516, label: "Geocache listing (37516)" },
{ value: 38172, label: "Cashu Mint Announcement (38172)" },
{ value: 38173, label: "Fedimint Announcement (38173)" },
{ value: 38383, label: "Peer-to-peer Order events (38383)" },
{ value: 39089, label: "Starter packs (39089)" },
{ value: 39092, label: "Media starter packs (39092)" },
{ value: 39701, label: "Web bookmarks (39701)" },
];
// replaceableKinds is now imported from constants.js
// Kind name mapping based on NIP specification
// Matches official Nostr event kinds from https://github.com/nostr-protocol/nips
const kindNames = {
0: "User Metadata",
1: "Short Text Note",
2: "Recommend Relay",
3: "Follows",
4: "Encrypted Direct Messages",
5: "Event Deletion Request",
6: "Repost",
7: "Reaction",
8: "Badge Award",
9: "Chat Message",
10: "Group Chat Threaded Reply",
11: "Thread",
12: "Group Thread Reply",
13: "Seal",
14: "Direct Message",
15: "File Message",
16: "Generic Repost",
17: "Reaction to a website",
20: "Picture",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1021: "Bid",
1022: "Bid Confirmation",
1040: "OpenTimestamps",
1063: "File Metadata",
1311: "Live Chat Message",
1971: "Problem Tracker",
1984: "Reporting",
1985: "Label",
4550: "Community Post Approval",
5000: "Job Request",
5999: "Job Request",
6000: "Job Result",
6999: "Job Result",
7000: "Job Feedback",
9041: "Zap Goal",
9734: "Zap Request",
9735: "Zap",
9882: "Highlights",
10000: "Mute list",
10001: "Pin list",
10002: "Relay List Metadata",
10003: "Bookmarks list",
10004: "Communities list",
10005: "Public Chats list",
10006: "Blocked Relays list",
10007: "Search Relays list",
10015: "Interests",
10030: "User Emoji list",
10050: "DM relays",
10096: "File Storage Server List",
13194: "Wallet Service Info",
21000: "Lightning pub RPC",
22242: "Client Authentication",
23194: "Wallet Request",
23195: "Wallet Response",
23196: "Wallet Notification",
23197: "Wallet Notification",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Follow sets",
30001: "Generic lists",
30002: "Relay sets",
30003: "Bookmark sets",
30004: "Curation sets",
30008: "Profile Badges",
30009: "Badge Definition",
30015: "Interest sets",
30017: "Stall Definition",
30018: "Product Definition",
30019: "Marketplace UI/UX",
30020: "Product sold as an auction",
30023: "Long-form Content",
30024: "Draft Long-form Content",
30030: "Emoji sets",
30078: "Application-specific Data",
30311: "Live Event",
30315: "User Statuses",
30402: "Classified Listing",
30403: "Draft Classified Listing",
31922: "Date-Based Calendar Event",
31923: "Time-Based Calendar Event",
31924: "Calendar",
31925: "Calendar Event RSVP",
31989: "Handler recommendation",
31990: "Handler information",
34235: "Video Event Horizontal",
34236: "Video Event Vertical",
34550: "Community Definition",
};
function getKindName(kind) {
return kindNames[kind] || `Kind ${kind}`;
}
function truncatePubkey(pubkey) {
if (!pubkey) return "unknown";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
function truncateContent(content, maxLength = 100) {
if (!content) return "";
return content.length > maxLength
? content.slice(0, maxLength) + "..."
: content;
}
function formatTimestamp(timestamp) {
if (!timestamp) return "";
return new Date(timestamp * 1000).toLocaleString();
}
// Helper functions imported from utils.js:
// - getKindName, truncatePubkey, truncateContent, formatTimestamp, escapeHtml, copyToClipboard, showCopyFeedback
function toggleEventExpansion(eventId) {
if (expandedEvents.has(eventId)) {
@ -338,47 +143,12 @@ @@ -338,47 +143,12 @@
}
async function copyEventToClipboard(eventData, clickEvent) {
try {
// Create minified JSON (no indentation)
const minifiedJson = JSON.stringify(eventData);
await navigator.clipboard.writeText(minifiedJson);
// Show temporary feedback
const button = clickEvent.target.closest(".copy-json-btn");
if (button) {
const originalText = button.textContent;
button.textContent = "✅";
button.style.backgroundColor = "#4CAF50";
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = "";
}, 2000);
}
} catch (error) {
console.error("Failed to copy to clipboard:", error);
// Fallback for older browsers
try {
const textArea = document.createElement("textarea");
textArea.value = JSON.stringify(eventData);
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
const button = clickEvent.target.closest(".copy-json-btn");
if (button) {
const originalText = button.textContent;
button.textContent = "✅";
button.style.backgroundColor = "#4CAF50";
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = "";
}, 2000);
}
} catch (fallbackError) {
console.error("Fallback copy also failed:", fallbackError);
alert("Failed to copy to clipboard. Please copy manually.");
}
const minifiedJson = JSON.stringify(eventData);
const success = await copyToClipboard(minifiedJson);
const button = clickEvent.target.closest(".copy-json-btn");
showCopyFeedback(button, success);
if (!success) {
alert("Failed to copy to clipboard. Please copy manually.");
}
}
@ -736,15 +506,7 @@ @@ -736,15 +506,7 @@
}
}
// Safely render "about" text: convert double newlines to a single HTML line break
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// escapeHtml is imported from utils.js
// Recovery tab functions
async function loadRecoveryEvents() {
@ -4329,8 +4091,10 @@ @@ -4329,8 +4091,10 @@
/* Recovery Tab Styles */
.recovery-tab {
padding: 20px;
width: 100%;
max-width: 1200px;
margin: 0;
box-sizing: border-box;
}
.recovery-tab h3 {

12
app/web/src/ComposeView.svelte

@ -126,4 +126,16 @@ @@ -126,4 +126,16 @@
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
@media (max-width: 1280px) {
.compose-view {
left: 60px;
}
}
@media (max-width: 640px) {
.compose-view {
left: 160px;
}
}
</style>

1
app/web/src/EventsView.svelte

@ -272,6 +272,7 @@ @@ -272,6 +272,7 @@
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.events-view-content {

8
app/web/src/ExportView.svelte

@ -57,7 +57,9 @@ @@ -57,7 +57,9 @@
border-radius: 8px;
padding: 1em;
margin: 1em;
width: 32em;
width: 100%;
max-width: 32em;
box-sizing: border-box;
background-color: var(--card-bg);
}
@ -97,7 +99,9 @@ @@ -97,7 +99,9 @@
background-color: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
width: 32em;
width: 100%;
max-width: 32em;
box-sizing: border-box;
}
.login-prompt p {

4
app/web/src/ImportView.svelte

@ -72,7 +72,9 @@ @@ -72,7 +72,9 @@
padding: 1em;
border-radius: 8px;
margin-bottom: 1.5rem;
width: 32em;
width: 100%;
max-width: 32em;
box-sizing: border-box;
}
.import-section h3 {

1
app/web/src/PolicyView.svelte

@ -336,6 +336,7 @@ @@ -336,6 +336,7 @@
background: var(--header-bg);
color: var(--text-color);
border-radius: 8px;
box-sizing: border-box;
}
.policy-view h2 {

1
app/web/src/RecoveryView.svelte

@ -186,6 +186,7 @@ @@ -186,6 +186,7 @@
background: var(--header-bg);
color: var(--text-color);
border-radius: 8px;
box-sizing: border-box;
}
.recovery-tab h3 {

34
app/web/src/Sidebar.svelte

@ -70,6 +70,7 @@ @@ -70,6 +70,7 @@
display: flex;
align-items: center;
padding: 0.75em;
padding-left: 1em;
background: transparent;
color: var(--text-color);
border: none;
@ -118,4 +119,37 @@ @@ -118,4 +119,37 @@
background-color: var(--warning);
color: var(--text-color);
}
@media (max-width: 1280px) {
.sidebar {
width: 60px;
}
.tab-label {
display: none;
}
.tab-close-icon {
display: none;
}
.tab {
/* Keep left alignment so icons stay in same position */
justify-content: flex-start;
}
}
@media (max-width: 640px) {
.sidebar {
width: 160px;
}
.tab-label {
display: block;
}
.tab {
justify-content: flex-start;
}
}
</style>

370
app/web/src/api.js

@ -0,0 +1,370 @@ @@ -0,0 +1,370 @@
/**
* API helper functions for ORLY relay management endpoints
*/
/**
* Create NIP-98 authentication header
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} method - HTTP method
* @param {string} url - Request URL
* @returns {Promise<string|null>} Base64 encoded auth header or null
*/
export async function createNIP98Auth(signer, pubkey, method, url) {
if (!signer || !pubkey) {
console.log("No signer or pubkey available");
return null;
}
try {
// Create unsigned auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["method", method],
],
content: "",
};
// Sign using the signer
const signedEvent = await signer.signEvent(authEvent);
return btoa(JSON.stringify(signedEvent));
} catch (error) {
console.error("Error creating NIP-98 auth:", error);
return null;
}
}
/**
* Fetch user role from the relay
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<string>} User role
*/
export async function fetchUserRole(signer, pubkey) {
try {
const url = `${window.location.origin}/api/role`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (response.ok) {
const data = await response.json();
return data.role || "";
}
} catch (error) {
console.error("Error fetching user role:", error);
}
return "";
}
/**
* Fetch ACL mode from the relay
* @returns {Promise<string>} ACL mode
*/
export async function fetchACLMode() {
try {
const response = await fetch(`${window.location.origin}/api/acl-mode`);
if (response.ok) {
const data = await response.json();
return data.mode || "";
}
} catch (error) {
console.error("Error fetching ACL mode:", error);
}
return "";
}
// ==================== Sprocket API ====================
/**
* Load sprocket configuration
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Sprocket config data
*/
export async function loadSprocketConfig(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/config`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load config: ${response.statusText}`);
return await response.json();
}
/**
* Load sprocket status
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Sprocket status data
*/
export async function loadSprocketStatus(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/status`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load status: ${response.statusText}`);
return await response.json();
}
/**
* Load sprocket script
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<string>} Sprocket script content
*/
export async function loadSprocketScript(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (response.status === 404) return "";
if (!response.ok) throw new Error(`Failed to load sprocket: ${response.statusText}`);
return await response.text();
}
/**
* Save sprocket script
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} script - Script content
* @returns {Promise<object>} Save result
*/
export async function saveSprocketScript(signer, pubkey, script) {
const url = `${window.location.origin}/api/sprocket`;
const authHeader = await createNIP98Auth(signer, pubkey, "PUT", url);
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "text/plain",
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
},
body: script,
});
if (!response.ok) throw new Error(`Failed to save: ${response.statusText}`);
return await response.json();
}
/**
* Restart sprocket
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Restart result
*/
export async function restartSprocket(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/restart`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, {
method: "POST",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to restart: ${response.statusText}`);
return await response.json();
}
/**
* Delete sprocket
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Delete result
*/
export async function deleteSprocket(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket`;
const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url);
const response = await fetch(url, {
method: "DELETE",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
return await response.json();
}
/**
* Load sprocket versions
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<Array>} Version list
*/
export async function loadSprocketVersions(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/versions`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load versions: ${response.statusText}`);
return await response.json();
}
/**
* Load specific sprocket version
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} version - Version filename
* @returns {Promise<string>} Version content
*/
export async function loadSprocketVersion(signer, pubkey, version) {
const url = `${window.location.origin}/api/sprocket/versions/${encodeURIComponent(version)}`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load version: ${response.statusText}`);
return await response.text();
}
/**
* Delete sprocket version
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} filename - Version filename
* @returns {Promise<object>} Delete result
*/
export async function deleteSprocketVersion(signer, pubkey, filename) {
const url = `${window.location.origin}/api/sprocket/versions/${encodeURIComponent(filename)}`;
const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url);
const response = await fetch(url, {
method: "DELETE",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to delete version: ${response.statusText}`);
return await response.json();
}
/**
* Upload sprocket script file
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {File} file - File to upload
* @returns {Promise<object>} Upload result
*/
export async function uploadSprocketScript(signer, pubkey, file) {
const content = await file.text();
return await saveSprocketScript(signer, pubkey, content);
}
// ==================== Policy API ====================
/**
* Load policy configuration
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Policy config
*/
export async function loadPolicyConfig(signer, pubkey) {
const url = `${window.location.origin}/api/policy/config`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load policy config: ${response.statusText}`);
return await response.json();
}
/**
* Load policy JSON
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Policy JSON
*/
export async function loadPolicy(signer, pubkey) {
const url = `${window.location.origin}/api/policy`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load policy: ${response.statusText}`);
return await response.json();
}
/**
* Validate policy JSON
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} policyJson - Policy JSON string
* @returns {Promise<object>} Validation result
*/
export async function validatePolicy(signer, pubkey, policyJson) {
const url = `${window.location.origin}/api/policy/validate`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
},
body: policyJson,
});
return await response.json();
}
/**
* Fetch policy follows whitelist
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<Array>} List of followed pubkeys
*/
export async function fetchPolicyFollows(signer, pubkey) {
const url = `${window.location.origin}/api/policy/follows`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to fetch follows: ${response.statusText}`);
const data = await response.json();
return data.follows || [];
}
// ==================== Export/Import API ====================
/**
* Export events
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {Array} authorPubkeys - Filter by authors (empty for all)
* @returns {Promise<Blob>} JSONL blob
*/
export async function exportEvents(signer, pubkey, authorPubkeys = []) {
const url = `${window.location.origin}/api/export`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
},
body: JSON.stringify({ pubkeys: authorPubkeys }),
});
if (!response.ok) throw new Error(`Export failed: ${response.statusText}`);
return await response.blob();
}
/**
* Import events from file
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {File} file - JSONL file to import
* @returns {Promise<object>} Import result
*/
export async function importEvents(signer, pubkey, file) {
const url = `${window.location.origin}/api/import`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const formData = new FormData();
formData.append("file", file);
const response = await fetch(url, {
method: "POST",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
body: formData,
});
if (!response.ok) throw new Error(`Import failed: ${response.statusText}`);
return await response.json();
}

190
app/web/src/constants.js

@ -4,3 +4,193 @@ export const DEFAULT_RELAYS = [ @@ -4,3 +4,193 @@ export const DEFAULT_RELAYS = [
// Automatically use ws:// for http:// and wss:// for https://
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/`,
];
// Replaceable kinds for the recovery dropdown
// Based on NIP-01: kinds 0, 3, and 10000-19999 are replaceable
// kinds 30000-39999 are addressable (parameterized replaceable)
export const replaceableKinds = [
// Basic replaceable kinds (0, 3)
{ value: 0, label: "User Metadata (0)" },
{ value: 3, label: "Follows (3)" },
// Replaceable range 10000-19999
{ value: 10000, label: "Mute list (10000)" },
{ value: 10001, label: "Pin list (10001)" },
{ value: 10002, label: "Relay List Metadata (10002)" },
{ value: 10003, label: "Bookmark list (10003)" },
{ value: 10004, label: "Communities list (10004)" },
{ value: 10005, label: "Public chats list (10005)" },
{ value: 10006, label: "Blocked relays list (10006)" },
{ value: 10007, label: "Search relays list (10007)" },
{ value: 10009, label: "User groups (10009)" },
{ value: 10012, label: "Favorite relays list (10012)" },
{ value: 10013, label: "Private event relay list (10013)" },
{ value: 10015, label: "Interests list (10015)" },
{ value: 10019, label: "Nutzap Mint Recommendation (10019)" },
{ value: 10020, label: "Media follows (10020)" },
{ value: 10030, label: "User emoji list (10030)" },
{ value: 10050, label: "Relay list to receive DMs (10050)" },
{ value: 10051, label: "KeyPackage Relays List (10051)" },
{ value: 10063, label: "User server list (10063)" },
{ value: 10096, label: "File storage server list (10096)" },
{ value: 10166, label: "Relay Monitor Announcement (10166)" },
{ value: 10312, label: "Room Presence (10312)" },
{ value: 10377, label: "Proxy Announcement (10377)" },
{ value: 11111, label: "Transport Method Announcement (11111)" },
{ value: 13194, label: "Wallet Info (13194)" },
{ value: 17375, label: "Cashu Wallet Event (17375)" },
// Addressable range 30000-39999 (parameterized replaceable)
{ value: 30000, label: "Follow sets (30000)" },
{ value: 30001, label: "Generic lists (30001)" },
{ value: 30002, label: "Relay sets (30002)" },
{ value: 30003, label: "Bookmark sets (30003)" },
{ value: 30004, label: "Curation sets (30004)" },
{ value: 30005, label: "Video sets (30005)" },
{ value: 30007, label: "Kind mute sets (30007)" },
{ value: 30008, label: "Profile Badges (30008)" },
{ value: 30009, label: "Badge Definition (30009)" },
{ value: 30015, label: "Interest sets (30015)" },
{ value: 30017, label: "Create or update a stall (30017)" },
{ value: 30018, label: "Create or update a product (30018)" },
{ value: 30019, label: "Marketplace UI/UX (30019)" },
{ value: 30020, label: "Product sold as an auction (30020)" },
{ value: 30023, label: "Long-form Content (30023)" },
{ value: 30024, label: "Draft Long-form Content (30024)" },
{ value: 30030, label: "Emoji sets (30030)" },
{ value: 30040, label: "Curated Publication Index (30040)" },
{ value: 30041, label: "Curated Publication Content (30041)" },
{ value: 30063, label: "Release artifact sets (30063)" },
{ value: 30078, label: "Application-specific Data (30078)" },
{ value: 30166, label: "Relay Discovery (30166)" },
{ value: 30267, label: "App curation sets (30267)" },
{ value: 30311, label: "Live Event (30311)" },
{ value: 30312, label: "Interactive Room (30312)" },
{ value: 30313, label: "Conference Event (30313)" },
{ value: 30315, label: "User Statuses (30315)" },
{ value: 30388, label: "Slide Set (30388)" },
{ value: 30402, label: "Classified Listing (30402)" },
{ value: 30403, label: "Draft Classified Listing (30403)" },
{ value: 30617, label: "Repository announcements (30617)" },
{ value: 30618, label: "Repository state announcements (30618)" },
{ value: 30818, label: "Wiki article (30818)" },
{ value: 30819, label: "Redirects (30819)" },
{ value: 31234, label: "Draft Event (31234)" },
{ value: 31388, label: "Link Set (31388)" },
{ value: 31890, label: "Feed (31890)" },
{ value: 31922, label: "Date-Based Calendar Event (31922)" },
{ value: 31923, label: "Time-Based Calendar Event (31923)" },
{ value: 31924, label: "Calendar (31924)" },
{ value: 31925, label: "Calendar Event RSVP (31925)" },
{ value: 31989, label: "Handler recommendation (31989)" },
{ value: 31990, label: "Handler information (31990)" },
{ value: 32267, label: "Software Application (32267)" },
{ value: 34550, label: "Community Definition (34550)" },
{ value: 37516, label: "Geocache listing (37516)" },
{ value: 38172, label: "Cashu Mint Announcement (38172)" },
{ value: 38173, label: "Fedimint Announcement (38173)" },
{ value: 38383, label: "Peer-to-peer Order events (38383)" },
{ value: 39089, label: "Starter packs (39089)" },
{ value: 39092, label: "Media starter packs (39092)" },
{ value: 39701, label: "Web bookmarks (39701)" },
];
// Kind name mapping based on NIP specification
// Matches official Nostr event kinds from https://github.com/nostr-protocol/nips
export const kindNames = {
0: "User Metadata",
1: "Short Text Note",
2: "Recommend Relay",
3: "Follows",
4: "Encrypted Direct Messages",
5: "Event Deletion Request",
6: "Repost",
7: "Reaction",
8: "Badge Award",
9: "Chat Message",
10: "Group Chat Threaded Reply",
11: "Thread",
12: "Group Thread Reply",
13: "Seal",
14: "Direct Message",
15: "File Message",
16: "Generic Repost",
17: "Reaction to a website",
20: "Picture",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1021: "Bid",
1022: "Bid Confirmation",
1040: "OpenTimestamps",
1063: "File Metadata",
1311: "Live Chat Message",
1971: "Problem Tracker",
1984: "Reporting",
1985: "Label",
4550: "Community Post Approval",
5000: "Job Request",
5999: "Job Request",
6000: "Job Result",
6999: "Job Result",
7000: "Job Feedback",
9041: "Zap Goal",
9734: "Zap Request",
9735: "Zap",
9882: "Highlights",
10000: "Mute list",
10001: "Pin list",
10002: "Relay List Metadata",
10003: "Bookmarks list",
10004: "Communities list",
10005: "Public Chats list",
10006: "Blocked Relays list",
10007: "Search Relays list",
10015: "Interests",
10030: "User Emoji list",
10050: "DM relays",
10096: "File Storage Server List",
13194: "Wallet Service Info",
21000: "Lightning pub RPC",
22242: "Client Authentication",
23194: "Wallet Request",
23195: "Wallet Response",
23196: "Wallet Notification",
23197: "Wallet Notification",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Follow sets",
30001: "Generic lists",
30002: "Relay sets",
30003: "Bookmark sets",
30004: "Curation sets",
30008: "Profile Badges",
30009: "Badge Definition",
30015: "Interest sets",
30017: "Stall Definition",
30018: "Product Definition",
30019: "Marketplace UI/UX",
30020: "Product sold as an auction",
30023: "Long-form Content",
30024: "Draft Long-form Content",
30030: "Emoji sets",
30078: "Application-specific Data",
30311: "Live Event",
30315: "User Statuses",
30402: "Classified Listing",
30403: "Draft Classified Listing",
31922: "Date-Based Calendar Event",
31923: "Time-Based Calendar Event",
31924: "Calendar",
31925: "Calendar Event RSVP",
31989: "Handler recommendation",
31990: "Handler information",
34235: "Video Event Horizontal",
34236: "Video Event Vertical",
34550: "Community Definition",
};
// Cache configuration
export const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

88
app/web/src/stores.js

@ -0,0 +1,88 @@ @@ -0,0 +1,88 @@
import { writable, derived } from 'svelte/store';
// ==================== User/Auth State ====================
export const isLoggedIn = writable(false);
export const userPubkey = writable("");
export const userProfile = writable(null);
export const userRole = writable("");
export const userSigner = writable(null);
export const authMethod = writable("");
// View-as role for permission testing
export const viewAsRole = writable("");
// Derived: effective role (actual or view-as)
export const currentEffectiveRole = derived(
[userRole, viewAsRole],
([$userRole, $viewAsRole]) => $viewAsRole || $userRole
);
// ==================== UI State ====================
export const isDarkTheme = writable(false);
export const showLoginModal = writable(false);
export const showSettingsDrawer = writable(false);
export const selectedTab = writable(localStorage.getItem("selectedTab") || "export");
export const showFilterBuilder = writable(false);
// ==================== ACL State ====================
export const aclMode = writable("");
export const isPolicyAdmin = writable(false);
export const policyEnabled = writable(false);
// ==================== Events Cache ====================
export const globalEventsCache = writable([]);
export const globalCacheTimestamp = writable(0);
// ==================== Search State ====================
export const searchQuery = writable("");
export const searchTabs = writable([]);
export const searchResults = writable(new Map());
// ==================== Helper Functions ====================
/**
* Reset all auth-related stores on logout
*/
export function resetAuthState() {
isLoggedIn.set(false);
userPubkey.set("");
userProfile.set(null);
userRole.set("");
userSigner.set(null);
authMethod.set("");
viewAsRole.set("");
isPolicyAdmin.set(false);
}
/**
* Clear the events cache
*/
export function clearEventsCache() {
globalEventsCache.set([]);
globalCacheTimestamp.set(0);
}
/**
* Update the events cache
* @param {Array} events - Events to cache
*/
export function updateEventsCache(events) {
globalEventsCache.set(events);
globalCacheTimestamp.set(Date.now());
}
/**
* Check if cache is still valid
* @param {number} cacheDuration - Cache duration in ms
* @returns {boolean}
*/
export function isCacheValid(cacheDuration = 5 * 60 * 1000) {
let timestamp;
globalCacheTimestamp.subscribe(v => timestamp = v)();
return Date.now() - timestamp < cacheDuration;
}

119
app/web/src/utils.js

@ -0,0 +1,119 @@ @@ -0,0 +1,119 @@
import { kindNames } from './constants.js';
/**
* Get human-readable name for a Nostr event kind
* @param {number} kind - The event kind number
* @returns {string} Human readable kind name
*/
export function getKindName(kind) {
return kindNames[kind] || `Kind ${kind}`;
}
/**
* Truncate a pubkey for display
* @param {string} pubkey - The full pubkey hex string
* @returns {string} Truncated pubkey
*/
export function truncatePubkey(pubkey) {
if (!pubkey) return "unknown";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
/**
* Truncate content for preview display
* @param {string} content - The content to truncate
* @param {number} maxLength - Maximum length before truncation
* @returns {string} Truncated content
*/
export function truncateContent(content, maxLength = 100) {
if (!content) return "";
return content.length > maxLength
? content.slice(0, maxLength) + "..."
: content;
}
/**
* Format a Unix timestamp for display
* @param {number} timestamp - Unix timestamp in seconds
* @returns {string} Formatted date/time string
*/
export function formatTimestamp(timestamp) {
if (!timestamp) return "";
return new Date(timestamp * 1000).toLocaleString();
}
/**
* Escape HTML special characters to prevent XSS
* @param {string} str - String to escape
* @returns {string} Escaped string
*/
export function escapeHtml(str) {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Convert "about" text to safe HTML with line breaks
* @param {string} text - The about text
* @returns {string} HTML with line breaks
*/
export function aboutToHtml(text) {
if (!text) return "";
return escapeHtml(text).replace(/\n\n/g, "<br>");
}
/**
* Copy text to clipboard with fallback for older browsers
* @param {string} text - Text to copy
* @returns {Promise<boolean>} Whether copy succeeded
*/
export async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
console.error("Failed to copy to clipboard:", error);
// Fallback for older browsers
try {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
return true;
} catch (fallbackError) {
console.error("Fallback copy also failed:", fallbackError);
return false;
}
}
}
/**
* Show copy feedback on a button element
* @param {HTMLElement} button - The button element
* @param {boolean} success - Whether copy succeeded
*/
export function showCopyFeedback(button, success = true) {
if (!button) return;
const originalText = button.textContent;
const originalBg = button.style.backgroundColor;
if (success) {
button.textContent = "";
button.style.backgroundColor = "#4CAF50";
} else {
button.textContent = "L";
button.style.backgroundColor = "#f44336";
}
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = originalBg;
}, 2000);
}
Loading…
Cancel
Save