Browse Source

feat(ui): add standalone dashboard mode and relay switching (v0.52.2)

- Add standalone mode for dashboard to connect to any ORLY relay
- Implement relay switcher dropdown in header for standalone mode
- Add mobile drawer sidebar at 640px breakpoint with hamburger menu
- Add dedicated fallback pool for profile/relay list/contact list fetches
- Fix relay URL display to show host instead of NIP-11 name
- Add filter validation and defensive checks in event fetching
- Auto-expand search window from 30 days to 6 months on few results
- Add CORS support for API endpoints with configurable origins
- Fetch user relay list (NIP-65 kind 10002) and contact list on login
- Fix light mode user name color visibility in header

Files modified:
- app/config/config.go: Add CORS configuration options
- app/server.go: Add CORS middleware for API endpoints
- app/handle-relayinfo.go: Include CORS in relay info
- app/web/src/config.js: New config module for standalone mode
- app/web/src/stores.js: Add relay URL and standalone mode stores
- app/web/src/nostr.js: Add fallback pool, filter validation, relay/contact fetch
- app/web/src/Header.svelte: Relay dropdown, mobile menu, static indicator
- app/web/src/Sidebar.svelte: Mobile drawer mode with overlay
- app/web/src/App.svelte: Relay switching, mobile menu state, NIP-65 fetch
- app/web/src/api.js: Use configurable base URL
- app/web/src/constants.js: Add fallback relays, dynamic relay URLs
- cmd/dashboard-server/main.go: New standalone dashboard server

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
main v0.52.2
woikos 4 months ago
parent
commit
7149cebb2f
No known key found for this signature in database
  1. 234
      POLICY_BUG_FIX_SUMMARY.md
  2. 5
      app/config/config.go
  3. 10
      app/handle-relayinfo.go
  4. 53
      app/server.go
  5. 5
      app/web/bun.lock
  6. 11
      app/web/dist/bundle.css
  7. 30
      app/web/dist/bundle.js
  8. 2
      app/web/dist/bundle.js.map
  9. 2
      app/web/package.json
  10. 12
      app/web/rollup.config.js
  11. 419
      app/web/src/App.svelte
  12. 17
      app/web/src/BlossomView.svelte
  13. 1192
      app/web/src/BunkerView.svelte
  14. 2
      app/web/src/ComposeView.svelte
  15. 5
      app/web/src/CurationView.svelte
  16. 6
      app/web/src/EventsView.svelte
  17. 347
      app/web/src/Header.svelte
  18. 11
      app/web/src/LogView.svelte
  19. 5
      app/web/src/ManagedACL.svelte
  20. 494
      app/web/src/RelayConnectModal.svelte
  21. 23
      app/web/src/RelayConnectView.svelte
  22. 72
      app/web/src/Sidebar.svelte
  23. 58
      app/web/src/api.js
  24. 508
      app/web/src/bunker-service.js
  25. 423
      app/web/src/bunker-worker.js
  26. 310
      app/web/src/config.js
  27. 15
      app/web/src/constants.js
  28. 5
      app/web/src/main.js
  29. 324
      app/web/src/nostr.js
  30. 79
      app/web/src/stores.js
  31. 70
      cmd/dashboard-server/main.go
  32. 2
      pkg/version/version

234
POLICY_BUG_FIX_SUMMARY.md

@ -0,0 +1,234 @@
# Policy System Bug Fix Summary
## Bug Report
**Issue:** Kind 1 events were being accepted even though the policy whitelist only contained kind 4678.
## Root Cause Analysis
The relay had **TWO critical bugs** in the policy system that worked together to create a security vulnerability:
### Bug #1: Hardcoded `return true` in `checkKindsPolicy()`
**Location:** [`pkg/policy/policy.go:1010`](pkg/policy/policy.go#L1010)
```go
// BEFORE (BUG):
// No specific rules (maybe global rule exists) - allow all kinds
return true
// AFTER (FIXED):
// No specific rules (maybe global rule exists) - fall back to default policy
return p.getDefaultPolicyAction()
```
**Problem:** When no whitelist, blacklist, or rules were present, the function returned `true` unconditionally, ignoring the `default_policy` configuration.
**Impact:** Empty policy configurations would allow ALL event kinds.
---
### Bug #2: Silent Failure on Config Load Error
**Location:** [`pkg/policy/policy.go:363-378`](pkg/policy/policy.go#L363-L378)
```go
// BEFORE (BUG):
if err := policy.LoadFromFile(configPath); err != nil {
log.W.F("failed to load policy configuration from %s: %v", configPath, err)
log.I.F("using default policy configuration")
}
// AFTER (FIXED):
if err := policy.LoadFromFile(configPath); err != nil {
log.E.F("FATAL: Policy system is ENABLED (ORLY_POLICY_ENABLED=true) but configuration failed to load from %s: %v", configPath, err)
log.E.F("The relay cannot start with an invalid policy configuration.")
log.E.F("Fix: Either disable the policy system (ORLY_POLICY_ENABLED=false) or ensure %s exists and contains valid JSON", configPath)
panic(fmt.Sprintf("fatal policy configuration error: %v", err))
}
```
**Problem:** When policy was enabled but `policy.json` failed to load:
- Only logged a WARNING (not fatal)
- Continued with empty policy object (no whitelist, no rules)
- Empty policy + Bug #1 = allowed ALL events
- Relay appeared to be "protected" but was actually wide open
**Impact:** **Critical security vulnerability** - misconfigured policy files would silently allow all events.
---
## Combined Effect
When a relay operator:
1. Enabled policy system (`ORLY_POLICY_ENABLED=true`)
2. Had a missing, malformed, or inaccessible `policy.json` file
The relay would:
- ❌ Log "policy allowed event" (appearing to work)
- ❌ Have empty whitelist/rules (silent failure)
- ❌ Fall through to hardcoded `return true` (Bug #1)
- ✅ **Allow ALL event kinds** (complete bypass)
---
## Fixes Applied
### Fix #1: Respect `default_policy` Setting
Changed `checkKindsPolicy()` to return `p.getDefaultPolicyAction()` instead of hardcoded `true`.
**Result:** When no whitelist/rules exist, the policy respects the `default_policy` configuration (either "allow" or "deny").
### Fix #2: Fail-Fast on Config Error
Changed `NewWithManager()` to **panic immediately** if policy is enabled but config fails to load.
**Result:** Relay refuses to start with invalid configuration, forcing operator to fix it.
---
## Test Coverage
### New Tests Added
1. **`TestBugFix_FailSafeWhenConfigMissing`** - Verifies panic on missing config
2. **`TestBugFix_EmptyWhitelistRespectsDefaultPolicy`** - Tests both deny and allow defaults
3. **`TestBugReproduction_*`** - Reproduces the exact scenario from the bug report
### Existing Tests Updated
- **`TestNewWithManager`** - Now handles both enabled and disabled policy scenarios
- All existing whitelist tests continue to pass ✅
---
## Behavior Changes
### Before Fix
```
Policy System: ENABLED ✅
Config File: MISSING ❌
Logs: "failed to load policy configuration" (warning)
Result: Allow ALL events 🚨
Policy System: ENABLED ✅
Config File: { "whitelist": [4678] } ✅
Logs: "policy allowed event" for kind 1
Result: Allow kind 1 event 🚨
```
### After Fix
```
Policy System: ENABLED ✅
Config File: MISSING ❌
Result: PANIC - relay refuses to start 🛑
Policy System: ENABLED ✅
Config File: { "whitelist": [4678] } ✅
Logs: "policy rejected event" for kind 1
Result: Reject kind 1 event ✅
```
---
## Migration Guide for Operators
### If Your Relay Panics After Upgrade
**Error Message:**
```
FATAL: Policy system is ENABLED (ORLY_POLICY_ENABLED=true) but configuration failed to load
panic: fatal policy configuration error: policy configuration file does not exist
```
**Resolution Options:**
1. **Create valid `policy.json`:**
```bash
mkdir -p ~/.config/ORLY
cat > ~/.config/ORLY/policy.json << 'EOF'
{
"default_policy": "allow",
"kind": {
"whitelist": [1, 3, 4, 5, 6, 7]
},
"rules": {}
}
EOF
```
2. **Disable policy system (temporary):**
```bash
# In your systemd service file:
Environment="ORLY_POLICY_ENABLED=false"
sudo systemctl daemon-reload
sudo systemctl restart orly
```
---
## Security Impact
**Severity:** 🔴 **CRITICAL**
**CVE-Like Description:**
> When `ORLY_POLICY_ENABLED=true` is set but the policy configuration file fails to load (missing file, permission error, or malformed JSON), the relay silently bypasses all policy checks and allows events of any kind, defeating the intended access control mechanism.
**Affected Versions:** All versions prior to this fix
**Fixed Versions:** Current HEAD after commit [TBD]
**CVSS-like:** Configuration-dependent vulnerability requiring operator misconfiguration
---
## Verification
To verify the fix is working:
1. **Test with valid config:**
```bash
# Should start normally
ORLY_POLICY_ENABLED=true ./orly
# Logs: "loaded policy configuration from ~/.config/ORLY/policy.json"
```
2. **Test with missing config:**
```bash
# Should panic immediately
mv ~/.config/ORLY/policy.json ~/.config/ORLY/policy.json.bak
ORLY_POLICY_ENABLED=true ./orly
# Expected: FATAL error and panic
```
3. **Test whitelist enforcement:**
```bash
# Create whitelist with only kind 4678
echo '{"kind":{"whitelist":[4678]},"rules":{}}' > ~/.config/ORLY/policy.json
# Try to send kind 1 event
# Expected: "policy rejected event" or "event blocked by policy"
```
---
## Files Modified
- [`pkg/policy/policy.go`](pkg/policy/policy.go) - Core fixes
- [`pkg/policy/bug_reproduction_test.go`](pkg/policy/bug_reproduction_test.go) - New test file
- [`pkg/policy/policy_test.go`](pkg/policy/policy_test.go) - Updated existing tests
---
## Related Documentation
- [Policy Usage Guide](docs/POLICY_USAGE_GUIDE.md)
- [Policy Troubleshooting](docs/POLICY_TROUBLESHOOTING.md)
- [CLAUDE.md](CLAUDE.md) - Build and configuration instructions
---
## Credits
**Bug Reported By:** User via client relay (relay1.zenotp.app)
**Root Cause Analysis:** Deep investigation of policy evaluation flow
**Fix Verified:** All tests passing, including reproduction of original bug scenario

5
app/config/config.go

@ -88,6 +88,11 @@ type C struct {
// Branding/white-label settings // Branding/white-label settings
BrandingDir string `env:"ORLY_BRANDING_DIR" usage:"directory containing branding assets and configuration (default: ~/.config/ORLY/branding)"` BrandingDir string `env:"ORLY_BRANDING_DIR" usage:"directory containing branding assets and configuration (default: ~/.config/ORLY/branding)"`
BrandingEnabled bool `env:"ORLY_BRANDING_ENABLED" default:"true" usage:"enable custom branding if branding directory exists"` BrandingEnabled bool `env:"ORLY_BRANDING_ENABLED" default:"true" usage:"enable custom branding if branding directory exists"`
Theme string `env:"ORLY_THEME" default:"auto" usage:"UI color theme: auto (follow system), light, dark"`
// CORS settings (for standalone dashboard mode)
CORSEnabled bool `env:"ORLY_CORS_ENABLED" default:"false" usage:"enable CORS headers for API endpoints (required for standalone dashboard)"`
CORSOrigins []string `env:"ORLY_CORS_ORIGINS" usage:"allowed CORS origins (comma-separated, or * for all origins)"`
// Sprocket settings // Sprocket settings
SprocketEnabled bool `env:"ORLY_SPROCKET_ENABLED" default:"false" usage:"enable sprocket event processing plugin system"` SprocketEnabled bool `env:"ORLY_SPROCKET_ENABLED" default:"false" usage:"enable sprocket event processing plugin system"`

10
app/handle-relayinfo.go

@ -29,6 +29,7 @@ type ExtendedRelayInfo struct {
*relayinfo.T *relayinfo.T
Addresses []string `json:"addresses,omitempty"` Addresses []string `json:"addresses,omitempty"`
GraphQuery *GraphQueryConfig `json:"graph_query,omitempty"` GraphQuery *GraphQueryConfig `json:"graph_query,omitempty"`
Theme string `json:"theme,omitempty"`
} }
// HandleRelayInfo generates and returns a relay information document in JSON // HandleRelayInfo generates and returns a relay information document in JSON
@ -203,12 +204,17 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
} }
} }
// Return extended info if we have addresses or graph query support, otherwise standard info // Return extended info if we have addresses, graph query support, or custom theme
if len(addresses) > 0 || graphConfig != nil { theme := s.Config.Theme
if theme != "auto" && theme != "light" && theme != "dark" {
theme = "auto"
}
if len(addresses) > 0 || graphConfig != nil || theme != "auto" {
extInfo := &ExtendedRelayInfo{ extInfo := &ExtendedRelayInfo{
T: info, T: info,
Addresses: addresses, Addresses: addresses,
GraphQuery: graphConfig, GraphQuery: graphConfig,
Theme: theme,
} }
if err := json.NewEncoder(w).Encode(extInfo); chk.E(err) { if err := json.NewEncoder(w).Encode(extInfo); chk.E(err) {
} }

53
app/server.go

@ -141,6 +141,32 @@ func (s *Server) isIPBlacklisted(remote string) bool {
return false return false
} }
// isAllowedCORSOrigin checks if the given origin is allowed for CORS requests.
// Returns true if:
// - CORSOrigins contains "*" (allow all)
// - CORSOrigins is empty (allow all when CORS is enabled)
// - The origin matches one of the configured origins
func (s *Server) isAllowedCORSOrigin(origin string) bool {
if origin == "" {
return false
}
// If no specific origins configured, allow all
if len(s.Config.CORSOrigins) == 0 {
return true
}
for _, allowed := range s.Config.CORSOrigins {
if allowed == "*" {
return true
}
if allowed == origin {
return true
}
}
return false
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check if this is a blossom-related path (needs CORS headers) // Check if this is a blossom-related path (needs CORS headers)
path := r.URL.Path path := r.URL.Path
@ -163,8 +189,31 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
} else if r.Method == "OPTIONS" { }
// Handle OPTIONS for non-blossom paths
// Set CORS headers for API endpoints when enabled (for standalone dashboard mode)
// Also allow root path for NIP-11 relay info requests
isAPIPath := strings.HasPrefix(path, "/api/")
isRelayInfoRequest := path == "/" && r.Header.Get("Accept") == "application/nostr+json"
if s.Config != nil && s.Config.CORSEnabled && (isAPIPath || isRelayInfoRequest) {
origin := r.Header.Get("Origin")
if s.isAllowedCORSOrigin(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
}
// Handle preflight OPTIONS requests for API paths
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
}
if r.Method == "OPTIONS" && !isBlossomPath && !isAPIPath {
// Handle OPTIONS for other paths
if s.mux != nil { if s.mux != nil {
s.mux.ServeHTTP(w, r) s.mux.ServeHTTP(w, r)
return return

5
app/web/bun.lock

@ -17,6 +17,7 @@
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^24.0.0", "@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.0", "@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-replace": "^5.0.0",
"@rollup/plugin-terser": "^0.4.0", "@rollup/plugin-terser": "^0.4.0",
"rollup": "^3.15.0", "rollup": "^3.15.0",
"rollup-plugin-copy": "^3.5.0", "rollup-plugin-copy": "^3.5.0",
@ -58,6 +59,8 @@
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@15.3.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA=="], "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@15.3.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA=="],
"@rollup/plugin-replace": ["@rollup/plugin-replace@5.0.7", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-PqxSfuorkHz/SPpyngLyg5GCEkOcee9M1bkxiVDr41Pd61mqP1PLOoDPbpl44SB2mQGKwV/In74gqQmGITOhEQ=="],
"@rollup/plugin-terser": ["@rollup/plugin-terser@0.4.4", "", { "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", "terser": "^5.17.4" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A=="], "@rollup/plugin-terser": ["@rollup/plugin-terser@0.4.4", "", { "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", "terser": "^5.17.4" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
@ -346,6 +349,8 @@
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"@rollup/plugin-replace/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], "@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="],
"@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],

11
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

30
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

2
app/web/package.json

@ -7,12 +7,14 @@
"fetch-kinds": "node scripts/fetch-kinds.js", "fetch-kinds": "node scripts/fetch-kinds.js",
"prebuild": "npm run fetch-kinds", "prebuild": "npm run fetch-kinds",
"build": "rollup -c", "build": "rollup -c",
"build:standalone": "STANDALONE_MODE=true rollup -c",
"dev": "rollup -c -w", "dev": "rollup -c -w",
"start": "sirv public --no-clear --single" "start": "sirv public --no-clear --single"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^24.0.0", "@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.0", "@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-replace": "^5.0.0",
"@rollup/plugin-terser": "^0.4.0", "@rollup/plugin-terser": "^0.4.0",
"rollup": "^3.15.0", "rollup": "^3.15.0",
"rollup-plugin-copy": "^3.5.0", "rollup-plugin-copy": "^3.5.0",

12
app/web/rollup.config.js

@ -6,9 +6,14 @@ import resolve from "@rollup/plugin-node-resolve";
import livereload from "rollup-plugin-livereload"; import livereload from "rollup-plugin-livereload";
import css from "rollup-plugin-css-only"; import css from "rollup-plugin-css-only";
import copy from "rollup-plugin-copy"; import copy from "rollup-plugin-copy";
import replace from "@rollup/plugin-replace";
const production = !process.env.ROLLUP_WATCH; const production = !process.env.ROLLUP_WATCH;
// Standalone mode configuration (for dashboard that connects to remote relay)
const standaloneMode = process.env.STANDALONE_MODE === 'true';
const defaultRelayUrl = process.env.DEFAULT_RELAY_URL || '';
// In dev mode, output to public/ so sirv can serve it // In dev mode, output to public/ so sirv can serve it
// In production, output to dist/ for embedding // In production, output to dist/ for embedding
const outputDir = production ? "dist" : "public"; const outputDir = production ? "dist" : "public";
@ -43,6 +48,13 @@ export default {
file: `${outputDir}/bundle.js`, file: `${outputDir}/bundle.js`,
}, },
plugins: [ plugins: [
// Replace environment variables at build time (for standalone mode)
replace({
preventAssignment: true,
'process.env.STANDALONE_MODE': JSON.stringify(standaloneMode ? 'true' : 'false'),
'process.env.DEFAULT_RELAY_URL': JSON.stringify(defaultRelayUrl),
}),
svelte({ svelte({
compilerOptions: { compilerOptions: {
// enable run-time checks when not in production // enable run-time checks when not in production

419
app/web/src/App.svelte

@ -17,6 +17,11 @@
import RelayConnectView from "./RelayConnectView.svelte"; import RelayConnectView from "./RelayConnectView.svelte";
import SearchResultsView from "./SearchResultsView.svelte"; import SearchResultsView from "./SearchResultsView.svelte";
import FilterDisplay from "./FilterDisplay.svelte"; import FilterDisplay from "./FilterDisplay.svelte";
import RelayConnectModal from "./RelayConnectModal.svelte";
// Relay config imports
import { isStandalone, hasRelayConfigured, fetchRelayInfoFromUrl, getApiBase } from "./config.js";
import { isStandaloneMode, relayUrl, relayInfo as relayInfoStore, relayConnectionStatus, isOrlyRelay } from "./stores.js";
// Utility imports // Utility imports
import { buildFilter } from "./helpers.tsx"; import { buildFilter } from "./helpers.tsx";
@ -28,6 +33,8 @@
import { import {
initializeNostrClient, initializeNostrClient,
fetchUserProfile, fetchUserProfile,
fetchUserRelayList,
fetchUserContactList,
fetchAllEvents, fetchAllEvents,
fetchUserEvents, fetchUserEvents,
searchEvents, searchEvents,
@ -37,6 +44,7 @@
queryEvents, queryEvents,
queryEventsFromDB, queryEventsFromDB,
debugIndexedDB, debugIndexedDB,
clearIndexedDBCache,
nostrClient, nostrClient,
NostrClient, NostrClient,
Nip07Signer, Nip07Signer,
@ -51,13 +59,17 @@
let isDarkTheme = false; let isDarkTheme = false;
let showLoginModal = false; let showLoginModal = false;
let showRelayConnectModal = false;
let isLoggedIn = false; let isLoggedIn = false;
let userPubkey = ""; let userPubkey = "";
let authMethod = ""; let authMethod = "";
let userProfile = null; let userProfile = null;
let userRelayList = null;
let userContactList = null;
let userRole = ""; let userRole = "";
let userSigner = null; let userSigner = null;
let showSettingsDrawer = false; let showSettingsDrawer = false;
let mobileMenuOpen = false;
let selectedTab = localStorage.getItem("selectedTab") || "export"; let selectedTab = localStorage.getItem("selectedTab") || "export";
let showFilterBuilder = false; // Show filter builder in events view let showFilterBuilder = false; // Show filter builder in events view
let eventsViewFilter = {}; // Active filter for events view let eventsViewFilter = {}; // Active filter for events view
@ -828,15 +840,33 @@
? escapeHtml(userProfile.about).replace(/\n{2,}/g, "<br>") ? escapeHtml(userProfile.about).replace(/\n{2,}/g, "<br>")
: ""; : "";
// Theme configuration: "auto" follows system, "light"/"dark" are forced
let configuredTheme = "auto";
// Detect system theme preference and listen for changes // Detect system theme preference and listen for changes
if (typeof window !== "undefined" && window.matchMedia) { if (typeof window !== "undefined" && window.matchMedia) {
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
isDarkTheme = darkModeQuery.matches; isDarkTheme = darkModeQuery.matches;
// Listen for system theme changes // Listen for system theme changes (only applies when theme is "auto")
darkModeQuery.addEventListener("change", (e) => { darkModeQuery.addEventListener("change", (e) => {
if (configuredTheme === "auto") {
isDarkTheme = e.matches; isDarkTheme = e.matches;
}
}); });
// Fetch relay info to get configured theme
(async () => {
try {
const relayInfo = await api.fetchRelayInfo();
if (relayInfo?.theme && relayInfo.theme !== "auto") {
configuredTheme = relayInfo.theme;
isDarkTheme = relayInfo.theme === "dark";
}
} catch (e) {
console.log("Could not fetch relay theme config:", e);
}
})();
} }
// Load state from localStorage // Load state from localStorage
@ -854,15 +884,25 @@
if (storedAuthMethod === "extension" && window.nostr) { if (storedAuthMethod === "extension" && window.nostr) {
userSigner = window.nostr; userSigner = window.nostr;
} }
// Fetch user role for already logged in users
fetchUserRole();
fetchACLMode();
} }
// Load persistent app state // Load persistent app state
loadPersistentState(); loadPersistentState();
// Initialize relay connection first
// In standalone mode without a relay, this will show the modal
// and skip the API calls until a relay is connected
initializeRelayConnection();
}
// Load relay-dependent data (called after relay is confirmed)
async function loadRelayData() {
// Fetch user role for already logged in users
if (isLoggedIn) {
fetchUserRole();
}
fetchACLMode();
// Load sprocket configuration // Load sprocket configuration
loadSprocketConfig(); loadSprocketConfig();
@ -876,6 +916,90 @@
fetchRelayVersion(); fetchRelayVersion();
} }
// Handle relay change from header dropdown
async function handleRelayChange(event) {
console.log("Relay changed:", event.detail?.info?.name);
// Reset the NostrClient to use new relay
nostrClient.reset();
// Clear IndexedDB cache (contains events from old relay)
await clearIndexedDBCache();
// Clear the events cache when switching relays
globalEventsCache = [];
globalCacheTimestamp = 0;
hasAttemptedEventLoad = false;
// Clear displayed events
allEvents = [];
myEvents = [];
// Reset pagination state
hasMoreEvents = true;
hasMoreMyEvents = true;
oldestEventTimestamp = null;
newestEventTimestamp = null;
// Clear search results
searchResults.clear();
searchTabs = [];
// Reload all relay-dependent data
loadRelayData();
// If the events tab is currently active, reload events
if (selectedTab === "events" && isLoggedIn) {
loadAllEvents(true);
} else if (selectedTab === "myevents" && isLoggedIn) {
loadMyEvents(true);
}
}
// Initialize relay connection
// In standalone mode with no relay configured, show the connection modal
// Otherwise, try to connect to the configured relay
async function initializeRelayConnection() {
if (isStandalone()) {
if (!hasRelayConfigured()) {
// No relay configured - show the connection modal
// Don't load relay data yet
showRelayConnectModal = true;
return;
} else {
// Try to fetch relay info to verify connection
await fetchRelayInfoFromUrl();
}
} else {
// Embedded mode - fetch relay info from same origin
await fetchRelayInfoFromUrl();
}
// Relay is configured/connected - load relay-dependent data
await loadRelayData();
}
function openRelayConnectModal() {
showRelayConnectModal = true;
}
function closeRelayConnectModal() {
showRelayConnectModal = false;
}
async function handleRelayConnected(event) {
// Relay connected successfully - reload data
console.log("Connected to relay:", event.detail?.info?.name);
// Refresh nostr client with new relay
if (nostrClient) {
nostrClient.refreshRelays();
}
// Load all relay-dependent data
await loadRelayData();
}
function savePersistentState() { function savePersistentState() {
if (typeof localStorage === "undefined") return; if (typeof localStorage === "undefined") return;
@ -976,7 +1100,7 @@
// Sprocket management functions // Sprocket management functions
async function loadSprocketConfig() { async function loadSprocketConfig() {
try { try {
const response = await fetch("/api/sprocket/config", { const response = await fetch(`${getApiBase()}/api/sprocket/config`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -986,9 +1110,13 @@
if (response.ok) { if (response.ok) {
const config = await response.json(); const config = await response.json();
sprocketEnabled = config.enabled; sprocketEnabled = config.enabled;
} else if (response.status === 404) {
// Non-ORLY relay - sprocket not available
sprocketEnabled = false;
} }
} catch (error) { } catch (error) {
console.error("Error loading sprocket config:", error); // Non-ORLY relay or network error - sprocket not available
sprocketEnabled = false;
} }
} }
@ -997,13 +1125,14 @@
const config = await api.fetchNRCConfig(); const config = await api.fetchNRCConfig();
nrcEnabled = config.enabled; nrcEnabled = config.enabled;
} catch (error) { } catch (error) {
console.error("Error loading NRC config:", error); // Non-ORLY relay or network error - NRC not available
nrcEnabled = false;
} }
} }
async function loadPolicyConfig() { async function loadPolicyConfig() {
try { try {
const response = await fetch("/api/policy/config", { const response = await fetch(`${getApiBase()}/api/policy/config`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -1013,9 +1142,12 @@
if (response.ok) { if (response.ok) {
const config = await response.json(); const config = await response.json();
policyEnabled = config.enabled || false; policyEnabled = config.enabled || false;
} else if (response.status === 404) {
// Non-ORLY relay - policy not available
policyEnabled = false;
} }
} catch (error) { } catch (error) {
console.error("Error loading policy config:", error); // Non-ORLY relay or network error - policy not available
policyEnabled = false; policyEnabled = false;
} }
} }
@ -1025,10 +1157,10 @@
try { try {
isLoadingSprocket = true; isLoadingSprocket = true;
const response = await fetch("/api/sprocket/status", { const response = await fetch(`${getApiBase()}/api/sprocket/status`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Nostr ${await createNIP98Auth("GET", "/api/sprocket/status")}`, Authorization: `Nostr ${await createNIP98Auth("GET", `${getApiBase()}/api/sprocket/status`)}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
@ -1053,10 +1185,10 @@
try { try {
isLoadingSprocket = true; isLoadingSprocket = true;
const response = await fetch("/api/sprocket/status", { const response = await fetch(`${getApiBase()}/api/sprocket/status`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Nostr ${await createNIP98Auth("GET", "/api/sprocket/status")}`, Authorization: `Nostr ${await createNIP98Auth("GET", `${getApiBase()}/api/sprocket/status`)}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
@ -1084,10 +1216,10 @@
try { try {
isLoadingSprocket = true; isLoadingSprocket = true;
const response = await fetch("/api/sprocket/update", { const response = await fetch(`${getApiBase()}/api/sprocket/update`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/update")}`, Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/update`)}`,
"Content-Type": "text/plain", "Content-Type": "text/plain",
}, },
body: sprocketScript, body: sprocketScript,
@ -1122,10 +1254,10 @@
try { try {
isLoadingSprocket = true; isLoadingSprocket = true;
const response = await fetch("/api/sprocket/restart", { const response = await fetch(`${getApiBase()}/api/sprocket/restart`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/restart")}`, Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/restart`)}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
@ -1166,10 +1298,10 @@
try { try {
isLoadingSprocket = true; isLoadingSprocket = true;
const response = await fetch("/api/sprocket/update", { const response = await fetch(`${getApiBase()}/api/sprocket/update`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/update")}`, Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/update`)}`,
"Content-Type": "text/plain", "Content-Type": "text/plain",
}, },
body: "", // Empty body deletes the script body: "", // Empty body deletes the script
@ -1205,10 +1337,10 @@
try { try {
isLoadingSprocket = true; isLoadingSprocket = true;
const response = await fetch("/api/sprocket/versions", { const response = await fetch(`${getApiBase()}/api/sprocket/versions`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Nostr ${await createNIP98Auth("GET", "/api/sprocket/versions")}`, Authorization: `Nostr ${await createNIP98Auth("GET", `${getApiBase()}/api/sprocket/versions`)}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
@ -1244,10 +1376,10 @@
try { try {
isLoadingSprocket = true; isLoadingSprocket = true;
const response = await fetch("/api/sprocket/delete-version", { const response = await fetch(`${getApiBase()}/api/sprocket/delete-version`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/delete-version")}`, Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/delete-version`)}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ filename }), body: JSON.stringify({ filename }),
@ -1321,10 +1453,10 @@
showPolicyMessage("Policy loaded successfully", "success"); showPolicyMessage("Policy loaded successfully", "success");
} else { } else {
// No policy event found, try to load from file via API // No policy event found, try to load from file via API
const response = await fetch("/api/policy", { const response = await fetch(`${getApiBase()}/api/policy`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Nostr ${await createNIP98Auth("GET", "/api/policy")}`, Authorization: `Nostr ${await createNIP98Auth("GET", `${getApiBase()}/api/policy`)}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
@ -1621,10 +1753,10 @@
const fileContent = await sprocketUploadFile.text(); const fileContent = await sprocketUploadFile.text();
// Upload the script // Upload the script
const response = await fetch("/api/sprocket/update", { const response = await fetch(`${getApiBase()}/api/sprocket/update`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/update")}`, Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/update`)}`,
"Content-Type": "text/plain", "Content-Type": "text/plain",
}, },
body: fileContent, body: fileContent,
@ -1703,6 +1835,11 @@
if (tab.requiresWrite && (!isLoggedIn || currentRole === "read")) { if (tab.requiresWrite && (!isLoggedIn || currentRole === "read")) {
return false; return false;
} }
// Hide ORLY-specific tabs when connected to a non-ORLY relay
const orlyOnlyTabs = ["sprocket", "policy", "managed-acl", "curation", "logs", "relay-connect"];
if (orlyOnlyTabs.includes(tab.id) && !$isOrlyRelay) {
return false;
}
// Hide sprocket tab if not enabled // Hide sprocket tab if not enabled
if (tab.id === "sprocket" && !sprocketEnabled) { if (tab.id === "sprocket" && !sprocketEnabled) {
return false; return false;
@ -1795,6 +1932,17 @@
userProfile = await fetchUserProfile(pubkey); userProfile = await fetchUserProfile(pubkey);
console.log("Profile loaded:", userProfile); console.log("Profile loaded:", userProfile);
// Fetch user's relay list (NIP-65) and contact list
userRelayList = await fetchUserRelayList(pubkey);
if (userRelayList) {
console.log("User relay list loaded:", userRelayList.all.length, "relays");
}
userContactList = await fetchUserContactList(pubkey);
if (userContactList) {
console.log("User contact list loaded:", userContactList.follows.length, "follows");
}
} catch (error) { } catch (error) {
console.error("Failed to load profile:", error); console.error("Failed to load profile:", error);
} }
@ -1809,6 +1957,8 @@
userPubkey = ""; userPubkey = "";
authMethod = ""; authMethod = "";
userProfile = null; userProfile = null;
userRelayList = null;
userContactList = null;
userRole = ""; userRole = "";
userSigner = null; userSigner = null;
userPrivkey = null; userPrivkey = null;
@ -1841,6 +1991,14 @@
showSettingsDrawer = false; showSettingsDrawer = false;
} }
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
function closeMobileMenu() {
mobileMenuOpen = false;
}
function toggleFilterBuilder() { function toggleFilterBuilder() {
showFilterBuilder = !showFilterBuilder; showFilterBuilder = !showFilterBuilder;
} }
@ -2028,36 +2186,81 @@
} }
try { try {
const response = await fetch(`/api/permissions/${userPubkey}`); const url = `${getApiBase()}/api/permissions/${userPubkey}`;
const response = await fetch(url);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
userRole = data.permission || ""; userRole = data.permission || "";
isOrlyRelay.set(true);
console.log("User role loaded:", userRole); console.log("User role loaded:", userRole);
console.log("Is owner?", userRole === "owner"); console.log("Is owner?", userRole === "owner");
} else if (response.status === 404) {
// Non-ORLY relay - fallback to write permission based on NIP-11
console.log("ORLY API not available, using NIP-11 fallback");
isOrlyRelay.set(false);
userRole = determineRoleFromNIP11();
} else { } else {
console.error("Failed to fetch user role:", response.status); console.error("Failed to fetch user role:", response.status);
userRole = ""; userRole = "";
} }
} catch (error) { } catch (error) {
console.error("Error fetching user role:", error); // Network error or non-ORLY relay - fallback to NIP-11
userRole = ""; console.log("Error fetching user role, using NIP-11 fallback:", error.message);
isOrlyRelay.set(false);
userRole = determineRoleFromNIP11();
} }
} }
/**
* Determine user role from NIP-11 relay info when ORLY API is not available.
* For generic relays, assume write permission unless NIP-11 indicates restrictions.
*/
function determineRoleFromNIP11() {
const info = $relayInfoStore;
if (!info) {
// No relay info, assume write access
return "write";
}
// Check NIP-11 limitation fields
const limitation = info.limitation || {};
// If auth_required is true and we're logged in, assume write
// If auth_required is false or not set, also assume write
if (limitation.auth_required === false || !limitation.auth_required) {
console.log("NIP-11: No auth required, granting write access");
return "write";
}
// If we're logged in and relay requires auth, assume write
if (isLoggedIn) {
console.log("NIP-11: Auth required and user is logged in, granting write access");
return "write";
}
// Otherwise, read-only
return "read";
}
async function fetchACLMode() { async function fetchACLMode() {
try { try {
const response = await fetch("/api/acl-mode"); const response = await fetch(`${getApiBase()}/api/acl-mode`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
aclMode = data.acl_mode || ""; aclMode = data.acl_mode || "";
console.log("ACL mode loaded:", aclMode); console.log("ACL mode loaded:", aclMode);
} else if (response.status === 404) {
// Non-ORLY relay - default to "none" (open relay mode)
console.log("ACL API not available, defaulting to 'none'");
aclMode = "none";
} else { } else {
console.error("Failed to fetch ACL mode:", response.status); console.error("Failed to fetch ACL mode:", response.status);
aclMode = ""; aclMode = "";
} }
} catch (error) { } catch (error) {
console.error("Error fetching ACL mode:", error); // Non-ORLY relay - default to "none"
aclMode = ""; console.log("Error fetching ACL mode, defaulting to 'none':", error.message);
aclMode = "none";
} }
} }
@ -2099,11 +2302,11 @@
}; };
if (aclMode !== "none" && isLoggedIn) { if (aclMode !== "none" && isLoggedIn) {
headers.Authorization = await createNIP98AuthHeader( headers.Authorization = await createNIP98AuthHeader(
"/api/export", `${getApiBase()}/api/export`,
"POST", "POST",
); );
} }
const response = await fetch("/api/export", { const response = await fetch(`${getApiBase()}/api/export`, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ pubkeys }), body: JSON.stringify({ pubkeys }),
@ -2180,14 +2383,14 @@
const headers = {}; const headers = {};
if (aclMode !== "none" && isLoggedIn) { if (aclMode !== "none" && isLoggedIn) {
headers.Authorization = await createNIP98AuthHeader( headers.Authorization = await createNIP98AuthHeader(
"/api/import", `${getApiBase()}/api/import`,
"POST", "POST",
); );
} }
const formData = new FormData(); const formData = new FormData();
formData.append("file", selectedFile); formData.append("file", selectedFile);
const response = await fetch("/api/import", { const response = await fetch(`${getApiBase()}/api/import`, {
method: "POST", method: "POST",
headers, headers,
body: formData, body: formData,
@ -2480,7 +2683,7 @@
kind: 27235, kind: 27235,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
["u", window.location.origin + url], ["u", url], // URL should already be absolute
["method", method.toUpperCase()], ["method", method.toUpperCase()],
], ],
content: "", content: "",
@ -2526,7 +2729,7 @@
kind: 27235, kind: 27235,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
["u", window.location.origin + url], ["u", url], // URL should already be absolute
["method", method.toUpperCase()], ["method", method.toUpperCase()],
], ],
content: "", content: "",
@ -2780,6 +2983,9 @@
{userPubkey} {userPubkey}
on:openSettingsDrawer={openSettingsDrawer} on:openSettingsDrawer={openSettingsDrawer}
on:openLoginModal={openLoginModal} on:openLoginModal={openLoginModal}
on:openRelayModal={openRelayConnectModal}
on:relayChanged={handleRelayChange}
on:toggleMobileMenu={toggleMobileMenu}
/> />
<!-- Main Content Area --> <!-- Main Content Area -->
@ -2790,8 +2996,10 @@
{tabs} {tabs}
{selectedTab} {selectedTab}
version={relayVersion} version={relayVersion}
mobileOpen={mobileMenuOpen}
on:selectTab={(e) => selectTab(e.detail)} on:selectTab={(e) => selectTab(e.detail)}
on:closeSearchTab={(e) => closeSearchTab(e.detail)} on:closeSearchTab={(e) => closeSearchTab(e.detail)}
on:closeMobileMenu={closeMobileMenu}
/> />
<!-- Main Content --> <!-- Main Content -->
@ -2839,6 +3047,7 @@
on:filterClear={handleFilterClear} on:filterClear={handleFilterClear}
/> />
{:else if selectedTab === "blossom"} {:else if selectedTab === "blossom"}
{#key $relayUrl}
<BlossomView <BlossomView
{isLoggedIn} {isLoggedIn}
{userPubkey} {userPubkey}
@ -2846,6 +3055,7 @@
{currentEffectiveRole} {currentEffectiveRole}
on:openLoginModal={openLoginModal} on:openLoginModal={openLoginModal}
/> />
{/key}
{:else if selectedTab === "compose"} {:else if selectedTab === "compose"}
<ComposeView <ComposeView
bind:composeEventJson bind:composeEventJson
@ -2879,7 +3089,9 @@
</p> </p>
</div> </div>
{:else if isLoggedIn && userRole === "owner"} {:else if isLoggedIn && userRole === "owner"}
{#key $relayUrl}
<ManagedACL {userSigner} {userPubkey} /> <ManagedACL {userSigner} {userPubkey} />
{/key}
{:else} {:else}
<div class="access-denied"> <div class="access-denied">
<p> <p>
@ -2913,7 +3125,9 @@
</p> </p>
</div> </div>
{:else if isLoggedIn && userRole === "owner"} {:else if isLoggedIn && userRole === "owner"}
{#key $relayUrl}
<CurationView {userSigner} {userPubkey} /> <CurationView {userSigner} {userPubkey} />
{/key}
{:else} {:else}
<div class="access-denied"> <div class="access-denied">
<p> <p>
@ -2970,6 +3184,7 @@
on:openLoginModal={openLoginModal} on:openLoginModal={openLoginModal}
/> />
{:else if selectedTab === "relay-connect"} {:else if selectedTab === "relay-connect"}
{#key $relayUrl}
<RelayConnectView <RelayConnectView
{isLoggedIn} {isLoggedIn}
{userRole} {userRole}
@ -2977,13 +3192,16 @@
{userPubkey} {userPubkey}
on:openLoginModal={openLoginModal} on:openLoginModal={openLoginModal}
/> />
{/key}
{:else if selectedTab === "logs"} {:else if selectedTab === "logs"}
{#key $relayUrl}
<LogView <LogView
{isLoggedIn} {isLoggedIn}
{userRole} {userRole}
{userSigner} {userSigner}
on:openLoginModal={openLoginModal} on:openLoginModal={openLoginModal}
/> />
{/key}
{:else if selectedTab === "recovery"} {:else if selectedTab === "recovery"}
<div class="recovery-tab"> <div class="recovery-tab">
<div> <div>
@ -3406,7 +3624,30 @@
</div> </div>
</div> </div>
{/if} {/if}
<!-- Additional settings can be added here -->
<!-- Relay Connection Section (for standalone mode) -->
{#if $isStandaloneMode}
<div class="relay-section">
<h3>Connected Relay</h3>
{#if $relayInfoStore}
<div class="relay-info-card">
<div class="relay-name">{$relayInfoStore.name || "Unknown relay"}</div>
{#if $relayInfoStore.description}
<div class="relay-description">{$relayInfoStore.description}</div>
{/if}
<div class="relay-url">{$relayUrl}</div>
</div>
{:else}
<div class="relay-disconnected">
<span class="status-dot disconnected"></span>
Not connected
</div>
{/if}
<button class="change-relay-btn" on:click={() => { closeSettingsDrawer(); openRelayConnectModal(); }}>
{$relayInfoStore ? "Change Relay" : "Connect to Relay"}
</button>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -3420,6 +3661,14 @@
on:close={closeLoginModal} on:close={closeLoginModal}
/> />
<!-- Relay Connect Modal (for standalone mode) -->
<RelayConnectModal
bind:showModal={showRelayConnectModal}
{isDarkTheme}
on:connected={handleRelayConnected}
on:close={closeRelayConnectModal}
/>
<style> <style>
:global(html), :global(html),
:global(body) { :global(body) {
@ -3600,13 +3849,6 @@
font-size: 1.2rem; font-size: 1.2rem;
} }
@media (max-width: 640px) {
.main-content {
left: 160px;
padding: 1rem;
}
}
.logout-btn { .logout-btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
@ -4201,6 +4443,14 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.main-content {
left: 0;
}
.search-results-view {
left: 0;
}
.settings-drawer { .settings-drawer {
width: 100%; width: 100%;
} }
@ -4458,4 +4708,77 @@
background: var(--header-bg); background: var(--header-bg);
border: none; border: none;
} }
/* Relay connection section in settings drawer */
.relay-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
}
.relay-section h3 {
margin: 0 0 12px 0;
color: var(--text-color);
font-size: 1.1rem;
}
.relay-info-card {
background: var(--muted);
padding: 12px;
border-radius: 8px;
margin-bottom: 12px;
}
.relay-name {
font-weight: 600;
color: var(--text-color);
margin-bottom: 4px;
}
.relay-description {
font-size: 0.9rem;
color: var(--muted-foreground);
margin-bottom: 8px;
}
.relay-url {
font-family: monospace;
font-size: 0.85rem;
color: var(--primary);
word-break: break-all;
}
.relay-disconnected {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted-foreground);
margin-bottom: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.disconnected {
background: var(--danger);
}
.change-relay-btn {
width: 100%;
padding: 10px 16px;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
transition: background-color 0.2s;
}
.change-relay-btn:hover {
background: #00acc1;
}
</style> </style>

17
app/web/src/BlossomView.svelte

@ -2,6 +2,7 @@
import { createEventDispatcher, onMount } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
import { npubEncode } from "nostr-tools/nip19"; import { npubEncode } from "nostr-tools/nip19";
import { fetchUserProfile } from "./nostr.js"; import { fetchUserProfile } from "./nostr.js";
import { getApiBase } from "./config.js";
export let isLoggedIn = false; export let isLoggedIn = false;
export let userPubkey = ""; export let userPubkey = "";
@ -104,7 +105,7 @@
error = ""; error = "";
try { try {
const url = `${window.location.origin}/blossom/list/${userPubkey}`; const url = `${getApiBase()}/blossom/list/${userPubkey}`;
const authHeader = await createBlossomAuth(userSigner, "list"); const authHeader = await createBlossomAuth(userSigner, "list");
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -209,16 +210,16 @@
if (blob.url.startsWith("http://") || blob.url.startsWith("https://")) { if (blob.url.startsWith("http://") || blob.url.startsWith("https://")) {
return blob.url; return blob.url;
} }
// Starts with / - it's a path, prepend origin // Starts with / - it's a path, prepend API base
if (blob.url.startsWith("/")) { if (blob.url.startsWith("/")) {
return `${window.location.origin}${blob.url}`; return `${getApiBase()}${blob.url}`;
} }
// No protocol - looks like host:port/path, add http:// // No protocol - looks like host:port/path, add http://
// This handles cases like "localhost:3334/blossom/..." // This handles cases like "localhost:3334/blossom/..."
return `http://${blob.url}`; return `http://${blob.url}`;
} }
// Fallback: construct URL with sha256 only // Fallback: construct URL with sha256 only
return `${window.location.origin}/blossom/${blob.sha256}`; return `${getApiBase()}/blossom/${blob.sha256}`;
} }
function openLoginModal() { function openLoginModal() {
@ -229,7 +230,7 @@
if (!confirm(`Delete blob ${truncateHash(blob.sha256)}?`)) return; if (!confirm(`Delete blob ${truncateHash(blob.sha256)}?`)) return;
try { try {
const url = `${window.location.origin}/blossom/${blob.sha256}`; const url = `${getApiBase()}/blossom/${blob.sha256}`;
const authHeader = await createBlossomAuth(userSigner, "delete", blob.sha256); const authHeader = await createBlossomAuth(userSigner, "delete", blob.sha256);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
@ -271,7 +272,7 @@
uploadProgress = `Uploading ${i + 1}/${selectedFiles.length}: ${file.name}`; uploadProgress = `Uploading ${i + 1}/${selectedFiles.length}: ${file.name}`;
try { try {
const url = `${window.location.origin}/blossom/upload`; const url = `${getApiBase()}/blossom/upload`;
const authHeader = await createBlossomAuth(userSigner, "upload"); const authHeader = await createBlossomAuth(userSigner, "upload");
const response = await fetch(url, { const response = await fetch(url, {
@ -330,7 +331,7 @@
error = ""; error = "";
try { try {
const url = `${window.location.origin}/blossom/admin/users`; const url = `${getApiBase()}/blossom/admin/users`;
const authHeader = await createBlossomAuth(userSigner, "admin"); const authHeader = await createBlossomAuth(userSigner, "admin");
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -364,7 +365,7 @@
error = ""; error = "";
try { try {
const url = `${window.location.origin}/blossom/list/${pubkeyHex}`; const url = `${getApiBase()}/blossom/list/${pubkeyHex}`;
const authHeader = await createBlossomAuth(userSigner, "list"); const authHeader = await createBlossomAuth(userSigner, "list");
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},

1192
app/web/src/BunkerView.svelte

File diff suppressed because it is too large Load Diff

2
app/web/src/ComposeView.svelte

@ -240,7 +240,7 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.compose-view { .compose-view {
left: 160px; left: 0;
} }
.compose-header { .compose-header {

5
app/web/src/CurationView.svelte

@ -1,6 +1,7 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { curationKindCategories, parseCustomKinds, formatKindsCompact } from "./kindCategories.js"; import { curationKindCategories, parseCustomKinds, formatKindsCompact } from "./kindCategories.js";
import { getApiBase, getWsUrl } from "./config.js";
// Props // Props
export let userSigner; export let userSigner;
@ -65,7 +66,7 @@
throw new Error("No user pubkey available."); throw new Error("No user pubkey available.");
} }
const fullUrl = window.location.origin + url; const fullUrl = getApiBase() + url;
const authEvent = { const authEvent = {
kind: 27235, kind: 27235,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
@ -401,7 +402,7 @@
const signedEvent = await userSigner.signEvent(configEvent); const signedEvent = await userSigner.signEvent(configEvent);
// Submit to relay via WebSocket // Submit to relay via WebSocket
const ws = new WebSocket(window.location.origin.replace(/^http/, 'ws')); const ws = new WebSocket(getWsUrl());
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
ws.onopen = () => { ws.onopen = () => {

6
app/web/src/EventsView.svelte

@ -407,10 +407,10 @@
} }
.kind-number { .kind-number {
background: var(--primary); background: var(--card-bg);
color: #ffffff; color: var(--text-color);
padding: 0.1em 0.4em; padding: 0.1em 0.4em;
border: 0; border: 1px solid var(--border-color);
font-size: 0.7em; font-size: 0.7em;
font-weight: 600; font-weight: 600;
font-family: monospace; font-family: monospace;

347
app/web/src/Header.svelte

@ -1,4 +1,7 @@
<script> <script>
import { isStandaloneMode, relayUrl, relayInfo, relayConnectionStatus, savedRelays, saveRelay } from "./stores.js";
import { getApiBase, connectToRelay, normalizeWsUrl } from "./config.js";
export let isDarkTheme = false; export let isDarkTheme = false;
export let isLoggedIn = false; export let isLoggedIn = false;
export let userRole = ""; export let userRole = "";
@ -10,17 +13,119 @@
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
// Dropdown state
let showDropdown = false;
let isConnecting = false;
let connectingUrl = "";
function openSettingsDrawer() { function openSettingsDrawer() {
dispatch("openSettingsDrawer"); dispatch("openSettingsDrawer");
} }
function toggleMobileMenu() {
dispatch("toggleMobileMenu");
}
function openLoginModal() { function openLoginModal() {
dispatch("openLoginModal"); dispatch("openLoginModal");
} }
function openRelayModal() {
dispatch("openRelayModal");
}
function toggleDropdown(event) {
event.stopPropagation();
showDropdown = !showDropdown;
}
function closeDropdown() {
showDropdown = false;
}
async function switchToRelay(url) {
console.log('[Header] switchToRelay called with:', url);
if (isConnecting || url === $relayUrl) {
console.log('[Header] Skipping - already connecting or same relay');
return;
}
isConnecting = true;
connectingUrl = url;
try {
console.log('[Header] Calling connectToRelay...');
const result = await connectToRelay(url);
console.log('[Header] connectToRelay result:', result);
if (result.success) {
const wsUrl = normalizeWsUrl(url);
saveRelay(url, wsUrl);
dispatch("relayChanged", { info: result.info });
closeDropdown();
} else {
console.log('[Header] Connection failed:', result.error);
}
} catch (error) {
console.error("[Header] Failed to switch relay:", error);
} finally {
isConnecting = false;
connectingUrl = "";
}
}
function handleManageRelays(event) {
event.stopPropagation();
closeDropdown();
openRelayModal();
}
// Close dropdown when clicking outside
function handleClickOutside(event) {
if (showDropdown) {
closeDropdown();
}
}
// Get display name for relay - always show host URL
// Explicitly reference $relayUrl in reactive statement for proper tracking
$: relayDisplayName = getRelayHost($relayUrl);
function getRelayHost(storeUrl) {
try {
// In standalone mode, use the stored relay URL
// In embedded mode (no stored URL), use the current origin
const url = storeUrl || getApiBase();
console.log("[Header] getRelayHost - storeUrl:", storeUrl, "resolved url:", url);
const parsed = new URL(url);
return parsed.host;
} catch {
return storeUrl || "local";
}
}
function formatRelayUrl(url) {
try {
const parsed = new URL(url.startsWith('http') ? url : 'https://' + url);
return parsed.host;
} catch {
return url;
}
}
function isCurrentRelay(url) {
return $relayUrl === url && $relayConnectionStatus === "connected";
}
</script> </script>
<svelte:window on:click={handleClickOutside} />
<header class="main-header" class:dark-theme={isDarkTheme}> <header class="main-header" class:dark-theme={isDarkTheme}>
<div class="header-content"> <div class="header-content">
<button class="mobile-menu-btn" on:click={toggleMobileMenu} aria-label="Toggle menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
<img src="/orly.png" alt="ORLY Logo" class="logo" /> <img src="/orly.png" alt="ORLY Logo" class="logo" />
<div class="header-title"> <div class="header-title">
<span class="app-title"> <span class="app-title">
@ -32,6 +137,59 @@
{/if} {/if}
</span> </span>
</div> </div>
<!-- Relay indicator - dropdown only in standalone mode -->
<div class="relay-dropdown-container">
{#if $isStandaloneMode}
<button
class="relay-indicator"
on:click={toggleDropdown}
title="Click to switch relays"
>
<span class="relay-status" class:connected={$relayConnectionStatus === "connected"} class:error={$relayConnectionStatus === "error"}></span>
<span class="relay-name">{relayDisplayName}</span>
<span class="dropdown-arrow" class:open={showDropdown}>&#9662;</span>
</button>
{#if showDropdown}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="relay-dropdown" on:click|stopPropagation>
{#if $savedRelays.length > 0}
<div class="dropdown-section">
<div class="dropdown-label">Saved Relays</div>
{#each $savedRelays as relay}
<button
class="dropdown-item"
class:current={isCurrentRelay(relay.url)}
class:connecting={connectingUrl === relay.url}
on:click={() => switchToRelay(relay.url)}
disabled={isConnecting}
>
<span class="item-status" class:connected={isCurrentRelay(relay.url)}></span>
<span class="item-url-label">{relay.name}</span>
{#if connectingUrl === relay.url}
<span class="connecting-indicator">...</span>
{/if}
</button>
{/each}
</div>
<div class="dropdown-divider"></div>
{/if}
<button class="dropdown-item manage-btn" on:click={handleManageRelays}>
Manage Relays...
</button>
</div>
{/if}
{:else}
<!-- Embedded mode: static indicator, no dropdown -->
<div class="relay-indicator static" title="Connected to {relayDisplayName}">
<span class="relay-status" class:connected={$relayConnectionStatus === "connected"} class:error={$relayConnectionStatus === "error"}></span>
<span class="relay-name">{relayDisplayName}</span>
</div>
{/if}
</div>
<div class="header-buttons"> <div class="header-buttons">
{#if isLoggedIn} {#if isLoggedIn}
<button class="user-profile-btn" on:click={openSettingsDrawer}> <button class="user-profile-btn" on:click={openSettingsDrawer}>
@ -81,6 +239,34 @@
margin: 0; margin: 0;
} }
.mobile-menu-btn {
display: none;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 0.5em;
margin-right: 0.25em;
}
.mobile-menu-btn svg {
width: 1.5em;
height: 1.5em;
}
.mobile-menu-btn:hover {
background: var(--card-bg);
border-radius: 4px;
}
@media (max-width: 640px) {
.mobile-menu-btn {
display: flex;
}
}
.logo { .logo {
height: 2.5em; height: 2.5em;
width: auto; width: auto;
@ -181,5 +367,166 @@
overflow: visible !important; overflow: visible !important;
text-overflow: unset !important; text-overflow: unset !important;
width: auto !important; width: auto !important;
color: var(--text-color);
}
/* Relay dropdown container */
.relay-dropdown-container {
position: relative;
align-self: center;
}
/* Relay indicator */
.relay-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
margin: 0 8px;
background: var(--muted);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
color: var(--text-color);
transition: background-color 0.2s, border-color 0.2s;
}
.relay-indicator:hover:not(.static) {
background: var(--card-bg);
border-color: var(--primary);
}
.relay-indicator.static {
cursor: default;
}
.relay-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--warning);
flex-shrink: 0;
}
.relay-status.connected {
background: var(--success);
}
.relay-status.error {
background: var(--danger);
}
.relay-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.dropdown-arrow {
font-size: 0.7em;
transition: transform 0.2s;
margin-left: 2px;
}
.dropdown-arrow.open {
transform: rotate(180deg);
}
/* Dropdown menu */
.relay-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 250px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001;
overflow: hidden;
}
.dropdown-section {
padding: 4px 0;
}
.dropdown-label {
padding: 6px 12px;
font-size: 0.75em;
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--text-color);
font-size: 0.9em;
transition: background-color 0.15s;
}
.dropdown-item:hover:not(:disabled) {
background: var(--tab-hover-bg);
}
.dropdown-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.dropdown-item.current {
background: rgba(16, 185, 129, 0.1);
}
.dropdown-item.connecting {
background: rgba(234, 179, 8, 0.1);
}
.item-status {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted-foreground);
flex-shrink: 0;
}
.item-status.connected {
background: var(--success);
}
.item-url-label {
flex: 1;
font-family: monospace;
font-size: 0.85em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.connecting-indicator {
color: var(--warning);
font-weight: bold;
}
.dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 4px 0;
}
.manage-btn {
color: var(--primary);
font-weight: 500;
} }
</style> </style>

11
app/web/src/LogView.svelte

@ -1,5 +1,6 @@
<script> <script>
import { createEventDispatcher, onMount, onDestroy } from "svelte"; import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { getApiBase } from "./config.js";
export let isLoggedIn = false; export let isLoggedIn = false;
export let userRole = ""; export let userRole = "";
@ -68,7 +69,7 @@
kind: 27235, kind: 27235,
created_at: now, created_at: now,
tags: [ tags: [
["u", `${window.location.origin}${path}`], ["u", `${getApiBase()}${path}`],
["method", method], ["method", method],
], ],
content: "", content: "",
@ -97,7 +98,7 @@
try { try {
const path = `/api/logs?offset=${offset}&limit=${LIMIT}`; const path = `/api/logs?offset=${offset}&limit=${LIMIT}`;
const authHeader = await createAuthHeader("GET", path); const authHeader = await createAuthHeader("GET", path);
const url = `${window.location.origin}${path}`; const url = `${getApiBase()}${path}`;
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
}); });
@ -131,7 +132,7 @@
async function loadLogLevel() { async function loadLogLevel() {
try { try {
const response = await fetch(`${window.location.origin}/api/logs/level`); const response = await fetch(`${getApiBase()}/api/logs/level`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
currentLogLevel = data.level || "info"; currentLogLevel = data.level || "info";
@ -147,7 +148,7 @@
try { try {
const authHeader = await createAuthHeader("POST", "/api/logs/level"); const authHeader = await createAuthHeader("POST", "/api/logs/level");
const response = await fetch(`${window.location.origin}/api/logs/level`, { const response = await fetch(`${getApiBase()}/api/logs/level`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -175,7 +176,7 @@
try { try {
const authHeader = await createAuthHeader("POST", "/api/logs/clear"); const authHeader = await createAuthHeader("POST", "/api/logs/clear");
const response = await fetch(`${window.location.origin}/api/logs/clear`, { const response = await fetch(`${getApiBase()}/api/logs/clear`, {
method: "POST", method: "POST",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
}); });

5
app/web/src/ManagedACL.svelte

@ -1,5 +1,6 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getApiBase } from "./config.js";
// Props // Props
export let userSigner; export let userSigner;
@ -93,7 +94,7 @@
try { try {
isLoading = true; isLoading = true;
console.log("Fetching relay info from /"); console.log("Fetching relay info from /");
const response = await fetch(window.location.origin + "/", { const response = await fetch(getApiBase() +"/", {
headers: { headers: {
Accept: "application/nostr+json", Accept: "application/nostr+json",
}, },
@ -147,7 +148,7 @@
} }
// Get the full URL // Get the full URL
const fullUrl = window.location.origin + url; const fullUrl = getApiBase() +url;
// Create NIP-98 authentication event // Create NIP-98 authentication event
const authEvent = { const authEvent = {

494
app/web/src/RelayConnectModal.svelte

@ -0,0 +1,494 @@
<script>
import { createEventDispatcher } from "svelte";
import { connectToRelay, normalizeWsUrl } from "./config.js";
import { relayInfo, relayConnectionStatus, relayUrl, savedRelays, saveRelay, removeRelay } from "./stores.js";
const dispatch = createEventDispatcher();
export let showModal = false;
export let isDarkTheme = false;
let urlInput = "";
let isConnecting = false;
let errorMessage = "";
let connectingUrl = "";
function closeModal() {
showModal = false;
errorMessage = "";
dispatch("close");
}
async function handleConnect(url = null) {
const targetUrl = url || urlInput.trim();
if (!targetUrl) {
errorMessage = "Please enter a relay URL";
return;
}
isConnecting = true;
connectingUrl = targetUrl;
errorMessage = "";
try {
const result = await connectToRelay(targetUrl);
if (result.success) {
// Save with the wss:// URL as the display name
const wsUrl = normalizeWsUrl(targetUrl);
saveRelay(targetUrl, wsUrl);
urlInput = ""; // Clear input on success
dispatch("connected", { info: result.info });
closeModal();
} else {
errorMessage = result.error || "Failed to connect";
}
} catch (error) {
errorMessage = error.message || "Connection failed";
} finally {
isConnecting = false;
connectingUrl = "";
}
}
async function handleAddRelay() {
const targetUrl = urlInput.trim();
if (!targetUrl) {
errorMessage = "Please enter a relay URL";
return;
}
isConnecting = true;
errorMessage = "";
try {
const result = await connectToRelay(targetUrl);
if (result.success) {
const wsUrl = normalizeWsUrl(targetUrl);
saveRelay(targetUrl, wsUrl);
urlInput = "";
dispatch("connected", { info: result.info });
// Don't close modal - stay open to manage relays
} else {
errorMessage = result.error || "Failed to connect";
}
} catch (error) {
errorMessage = error.message || "Connection failed";
} finally {
isConnecting = false;
}
}
function handleRemoveRelay(url, event) {
event.stopPropagation();
removeRelay(url);
}
function handleKeydown(event) {
if (event.key === "Enter" && !isConnecting) {
handleAddRelay();
} else if (event.key === "Escape") {
closeModal();
}
}
function isCurrentRelay(url) {
return $relayUrl === url && $relayConnectionStatus === "connected";
}
// Reset input when modal opens
$: if (showModal) {
urlInput = "";
errorMessage = "";
}
</script>
{#if showModal}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="modal-overlay" on:click={closeModal}>
<div
class="modal"
class:dark={isDarkTheme}
on:click|stopPropagation
>
<div class="modal-header">
<h2>Relay Manager</h2>
<button class="close-btn" on:click={closeModal}>&times;</button>
</div>
<div class="modal-content">
<!-- Add new relay section at top -->
<div class="add-relay-section">
<div class="section-header">Add Relay</div>
<div class="input-row">
<input
type="text"
placeholder="wss://relay.example.com"
bind:value={urlInput}
on:keydown={handleKeydown}
disabled={isConnecting}
class="url-input"
/>
<button
class="add-btn"
on:click={handleAddRelay}
disabled={isConnecting || !urlInput.trim()}
>
{#if isConnecting && !connectingUrl}
Adding...
{:else}
Add
{/if}
</button>
</div>
</div>
{#if errorMessage}
<div class="error-message">
{errorMessage}
</div>
{/if}
<!-- Saved relays list -->
<div class="saved-relays-section">
<div class="section-header">Saved Relays</div>
{#if $savedRelays.length > 0}
<div class="saved-relays-list">
{#each $savedRelays as relay}
<div
class="relay-item"
class:current={isCurrentRelay(relay.url)}
class:connecting={connectingUrl === relay.url}
>
<button
class="relay-connect-btn"
on:click={() => handleConnect(relay.url)}
disabled={isConnecting}
title="Click to connect"
>
<span class="relay-status-dot" class:connected={isCurrentRelay(relay.url)}></span>
<span class="relay-url-label">{relay.name}</span>
{#if isCurrentRelay(relay.url)}
<span class="current-badge">Connected</span>
{:else if connectingUrl === relay.url}
<span class="connecting-badge">Connecting...</span>
{/if}
</button>
<button
class="relay-remove-btn"
on:click={(e) => handleRemoveRelay(relay.url, e)}
title="Remove relay"
disabled={isConnecting}
>
Remove
</button>
</div>
{/each}
</div>
{:else}
<div class="empty-state">
No saved relays. Add one above to get started.
</div>
{/if}
</div>
<div class="button-group">
<button class="done-btn" on:click={closeModal}>
Done
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: var(--bg-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 550px;
max-height: 90vh;
overflow-y: auto;
border: 1px solid var(--border-color);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
margin: 0;
color: var(--text-color);
font-size: 1.25rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-color);
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s;
}
.close-btn:hover {
background-color: var(--tab-hover-bg);
}
.modal-content {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
font-size: 0.85rem;
color: var(--muted-foreground);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.add-relay-section {
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.input-row {
display: flex;
gap: 8px;
}
.url-input {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--input-border);
border-radius: 6px;
font-size: 0.95rem;
font-family: monospace;
background: var(--bg-color);
color: var(--text-color);
}
.url-input:focus {
outline: none;
border-color: var(--primary);
}
.url-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.add-btn {
padding: 10px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
transition: background-color 0.2s;
}
.add-btn:hover:not(:disabled) {
background: #00acc1;
}
.add-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.error-message {
padding: 10px 12px;
background: #fee2e2;
color: #dc2626;
border-radius: 6px;
font-size: 0.9rem;
}
.dark .error-message {
background: #450a0a;
color: #fca5a5;
}
.saved-relays-section {
flex: 1;
}
.saved-relays-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.relay-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px;
border-radius: 6px;
background: var(--muted);
transition: background-color 0.2s;
}
.relay-item.current {
background: rgba(16, 185, 129, 0.15);
}
.relay-item.connecting {
background: rgba(234, 179, 8, 0.15);
}
.relay-connect-btn {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
border-radius: 4px;
transition: background-color 0.15s;
}
.relay-connect-btn:hover:not(:disabled) {
background: var(--tab-hover-bg);
}
.relay-connect-btn:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.relay-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted-foreground);
flex-shrink: 0;
}
.relay-status-dot.connected {
background: var(--success);
}
.relay-url-label {
flex: 1;
color: var(--text-color);
font-family: monospace;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-badge {
font-size: 0.7rem;
padding: 2px 8px;
background: var(--success);
color: white;
border-radius: 4px;
font-weight: 500;
flex-shrink: 0;
}
.connecting-badge {
font-size: 0.7rem;
padding: 2px 8px;
background: var(--warning);
color: white;
border-radius: 4px;
font-weight: 500;
flex-shrink: 0;
}
.relay-remove-btn {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--muted-foreground);
cursor: pointer;
font-size: 0.8rem;
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
flex-shrink: 0;
}
.relay-remove-btn:hover:not(:disabled) {
background: var(--danger);
border-color: var(--danger);
color: white;
}
.relay-remove-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.empty-state {
padding: 20px;
text-align: center;
color: var(--muted-foreground);
font-size: 0.9rem;
}
.button-group {
display: flex;
justify-content: flex-end;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.done-btn {
padding: 10px 24px;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: background-color 0.2s;
}
.done-btn:hover {
background: #00acc1;
}
</style>

23
app/web/src/RelayConnectView.svelte

@ -7,6 +7,7 @@
import { createEventDispatcher, onMount } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
import * as api from "./api.js"; import * as api from "./api.js";
import { copyToClipboard, showCopyFeedback } from "./utils.js"; import { copyToClipboard, showCopyFeedback } from "./utils.js";
import { relayUrl } from "./stores.js";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -28,13 +29,35 @@
let currentURI = ""; let currentURI = "";
let currentLabel = ""; let currentLabel = "";
let initialLoadDone = false;
let currentRelayUrl = "";
onMount(async () => { onMount(async () => {
currentRelayUrl = $relayUrl || "";
await loadNRCConfig(); await loadNRCConfig();
initialLoadDone = true;
}); });
// Watch for relay URL changes after initial load
$: watchedRelayUrl = $relayUrl;
$: if (initialLoadDone && watchedRelayUrl !== currentRelayUrl) {
currentRelayUrl = watchedRelayUrl;
handleRelayChange();
}
function handleRelayChange() {
console.log("[RelayConnectView] Relay changed, reloading...");
connections = [];
config = {};
nrcEnabled = false;
loadNRCConfig();
}
async function loadNRCConfig() { async function loadNRCConfig() {
console.log("[RelayConnectView] loadNRCConfig called, current relayUrl:", $relayUrl);
try { try {
const result = await api.fetchNRCConfig(); const result = await api.fetchNRCConfig();
console.log("[RelayConnectView] NRC config result:", result);
nrcEnabled = result.enabled; nrcEnabled = result.enabled;
badgerRequired = result.badger_required; badgerRequired = result.badger_required;

72
app/web/src/Sidebar.svelte

@ -3,20 +3,33 @@
export let tabs = []; export let tabs = [];
export let selectedTab = ""; export let selectedTab = "";
export let version = ""; export let version = "";
export let mobileOpen = false;
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
function selectTab(tabId) { function selectTab(tabId) {
dispatch("selectTab", tabId); dispatch("selectTab", tabId);
// Close mobile drawer when tab is selected
dispatch("closeMobileMenu");
} }
function closeSearchTab(tabId) { function closeSearchTab(tabId) {
dispatch("closeSearchTab", tabId); dispatch("closeSearchTab", tabId);
} }
function closeMobileMenu() {
dispatch("closeMobileMenu");
}
</script> </script>
<aside class="sidebar" class:dark-theme={isDarkTheme}> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if mobileOpen}
<div class="mobile-overlay" on:click={closeMobileMenu}></div>
{/if}
<aside class="sidebar" class:dark-theme={isDarkTheme} class:mobile-open={mobileOpen}>
<div class="sidebar-content"> <div class="sidebar-content">
<div class="tabs"> <div class="tabs">
{#each tabs as tab} {#each tabs as tab}
@ -151,20 +164,6 @@
} }
} }
@media (max-width: 640px) {
.sidebar {
width: 160px;
}
.tab-label {
display: block;
}
.tab {
justify-content: flex-start;
}
}
.version-link { .version-link {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@ -207,9 +206,50 @@
} }
} }
/* Mobile drawer styles */
.mobile-overlay {
display: none;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.version-text { .mobile-overlay {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 199;
}
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 200;
width: 200px;
}
.sidebar.mobile-open {
transform: translateX(0);
}
/* Show labels in mobile drawer */
.sidebar.mobile-open .tab-label {
display: block;
}
.sidebar.mobile-open .tab-close-icon {
display: block;
}
.sidebar.mobile-open .version-text {
display: inline; display: inline;
} }
.sidebar.mobile-open .version-link {
padding-left: 1em;
} }
}
</style> </style>

58
app/web/src/api.js

@ -2,6 +2,8 @@
* API helper functions for ORLY relay management endpoints * API helper functions for ORLY relay management endpoints
*/ */
import { getApiBase } from './config.js';
/** /**
* Create NIP-98 authentication header * Create NIP-98 authentication header
* @param {object} signer - The signer instance * @param {object} signer - The signer instance
@ -59,7 +61,7 @@ export async function createNIP98Auth(signer, pubkey, method, url) {
*/ */
export async function fetchUserRole(signer, pubkey) { export async function fetchUserRole(signer, pubkey) {
try { try {
const url = `${window.location.origin}/api/role`; const url = `${getApiBase()}/api/role`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -80,7 +82,7 @@ export async function fetchUserRole(signer, pubkey) {
*/ */
export async function fetchACLMode() { export async function fetchACLMode() {
try { try {
const response = await fetch(`${window.location.origin}/api/acl-mode`); const response = await fetch(`${getApiBase()}/api/acl-mode`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
return data.mode || ""; return data.mode || "";
@ -100,7 +102,7 @@ export async function fetchACLMode() {
* @returns {Promise<object>} Sprocket config data * @returns {Promise<object>} Sprocket config data
*/ */
export async function loadSprocketConfig(signer, pubkey) { export async function loadSprocketConfig(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/config`; const url = `${getApiBase()}/api/sprocket/config`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -116,7 +118,7 @@ export async function loadSprocketConfig(signer, pubkey) {
* @returns {Promise<object>} Sprocket status data * @returns {Promise<object>} Sprocket status data
*/ */
export async function loadSprocketStatus(signer, pubkey) { export async function loadSprocketStatus(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/status`; const url = `${getApiBase()}/api/sprocket/status`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -132,7 +134,7 @@ export async function loadSprocketStatus(signer, pubkey) {
* @returns {Promise<string>} Sprocket script content * @returns {Promise<string>} Sprocket script content
*/ */
export async function loadSprocketScript(signer, pubkey) { export async function loadSprocketScript(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket`; const url = `${getApiBase()}/api/sprocket`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -150,7 +152,7 @@ export async function loadSprocketScript(signer, pubkey) {
* @returns {Promise<object>} Save result * @returns {Promise<object>} Save result
*/ */
export async function saveSprocketScript(signer, pubkey, script) { export async function saveSprocketScript(signer, pubkey, script) {
const url = `${window.location.origin}/api/sprocket`; const url = `${getApiBase()}/api/sprocket`;
const authHeader = await createNIP98Auth(signer, pubkey, "PUT", url); const authHeader = await createNIP98Auth(signer, pubkey, "PUT", url);
const response = await fetch(url, { const response = await fetch(url, {
method: "PUT", method: "PUT",
@ -171,7 +173,7 @@ export async function saveSprocketScript(signer, pubkey, script) {
* @returns {Promise<object>} Restart result * @returns {Promise<object>} Restart result
*/ */
export async function restartSprocket(signer, pubkey) { export async function restartSprocket(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/restart`; const url = `${getApiBase()}/api/sprocket/restart`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url); const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@ -188,7 +190,7 @@ export async function restartSprocket(signer, pubkey) {
* @returns {Promise<object>} Delete result * @returns {Promise<object>} Delete result
*/ */
export async function deleteSprocket(signer, pubkey) { export async function deleteSprocket(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket`; const url = `${getApiBase()}/api/sprocket`;
const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url); const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
@ -205,7 +207,7 @@ export async function deleteSprocket(signer, pubkey) {
* @returns {Promise<Array>} Version list * @returns {Promise<Array>} Version list
*/ */
export async function loadSprocketVersions(signer, pubkey) { export async function loadSprocketVersions(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/versions`; const url = `${getApiBase()}/api/sprocket/versions`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -222,7 +224,7 @@ export async function loadSprocketVersions(signer, pubkey) {
* @returns {Promise<string>} Version content * @returns {Promise<string>} Version content
*/ */
export async function loadSprocketVersion(signer, pubkey, version) { export async function loadSprocketVersion(signer, pubkey, version) {
const url = `${window.location.origin}/api/sprocket/versions/${encodeURIComponent(version)}`; const url = `${getApiBase()}/api/sprocket/versions/${encodeURIComponent(version)}`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -239,7 +241,7 @@ export async function loadSprocketVersion(signer, pubkey, version) {
* @returns {Promise<object>} Delete result * @returns {Promise<object>} Delete result
*/ */
export async function deleteSprocketVersion(signer, pubkey, filename) { export async function deleteSprocketVersion(signer, pubkey, filename) {
const url = `${window.location.origin}/api/sprocket/versions/${encodeURIComponent(filename)}`; const url = `${getApiBase()}/api/sprocket/versions/${encodeURIComponent(filename)}`;
const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url); const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
@ -270,7 +272,7 @@ export async function uploadSprocketScript(signer, pubkey, file) {
* @returns {Promise<object>} Policy config * @returns {Promise<object>} Policy config
*/ */
export async function loadPolicyConfig(signer, pubkey) { export async function loadPolicyConfig(signer, pubkey) {
const url = `${window.location.origin}/api/policy/config`; const url = `${getApiBase()}/api/policy/config`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -286,7 +288,7 @@ export async function loadPolicyConfig(signer, pubkey) {
* @returns {Promise<object>} Policy JSON * @returns {Promise<object>} Policy JSON
*/ */
export async function loadPolicy(signer, pubkey) { export async function loadPolicy(signer, pubkey) {
const url = `${window.location.origin}/api/policy`; const url = `${getApiBase()}/api/policy`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -303,7 +305,7 @@ export async function loadPolicy(signer, pubkey) {
* @returns {Promise<object>} Validation result * @returns {Promise<object>} Validation result
*/ */
export async function validatePolicy(signer, pubkey, policyJson) { export async function validatePolicy(signer, pubkey, policyJson) {
const url = `${window.location.origin}/api/policy/validate`; const url = `${getApiBase()}/api/policy/validate`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url); const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@ -323,7 +325,7 @@ export async function validatePolicy(signer, pubkey, policyJson) {
* @returns {Promise<Array>} List of followed pubkeys * @returns {Promise<Array>} List of followed pubkeys
*/ */
export async function fetchPolicyFollows(signer, pubkey) { export async function fetchPolicyFollows(signer, pubkey) {
const url = `${window.location.origin}/api/policy/follows`; const url = `${getApiBase()}/api/policy/follows`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -341,7 +343,7 @@ export async function fetchPolicyFollows(signer, pubkey) {
*/ */
export async function fetchRelayInfo() { export async function fetchRelayInfo() {
try { try {
const response = await fetch(window.location.origin, { const response = await fetch(getApiBase(), {
headers: { headers: {
Accept: "application/nostr+json", Accept: "application/nostr+json",
}, },
@ -365,7 +367,7 @@ export async function fetchRelayInfo() {
* @returns {Promise<Blob>} JSONL blob * @returns {Promise<Blob>} JSONL blob
*/ */
export async function exportEvents(signer, pubkey, authorPubkeys = []) { export async function exportEvents(signer, pubkey, authorPubkeys = []) {
const url = `${window.location.origin}/api/export`; const url = `${getApiBase()}/api/export`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url); const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, { const response = await fetch(url, {
@ -389,7 +391,7 @@ export async function exportEvents(signer, pubkey, authorPubkeys = []) {
* @returns {Promise<object>} Import result * @returns {Promise<object>} Import result
*/ */
export async function importEvents(signer, pubkey, file) { export async function importEvents(signer, pubkey, file) {
const url = `${window.location.origin}/api/import`; const url = `${getApiBase()}/api/import`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url); const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const formData = new FormData(); const formData = new FormData();
@ -413,7 +415,7 @@ export async function importEvents(signer, pubkey, file) {
*/ */
export async function fetchWireGuardStatus() { export async function fetchWireGuardStatus() {
try { try {
const response = await fetch(`${window.location.origin}/api/wireguard/status`); const response = await fetch(`${getApiBase()}/api/wireguard/status`);
if (response.ok) { if (response.ok) {
return await response.json(); return await response.json();
} }
@ -430,7 +432,7 @@ export async function fetchWireGuardStatus() {
* @returns {Promise<object>} WireGuard config * @returns {Promise<object>} WireGuard config
*/ */
export async function getWireGuardConfig(signer, pubkey) { export async function getWireGuardConfig(signer, pubkey) {
const url = `${window.location.origin}/api/wireguard/config`; const url = `${getApiBase()}/api/wireguard/config`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -449,7 +451,7 @@ export async function getWireGuardConfig(signer, pubkey) {
* @returns {Promise<object>} Regeneration result * @returns {Promise<object>} Regeneration result
*/ */
export async function regenerateWireGuard(signer, pubkey) { export async function regenerateWireGuard(signer, pubkey) {
const url = `${window.location.origin}/api/wireguard/regenerate`; const url = `${getApiBase()}/api/wireguard/regenerate`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url); const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@ -470,7 +472,7 @@ export async function regenerateWireGuard(signer, pubkey) {
* @returns {Promise<object>} Audit data with revoked_keys and access_logs * @returns {Promise<object>} Audit data with revoked_keys and access_logs
*/ */
export async function getWireGuardAudit(signer, pubkey) { export async function getWireGuardAudit(signer, pubkey) {
const url = `${window.location.origin}/api/wireguard/audit`; const url = `${getApiBase()}/api/wireguard/audit`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -489,8 +491,10 @@ export async function getWireGuardAudit(signer, pubkey) {
* @returns {Promise<object>} NRC config status * @returns {Promise<object>} NRC config status
*/ */
export async function fetchNRCConfig() { export async function fetchNRCConfig() {
const apiBase = getApiBase();
console.log("[api] fetchNRCConfig using base URL:", apiBase);
try { try {
const response = await fetch(`${window.location.origin}/api/nrc/config`); const response = await fetch(`${apiBase}/api/nrc/config`);
if (response.ok) { if (response.ok) {
return await response.json(); return await response.json();
} }
@ -507,7 +511,7 @@ export async function fetchNRCConfig() {
* @returns {Promise<object>} Connections list and config * @returns {Promise<object>} Connections list and config
*/ */
export async function fetchNRCConnections(signer, pubkey) { export async function fetchNRCConnections(signer, pubkey) {
const url = `${window.location.origin}/api/nrc/connections`; const url = `${getApiBase()}/api/nrc/connections`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
@ -528,7 +532,7 @@ export async function fetchNRCConnections(signer, pubkey) {
* @returns {Promise<object>} Created connection with URI * @returns {Promise<object>} Created connection with URI
*/ */
export async function createNRCConnection(signer, pubkey, label, useCashu = false) { export async function createNRCConnection(signer, pubkey, label, useCashu = false) {
const url = `${window.location.origin}/api/nrc/connections`; const url = `${getApiBase()}/api/nrc/connections`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url); const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@ -553,7 +557,7 @@ export async function createNRCConnection(signer, pubkey, label, useCashu = fals
* @returns {Promise<object>} Delete result * @returns {Promise<object>} Delete result
*/ */
export async function deleteNRCConnection(signer, pubkey, connId) { export async function deleteNRCConnection(signer, pubkey, connId) {
const url = `${window.location.origin}/api/nrc/connections/${connId}`; const url = `${getApiBase()}/api/nrc/connections/${connId}`;
const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url); const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url);
const response = await fetch(url, { const response = await fetch(url, {
method: "DELETE", method: "DELETE",
@ -574,7 +578,7 @@ export async function deleteNRCConnection(signer, pubkey, connId) {
* @returns {Promise<object>} Connection URI * @returns {Promise<object>} Connection URI
*/ */
export async function getNRCConnectionURI(signer, pubkey, connId) { export async function getNRCConnectionURI(signer, pubkey, connId) {
const url = `${window.location.origin}/api/nrc/connections/${connId}/uri`; const url = `${getApiBase()}/api/nrc/connections/${connId}/uri`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url); const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, { const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {}, headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},

508
app/web/src/bunker-service.js

@ -0,0 +1,508 @@
/**
* BunkerService - NIP-46 Remote Signer
*
* Implements the signer side of NIP-46 protocol.
* Listens for signing requests from remote clients and responds using
* the user's private key stored in ORLY.
*
* Protocol:
* - Kind 24133 events for request/response
* - NIP-04 encryption for payloads
* - Methods: connect, get_public_key, sign_event, nip04_encrypt, nip04_decrypt, ping
*/
import { nip04 } from 'nostr-tools';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { secp256k1 } from '@noble/curves/secp256k1';
import { encodeToken } from './cashu-client.js';
// NIP-46 methods
const NIP46_METHOD = {
CONNECT: 'connect',
GET_PUBLIC_KEY: 'get_public_key',
SIGN_EVENT: 'sign_event',
NIP04_ENCRYPT: 'nip04_encrypt',
NIP04_DECRYPT: 'nip04_decrypt',
PING: 'ping'
};
/**
* Generate a random hex string.
*/
function generateRandomHex(bytes = 16) {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return bytesToHex(arr);
}
/**
* BunkerService class - implements NIP-46 signer protocol.
*/
export class BunkerService {
/**
* @param {string} relayUrl - WebSocket URL of the relay
* @param {string} userPubkey - User's public key (hex)
* @param {Uint8Array} userPrivkey - User's private key (32 bytes)
*/
constructor(relayUrl, userPubkey, userPrivkey) {
this.relayUrl = relayUrl;
this.userPubkey = userPubkey;
this.userPrivkey = userPrivkey;
this.ws = null;
this.connected = false;
this.allowedSecrets = new Set();
this.connectedClients = new Map(); // pubkey -> { connectedAt, lastActivity }
this.requestLog = [];
this.heartbeatInterval = null;
this.subscriptionId = null;
this.catToken = null;
// Callbacks
this.onClientConnected = null;
this.onClientDisconnected = null;
this.onRequest = null;
this.onStatusChange = null;
}
/**
* Add an allowed connection secret.
*/
addAllowedSecret(secret) {
this.allowedSecrets.add(secret);
}
/**
* Remove an allowed secret.
*/
removeAllowedSecret(secret) {
this.allowedSecrets.delete(secret);
}
/**
* Set CAT token for WebSocket connection.
*/
setCatToken(token) {
this.catToken = token;
}
/**
* Connect to the relay and start listening for NIP-46 requests.
*/
async connect() {
return new Promise((resolve, reject) => {
// Build WebSocket URL
let wsUrl = this.relayUrl;
if (wsUrl.startsWith('http://')) {
wsUrl = 'ws://' + wsUrl.slice(7);
} else if (wsUrl.startsWith('https://')) {
wsUrl = 'wss://' + wsUrl.slice(8);
} else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
wsUrl = 'wss://' + wsUrl;
}
// Add CAT token if available
if (this.catToken) {
const tokenEncoded = encodeToken(this.catToken);
const url = new URL(wsUrl);
url.searchParams.set('token', tokenEncoded);
wsUrl = url.toString();
}
console.log('[BunkerService] Connecting to:', wsUrl.split('?')[0]);
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;
console.log('[BunkerService] Connected to relay');
// Subscribe to NIP-46 events for our pubkey
this.subscriptionId = generateRandomHex(8);
const sub = JSON.stringify([
'REQ',
this.subscriptionId,
{
kinds: [24133],
'#p': [this.userPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
]);
ws.send(sub);
console.log('[BunkerService] Subscribed to NIP-46 events');
// Start heartbeat
this.startHeartbeat();
if (this.onStatusChange) {
this.onStatusChange('connected');
}
resolve();
};
ws.onerror = (error) => {
clearTimeout(timeout);
console.error('[BunkerService] WebSocket error:', error);
reject(new Error('WebSocket error'));
};
ws.onclose = () => {
this.connected = false;
this.ws = null;
this.stopHeartbeat();
console.log('[BunkerService] Disconnected from relay');
if (this.onStatusChange) {
this.onStatusChange('disconnected');
}
};
ws.onmessage = (event) => {
this.handleMessage(event.data);
};
});
}
/**
* Start WebSocket heartbeat to keep connection alive.
*/
startHeartbeat(intervalMs = 30000) {
this.stopHeartbeat();
this.heartbeatInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
// Send a ping via Nostr protocol (re-subscribe)
const sub = JSON.stringify([
'REQ',
this.subscriptionId,
{
kinds: [24133],
'#p': [this.userPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
]);
this.ws.send(sub);
}
}, intervalMs);
}
/**
* Stop WebSocket heartbeat.
*/
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
/**
* Disconnect from the relay.
*/
disconnect() {
this.stopHeartbeat();
if (this.ws) {
// Close subscription
if (this.subscriptionId) {
this.ws.send(JSON.stringify(['CLOSE', this.subscriptionId]));
}
this.ws.close();
this.ws = null;
}
this.connected = false;
this.connectedClients.clear();
}
/**
* Handle incoming WebSocket messages.
*/
async handleMessage(data) {
try {
const msg = JSON.parse(data);
if (!Array.isArray(msg)) return;
const [type, ...rest] = msg;
if (type === 'EVENT') {
const [, event] = rest;
if (event.kind === 24133) {
await this.handleNIP46Request(event);
}
} else if (type === 'OK') {
// Event published confirmation
console.log('[BunkerService] Event published:', rest[0]?.substring(0, 8));
} else if (type === 'NOTICE') {
console.warn('[BunkerService] Relay notice:', rest[0]);
}
} catch (err) {
console.error('[BunkerService] Failed to parse message:', err);
}
}
/**
* Handle NIP-46 request event.
*/
async handleNIP46Request(event) {
try {
// Decrypt the content with NIP-04
const privkeyHex = bytesToHex(this.userPrivkey);
const decrypted = await nip04.decrypt(privkeyHex, event.pubkey, event.content);
const request = JSON.parse(decrypted);
console.log('[BunkerService] Received request:', request.method, 'from:', event.pubkey.substring(0, 8));
// Log the request
this.requestLog.push({
id: request.id,
method: request.method,
from: event.pubkey,
timestamp: Date.now()
});
if (this.requestLog.length > 100) {
this.requestLog.shift();
}
if (this.onRequest) {
this.onRequest(request, event.pubkey);
}
// Handle the request
let result = null;
let error = null;
try {
switch (request.method) {
case NIP46_METHOD.CONNECT:
result = await this.handleConnect(request, event.pubkey);
break;
case NIP46_METHOD.GET_PUBLIC_KEY:
result = await this.handleGetPublicKey(request, event.pubkey);
break;
case NIP46_METHOD.SIGN_EVENT:
result = await this.handleSignEvent(request, event.pubkey);
break;
case NIP46_METHOD.NIP04_ENCRYPT:
result = await this.handleNip04Encrypt(request, event.pubkey);
break;
case NIP46_METHOD.NIP04_DECRYPT:
result = await this.handleNip04Decrypt(request, event.pubkey);
break;
case NIP46_METHOD.PING:
result = 'pong';
break;
default:
error = `Unknown method: ${request.method}`;
}
} catch (err) {
console.error('[BunkerService] Error handling request:', err);
error = err.message;
}
// Send response
await this.sendResponse(request.id, result, error, event.pubkey);
} catch (err) {
console.error('[BunkerService] Failed to handle NIP-46 request:', err);
}
}
/**
* Handle connect request.
*/
async handleConnect(request, senderPubkey) {
const [clientPubkey, secret] = request.params;
// Validate secret if required
if (this.allowedSecrets.size > 0) {
if (!secret || !this.allowedSecrets.has(secret)) {
throw new Error('Invalid or missing connection secret');
}
}
// Register connected client
this.connectedClients.set(senderPubkey, {
clientPubkey: clientPubkey || senderPubkey,
connectedAt: Date.now(),
lastActivity: Date.now()
});
console.log('[BunkerService] Client connected:', senderPubkey.substring(0, 8));
if (this.onClientConnected) {
this.onClientConnected(senderPubkey);
}
return 'ack';
}
/**
* Handle get_public_key request.
*/
async handleGetPublicKey(request, senderPubkey) {
// Update last activity
if (this.connectedClients.has(senderPubkey)) {
this.connectedClients.get(senderPubkey).lastActivity = Date.now();
}
return this.userPubkey;
}
/**
* Handle sign_event request.
*/
async handleSignEvent(request, senderPubkey) {
// Check if client is connected
if (!this.connectedClients.has(senderPubkey)) {
throw new Error('Not connected');
}
// Update last activity
this.connectedClients.get(senderPubkey).lastActivity = Date.now();
const [eventJson] = request.params;
const event = JSON.parse(eventJson);
// Ensure pubkey matches
if (event.pubkey && event.pubkey !== this.userPubkey) {
throw new Error('Event pubkey does not match signer pubkey');
}
// Set pubkey if not set
event.pubkey = this.userPubkey;
// Calculate event ID
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
event.id = bytesToHex(new Uint8Array(hash));
// Sign the event
const sig = secp256k1.sign(hexToBytes(event.id), this.userPrivkey);
event.sig = sig.toCompactHex();
console.log('[BunkerService] Signed event:', event.id.substring(0, 8), 'kind:', event.kind);
return JSON.stringify(event);
}
/**
* Handle nip04_encrypt request.
*/
async handleNip04Encrypt(request, senderPubkey) {
// Check if client is connected
if (!this.connectedClients.has(senderPubkey)) {
throw new Error('Not connected');
}
// Update last activity
this.connectedClients.get(senderPubkey).lastActivity = Date.now();
const [pubkey, plaintext] = request.params;
const privkeyHex = bytesToHex(this.userPrivkey);
const ciphertext = await nip04.encrypt(privkeyHex, pubkey, plaintext);
return ciphertext;
}
/**
* Handle nip04_decrypt request.
*/
async handleNip04Decrypt(request, senderPubkey) {
// Check if client is connected
if (!this.connectedClients.has(senderPubkey)) {
throw new Error('Not connected');
}
// Update last activity
this.connectedClients.get(senderPubkey).lastActivity = Date.now();
const [pubkey, ciphertext] = request.params;
const privkeyHex = bytesToHex(this.userPrivkey);
const plaintext = await nip04.decrypt(privkeyHex, pubkey, ciphertext);
return plaintext;
}
/**
* Send NIP-46 response to client.
*/
async sendResponse(requestId, result, error, recipientPubkey) {
if (!this.ws || !this.connected) {
console.error('[BunkerService] Cannot send response: not connected');
return;
}
const response = {
id: requestId,
result: result !== null ? result : undefined,
error: error !== null ? error : undefined
};
// Encrypt response with NIP-04
const privkeyHex = bytesToHex(this.userPrivkey);
const encrypted = await nip04.encrypt(privkeyHex, recipientPubkey, JSON.stringify(response));
// Create response event
const event = {
kind: 24133,
pubkey: this.userPubkey,
created_at: Math.floor(Date.now() / 1000),
content: encrypted,
tags: [['p', recipientPubkey]]
};
// Calculate event ID
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
event.id = bytesToHex(new Uint8Array(hash));
// Sign the event
const sig = secp256k1.sign(hexToBytes(event.id), this.userPrivkey);
event.sig = sig.toCompactHex();
// Send to relay
this.ws.send(JSON.stringify(['EVENT', event]));
console.log('[BunkerService] Sent response for:', requestId);
}
/**
* Check if the service is connected.
*/
isConnected() {
return this.connected;
}
/**
* Get list of connected clients.
*/
getConnectedClients() {
return Array.from(this.connectedClients.entries()).map(([pubkey, info]) => ({
pubkey,
...info
}));
}
/**
* Get request log.
*/
getRequestLog() {
return [...this.requestLog];
}
}

423
app/web/src/bunker-worker.js

@ -0,0 +1,423 @@
/**
* BunkerWorker - Web Worker for persistent NIP-46 bunker service
*
* Runs in a separate thread to maintain WebSocket connection
* regardless of UI component lifecycle.
*/
import { nip04 } from 'nostr-tools';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { secp256k1 } from '@noble/curves/secp256k1';
// State
let ws = null;
let connected = false;
let userPubkey = null;
let userPrivkey = null;
let relayUrl = null;
let catTokenEncoded = null;
let subscriptionId = null;
let heartbeatInterval = null;
let allowedSecrets = new Set();
let connectedClients = new Map();
// NIP-46 methods
const NIP46_METHOD = {
CONNECT: 'connect',
GET_PUBLIC_KEY: 'get_public_key',
SIGN_EVENT: 'sign_event',
NIP04_ENCRYPT: 'nip04_encrypt',
NIP04_DECRYPT: 'nip04_decrypt',
PING: 'ping'
};
function generateRandomHex(bytes = 16) {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return bytesToHex(arr);
}
function postStatus(status, data = {}) {
self.postMessage({ type: 'status', status, ...data });
}
function postError(error) {
self.postMessage({ type: 'error', error });
}
function postClientsUpdate() {
const clients = Array.from(connectedClients.entries()).map(([pubkey, info]) => ({
pubkey,
...info
}));
self.postMessage({ type: 'clients', clients });
}
async function connect() {
if (connected || !relayUrl || !userPubkey || !userPrivkey) {
postError('Missing configuration or already connected');
return;
}
return new Promise((resolve, reject) => {
let wsUrl = relayUrl;
if (wsUrl.startsWith('http://')) {
wsUrl = 'ws://' + wsUrl.slice(7);
} else if (wsUrl.startsWith('https://')) {
wsUrl = 'wss://' + wsUrl.slice(8);
} else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
wsUrl = 'wss://' + wsUrl;
}
// Add CAT token if available
if (catTokenEncoded) {
const url = new URL(wsUrl);
url.searchParams.set('token', catTokenEncoded);
wsUrl = url.toString();
}
console.log('[BunkerWorker] Connecting to:', wsUrl.split('?')[0]);
ws = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
ws.close();
postError('Connection timeout');
reject(new Error('Connection timeout'));
}, 10000);
ws.onopen = () => {
clearTimeout(timeout);
connected = true;
console.log('[BunkerWorker] Connected to relay');
// Subscribe to NIP-46 events
subscriptionId = generateRandomHex(8);
const sub = JSON.stringify([
'REQ',
subscriptionId,
{
kinds: [24133],
'#p': [userPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
]);
ws.send(sub);
startHeartbeat();
postStatus('connected');
resolve();
};
ws.onerror = (error) => {
clearTimeout(timeout);
console.error('[BunkerWorker] WebSocket error:', error);
postError('WebSocket error');
reject(new Error('WebSocket error'));
};
ws.onclose = () => {
connected = false;
ws = null;
stopHeartbeat();
console.log('[BunkerWorker] Disconnected from relay');
postStatus('disconnected');
};
ws.onmessage = (event) => {
handleMessage(event.data);
};
});
}
function disconnect() {
stopHeartbeat();
if (ws) {
if (subscriptionId) {
ws.send(JSON.stringify(['CLOSE', subscriptionId]));
}
ws.close();
ws = null;
}
connected = false;
connectedClients.clear();
postStatus('disconnected');
postClientsUpdate();
}
function startHeartbeat(intervalMs = 30000) {
stopHeartbeat();
heartbeatInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
const sub = JSON.stringify([
'REQ',
subscriptionId,
{
kinds: [24133],
'#p': [userPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
]);
ws.send(sub);
}
}, intervalMs);
}
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
}
async function handleMessage(data) {
try {
const msg = JSON.parse(data);
if (!Array.isArray(msg)) return;
const [type, ...rest] = msg;
if (type === 'EVENT') {
const [, event] = rest;
if (event.kind === 24133) {
await handleNIP46Request(event);
}
} else if (type === 'OK') {
console.log('[BunkerWorker] Event published:', rest[0]?.substring(0, 8));
} else if (type === 'NOTICE') {
console.warn('[BunkerWorker] Relay notice:', rest[0]);
}
} catch (err) {
console.error('[BunkerWorker] Failed to parse message:', err);
}
}
async function handleNIP46Request(event) {
try {
const privkeyHex = bytesToHex(userPrivkey);
const decrypted = await nip04.decrypt(privkeyHex, event.pubkey, event.content);
const request = JSON.parse(decrypted);
console.log('[BunkerWorker] Received request:', request.method, 'from:', event.pubkey.substring(0, 8));
// Log to main thread
self.postMessage({
type: 'request',
method: request.method,
from: event.pubkey,
timestamp: Date.now()
});
let result = null;
let error = null;
try {
switch (request.method) {
case NIP46_METHOD.CONNECT:
result = await handleConnect(request, event.pubkey);
break;
case NIP46_METHOD.GET_PUBLIC_KEY:
result = handleGetPublicKey(event.pubkey);
break;
case NIP46_METHOD.SIGN_EVENT:
result = await handleSignEvent(request, event.pubkey);
break;
case NIP46_METHOD.NIP04_ENCRYPT:
result = await handleNip04Encrypt(request, event.pubkey);
break;
case NIP46_METHOD.NIP04_DECRYPT:
result = await handleNip04Decrypt(request, event.pubkey);
break;
case NIP46_METHOD.PING:
result = 'pong';
break;
default:
error = `Unknown method: ${request.method}`;
}
} catch (err) {
console.error('[BunkerWorker] Error handling request:', err);
error = err.message;
}
await sendResponse(request.id, result, error, event.pubkey);
} catch (err) {
console.error('[BunkerWorker] Failed to handle NIP-46 request:', err);
}
}
async function handleConnect(request, senderPubkey) {
const [clientPubkey, secret] = request.params;
if (allowedSecrets.size > 0) {
if (!secret || !allowedSecrets.has(secret)) {
throw new Error('Invalid or missing connection secret');
}
}
connectedClients.set(senderPubkey, {
clientPubkey: clientPubkey || senderPubkey,
connectedAt: Date.now(),
lastActivity: Date.now()
});
console.log('[BunkerWorker] Client connected:', senderPubkey.substring(0, 8));
postClientsUpdate();
return 'ack';
}
function handleGetPublicKey(senderPubkey) {
if (connectedClients.has(senderPubkey)) {
connectedClients.get(senderPubkey).lastActivity = Date.now();
}
return userPubkey;
}
async function handleSignEvent(request, senderPubkey) {
if (!connectedClients.has(senderPubkey)) {
throw new Error('Not connected');
}
connectedClients.get(senderPubkey).lastActivity = Date.now();
const [eventJson] = request.params;
const event = JSON.parse(eventJson);
if (event.pubkey && event.pubkey !== userPubkey) {
throw new Error('Event pubkey does not match signer pubkey');
}
event.pubkey = userPubkey;
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
event.id = bytesToHex(new Uint8Array(hash));
const sig = secp256k1.sign(hexToBytes(event.id), userPrivkey);
event.sig = sig.toCompactHex();
console.log('[BunkerWorker] Signed event:', event.id.substring(0, 8), 'kind:', event.kind);
return JSON.stringify(event);
}
async function handleNip04Encrypt(request, senderPubkey) {
if (!connectedClients.has(senderPubkey)) {
throw new Error('Not connected');
}
connectedClients.get(senderPubkey).lastActivity = Date.now();
const [pubkey, plaintext] = request.params;
const privkeyHex = bytesToHex(userPrivkey);
return await nip04.encrypt(privkeyHex, pubkey, plaintext);
}
async function handleNip04Decrypt(request, senderPubkey) {
if (!connectedClients.has(senderPubkey)) {
throw new Error('Not connected');
}
connectedClients.get(senderPubkey).lastActivity = Date.now();
const [pubkey, ciphertext] = request.params;
const privkeyHex = bytesToHex(userPrivkey);
return await nip04.decrypt(privkeyHex, pubkey, ciphertext);
}
async function sendResponse(requestId, result, error, recipientPubkey) {
if (!ws || !connected) {
console.error('[BunkerWorker] Cannot send response: not connected');
return;
}
const response = {
id: requestId,
result: result !== null ? result : undefined,
error: error !== null ? error : undefined
};
const privkeyHex = bytesToHex(userPrivkey);
const encrypted = await nip04.encrypt(privkeyHex, recipientPubkey, JSON.stringify(response));
const event = {
kind: 24133,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
content: encrypted,
tags: [['p', recipientPubkey]]
};
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
event.id = bytesToHex(new Uint8Array(hash));
const sig = secp256k1.sign(hexToBytes(event.id), userPrivkey);
event.sig = sig.toCompactHex();
ws.send(JSON.stringify(['EVENT', event]));
console.log('[BunkerWorker] Sent response for:', requestId);
}
// Message handler from main thread
self.onmessage = async (event) => {
const { type, ...data } = event.data;
switch (type) {
case 'configure':
userPubkey = data.userPubkey;
userPrivkey = data.userPrivkey ? hexToBytes(data.userPrivkey) : null;
relayUrl = data.relayUrl;
catTokenEncoded = data.catTokenEncoded;
if (data.secrets) {
allowedSecrets = new Set(data.secrets);
}
console.log('[BunkerWorker] Configured for pubkey:', userPubkey?.substring(0, 8));
break;
case 'connect':
try {
await connect();
} catch (err) {
postError(err.message);
}
break;
case 'disconnect':
disconnect();
break;
case 'addSecret':
allowedSecrets.add(data.secret);
break;
case 'removeSecret':
allowedSecrets.delete(data.secret);
break;
case 'getStatus':
postStatus(connected ? 'connected' : 'disconnected');
postClientsUpdate();
break;
default:
console.warn('[BunkerWorker] Unknown message type:', type);
}
};
console.log('[BunkerWorker] Worker initialized');

310
app/web/src/config.js

@ -0,0 +1,310 @@
/**
* Relay configuration module for dual-mode operation (embedded vs standalone)
*
* Embedded mode: Dashboard served from same origin as relay (no CORS needed)
* Standalone mode: Dashboard hosted separately, connects to remote relay (CORS required)
*/
import { get } from 'svelte/store';
import { relayUrl, isStandaloneMode, relayInfo, relayConnectionStatus } from './stores.js';
// Build-time configuration (set via rollup replace plugin)
const BUILD_STANDALONE_MODE = typeof process !== 'undefined' &&
process.env && process.env.STANDALONE_MODE === 'true';
const BUILD_DEFAULT_RELAY_URL = typeof process !== 'undefined' &&
process.env && process.env.DEFAULT_RELAY_URL || '';
/**
* Initialize configuration on app startup
* Call this from main.js before rendering App
*/
export function initConfig() {
// Detect standalone mode:
// 1. Explicitly built as standalone
// 2. Has a configured relay URL in localStorage
// 3. Running from file:// protocol
// 4. Not running on a typical relay port (3334) - likely a static server
const hasStoredRelay = !!localStorage.getItem("relayUrl");
const isFileProtocol = window.location.protocol === 'file:';
const isNonRelayPort = !['3334', '443', '80', ''].includes(window.location.port);
const standalone = BUILD_STANDALONE_MODE || hasStoredRelay || isFileProtocol || isNonRelayPort;
isStandaloneMode.set(standalone);
// Set default relay URL from build config if not already set
if (BUILD_DEFAULT_RELAY_URL && !get(relayUrl)) {
relayUrl.set(BUILD_DEFAULT_RELAY_URL);
}
console.log('[config] Initialized:', {
standaloneMode: standalone,
buildStandalone: BUILD_STANDALONE_MODE,
hasStoredRelay,
isNonRelayPort,
port: window.location.port,
relayUrl: get(relayUrl) || '(same origin)'
});
}
/**
* Get the HTTP base URL for API calls
* @returns {string} Base URL (e.g., "https://relay.example.com")
*/
export function getApiBase() {
const url = get(relayUrl);
if (url) {
return normalizeHttpUrl(url);
}
return window.location.origin;
}
/**
* Get the WebSocket URL for relay connection
* @returns {string} WebSocket URL (e.g., "wss://relay.example.com/")
*/
export function getWsUrl() {
const url = get(relayUrl);
if (url) {
return normalizeWsUrl(url);
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/`;
}
/**
* Get array of relay URLs for nostr-tools SimplePool
* @returns {string[]} Array with single relay URL
*/
export function getRelayUrls() {
return [getWsUrl()];
}
/**
* Check if running in standalone mode
* @returns {boolean}
*/
export function isStandalone() {
return get(isStandaloneMode);
}
/**
* Check if a relay URL is configured (either stored or same-origin)
* @returns {boolean}
*/
export function hasRelayConfigured() {
// In embedded mode, always configured (same origin)
// In standalone mode, need explicit URL
if (!get(isStandaloneMode)) {
return true;
}
return !!get(relayUrl);
}
/**
* Set the relay URL and trigger connection
* @param {string} url - Relay URL (http/https/ws/wss)
*/
export function setRelayUrl(url) {
const normalized = url ? normalizeHttpUrl(url) : '';
relayUrl.set(normalized);
if (normalized) {
// Mark as standalone since we have an explicit URL
isStandaloneMode.set(true);
}
}
/**
* Fetch and validate relay info via NIP-11
* @param {string} [url] - Optional URL to check (defaults to current relay)
* @returns {Promise<object|null>} Relay info or null on error
*/
export async function fetchRelayInfoFromUrl(url) {
const baseUrl = url ? normalizeHttpUrl(url) : getApiBase();
try {
relayConnectionStatus.set("connecting");
const response = await fetch(baseUrl, {
headers: {
Accept: "application/nostr+json",
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const info = await response.json();
// Validate it looks like relay info
if (!info.name && !info.supported_nips) {
throw new Error("Invalid relay info response");
}
relayInfo.set(info);
relayConnectionStatus.set("connected");
return info;
} catch (error) {
console.error('[config] Failed to fetch relay info:', error);
relayConnectionStatus.set("error");
relayInfo.set(null);
return null;
}
}
/**
* Connect to a new relay URL
* Validates via NIP-11 first, falls back to WebSocket test if CORS blocks NIP-11
* @param {string} url - Relay URL
* @returns {Promise<{success: boolean, info?: object, error?: string}>}
*/
export async function connectToRelay(url) {
console.log('[config] connectToRelay called with:', url);
if (!url) {
return { success: false, error: "URL is required" };
}
const normalized = normalizeHttpUrl(url);
console.log('[config] Normalized HTTP URL:', normalized);
// Try to fetch relay info to validate
const info = await fetchRelayInfoFromUrl(normalized);
console.log('[config] fetchRelayInfoFromUrl returned:', info ? 'success' : 'null');
if (info) {
// NIP-11 worked, store the URL
setRelayUrl(normalized);
return { success: true, info };
}
// NIP-11 failed (likely CORS), try WebSocket connection test
console.log('[config] NIP-11 failed, trying WebSocket connection test');
const wsUrl = normalizeWsUrl(url);
console.log('[config] Normalized WS URL:', wsUrl);
const wsResult = await testWebSocketConnection(wsUrl);
console.log('[config] WebSocket test complete:', wsResult);
if (wsResult.success) {
// WebSocket worked, store the URL
setRelayUrl(normalized);
relayConnectionStatus.set("connected");
// Create minimal relay info
const minimalInfo = { name: wsUrl };
relayInfo.set(minimalInfo);
return { success: true, info: minimalInfo };
}
return { success: false, error: wsResult.error || "Could not connect to relay" };
}
/**
* Test WebSocket connection to a relay
* @param {string} wsUrl - WebSocket URL
* @returns {Promise<{success: boolean, error?: string}>}
*/
async function testWebSocketConnection(wsUrl) {
console.log('[config] Testing WebSocket connection to:', wsUrl);
return new Promise((resolve) => {
let resolved = false;
let ws = null;
const safeResolve = (result) => {
if (!resolved) {
resolved = true;
console.log('[config] WebSocket test result:', result);
resolve(result);
}
};
const timeout = setTimeout(() => {
console.log('[config] WebSocket connection timed out');
if (ws) ws.close();
safeResolve({ success: false, error: "Connection timed out" });
}, 5000);
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[config] WebSocket connected successfully');
clearTimeout(timeout);
ws.close();
safeResolve({ success: true });
};
ws.onerror = (error) => {
console.log('[config] WebSocket error:', error);
clearTimeout(timeout);
safeResolve({ success: false, error: "WebSocket connection failed" });
};
ws.onclose = (event) => {
console.log('[config] WebSocket closed:', event.code, event.reason);
clearTimeout(timeout);
if (event.code !== 1000 && !resolved) {
safeResolve({ success: false, error: `Connection closed: ${event.reason || 'code ' + event.code}` });
}
};
} catch (err) {
console.error('[config] WebSocket creation error:', err);
clearTimeout(timeout);
safeResolve({ success: false, error: err.message || "Failed to create WebSocket" });
}
});
}
// ==================== URL Normalization Helpers ====================
/**
* Normalize URL to HTTP(S) format
* @param {string} url
* @returns {string}
*/
function normalizeHttpUrl(url) {
let normalized = url.trim();
// Convert WebSocket URLs to HTTP
if (normalized.startsWith('wss://')) {
normalized = 'https://' + normalized.slice(6);
} else if (normalized.startsWith('ws://')) {
normalized = 'http://' + normalized.slice(5);
}
// Add protocol if missing
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
normalized = 'https://' + normalized;
}
// Remove trailing slash
return normalized.replace(/\/$/, '');
}
/**
* Normalize URL to WebSocket format
* @param {string} url
* @returns {string}
*/
export function normalizeWsUrl(url) {
let normalized = url.trim();
// Convert HTTP URLs to WebSocket
if (normalized.startsWith('https://')) {
normalized = 'wss://' + normalized.slice(8);
} else if (normalized.startsWith('http://')) {
normalized = 'ws://' + normalized.slice(7);
}
// Add protocol if missing
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
normalized = 'wss://' + normalized;
}
// Ensure trailing slash for relay URL
if (!normalized.endsWith('/')) {
normalized += '/';
}
return normalized;
}

15
app/web/src/constants.js

@ -1,9 +1,18 @@
// Default Nostr relays for searching // Default Nostr relays for searching
// Use startsWith to avoid minifier optimization issues // Import from config module for dual-mode support (embedded vs standalone)
import { getRelayUrls } from './config.js';
// Dynamic relay list - call this function to get current relay(s)
// In embedded mode: returns same-origin relay
// In standalone mode: returns configured relay URL
export function getDefaultRelays() {
return getRelayUrls();
}
// Legacy export for backwards compatibility
// Components should migrate to getDefaultRelays() for dynamic behavior
const wsProtocol = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:'; const wsProtocol = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
export const DEFAULT_RELAYS = [ export const DEFAULT_RELAYS = [
// Use the local relay WebSocket endpoint
// Automatically use ws:// for http:// and wss:// for https://
`${wsProtocol}//${window.location.host}/`, `${wsProtocol}//${window.location.host}/`,
]; ];

5
app/web/src/main.js

@ -1,5 +1,10 @@
import App from "./App.svelte"; import App from "./App.svelte";
import "../public/global.css"; import "../public/global.css";
import { initConfig } from "./config.js";
// Initialize relay configuration before creating the app
// This sets up standalone mode detection and relay URL handling
initConfig();
const app = new App({ const app = new App({
target: document.body, target: document.body,

324
app/web/src/nostr.js

@ -1,7 +1,17 @@
import { SimplePool } from 'nostr-tools/pool'; import { SimplePool } from 'nostr-tools/pool';
import { EventStore } from 'applesauce-core'; import { EventStore } from 'applesauce-core';
import { PrivateKeySigner } from 'applesauce-signers'; import { PrivateKeySigner } from 'applesauce-signers';
import { DEFAULT_RELAYS, FALLBACK_RELAYS } from "./constants.js"; import { getDefaultRelays, FALLBACK_RELAYS } from "./constants.js";
// Dedicated pool for fallback relay queries (separate from main pool to avoid conflicts)
let fallbackPool = null;
function getFallbackPool() {
if (!fallbackPool) {
fallbackPool = new SimplePool();
}
return fallbackPool;
}
// Nostr client wrapper using nostr-tools // Nostr client wrapper using nostr-tools
class NostrClient { class NostrClient {
@ -10,7 +20,39 @@ class NostrClient {
this.eventStore = new EventStore(); this.eventStore = new EventStore();
this.isConnected = false; this.isConnected = false;
this.signer = null; this.signer = null;
this.relays = [...DEFAULT_RELAYS]; // Use dynamic relay list (supports standalone mode)
this.relays = [...getDefaultRelays()];
}
// Refresh relay list from config (call when relay URL changes)
refreshRelays() {
const newRelays = getDefaultRelays();
if (JSON.stringify(this.relays) !== JSON.stringify(newRelays)) {
console.log("Relay list updated:", newRelays);
this.relays = [...newRelays];
}
}
// Reset client for new relay (close old connections, refresh relay list, create new pool)
reset() {
console.log("[NostrClient] Resetting for new relay...");
// Close ALL existing connections by destroying the pool
if (this.pool) {
try {
// Close connections to old relays first
this.pool.close(this.relays);
} catch (e) {
console.warn("[NostrClient] Error closing old relay connections:", e);
}
// Destroy the pool reference completely
this.pool = null;
}
// Create completely fresh pool
this.pool = new SimplePool();
this.isConnected = false;
// Refresh relay list
this.relays = [...getDefaultRelays()];
console.log("[NostrClient] Reset complete, new relays:", this.relays);
} }
async connect() { async connect() {
@ -511,8 +553,11 @@ export async function fetchUserProfile(pubkey) {
async function fetchProfileFromFallbackRelays(pubkey, filters) { async function fetchProfileFromFallbackRelays(pubkey, filters) {
return new Promise((resolve) => { return new Promise((resolve) => {
const events = []; const events = [];
const pool = getFallbackPool();
let sub;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
sub.close(); if (sub) sub.close();
// Return the most recent profile event // Return the most recent profile event
if (events.length > 0) { if (events.length > 0) {
events.sort((a, b) => b.created_at - a.created_at); events.sort((a, b) => b.created_at - a.created_at);
@ -522,7 +567,7 @@ async function fetchProfileFromFallbackRelays(pubkey, filters) {
} }
}, 5000); }, 5000);
const sub = nostrClient.pool.subscribeMany( sub = pool.subscribeMany(
FALLBACK_RELAYS, FALLBACK_RELAYS,
filters, filters,
{ {
@ -532,7 +577,7 @@ async function fetchProfileFromFallbackRelays(pubkey, filters) {
}, },
oneose() { oneose() {
clearTimeout(timeoutId); clearTimeout(timeoutId);
sub.close(); if (sub) sub.close();
if (events.length > 0) { if (events.length > 0) {
events.sort((a, b) => b.created_at - a.created_at); events.sort((a, b) => b.created_at - a.created_at);
resolve(events[0]); resolve(events[0]);
@ -578,6 +623,202 @@ async function processProfileEvent(profileEvent, pubkey) {
return profile; return profile;
} }
// Fetch user's relay list (NIP-65 kind 10002)
export async function fetchUserRelayList(pubkey) {
console.log(`[nostr] Fetching relay list for pubkey: ${pubkey?.substring(0, 8)}...`);
const filters = [{
kinds: [10002],
authors: [pubkey],
limit: 1
}];
// Try local relay first
try {
const events = await fetchEvents(filters, { timeout: 10000, useCache: true });
if (events.length > 0) {
const relayListEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
console.log("[nostr] Relay list found on local relay");
return parseRelayListFromEvent(relayListEvent);
}
} catch (error) {
console.warn("[nostr] Failed to fetch relay list from local relay:", error);
}
// Try fallback relays
console.log("[nostr] Relay list not found locally, trying fallback relays...");
try {
const relayListEvent = await fetchFromFallbackRelays(filters);
if (relayListEvent) {
// Cache and publish to local relay
await putEvent(relayListEvent);
try {
await nostrClient.publish(relayListEvent);
} catch (e) {
console.warn("[nostr] Failed to publish relay list to local relay:", e);
}
return parseRelayListFromEvent(relayListEvent);
}
} catch (error) {
console.warn("[nostr] Failed to fetch relay list from fallback relays:", error);
}
console.log("[nostr] No relay list found for pubkey");
return null;
}
// Parse relay list from kind 10002 event
function parseRelayListFromEvent(event) {
if (!event || event.kind !== 10002) return null;
const relays = {
read: [],
write: [],
all: []
};
for (const tag of event.tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1];
const marker = tag[2]; // 'read', 'write', or undefined (both)
if (marker === 'read') {
relays.read.push(url);
} else if (marker === 'write') {
relays.write.push(url);
} else {
// No marker means both read and write
relays.read.push(url);
relays.write.push(url);
}
relays.all.push({ url, read: marker !== 'write', write: marker !== 'read' });
}
}
console.log(`[nostr] Parsed relay list: ${relays.all.length} relays`);
return relays;
}
// Generic helper to fetch from fallback relays
async function fetchFromFallbackRelays(filters) {
return new Promise((resolve) => {
const events = [];
const pool = getFallbackPool();
let sub;
const timeoutId = setTimeout(() => {
if (sub) sub.close();
if (events.length > 0) {
events.sort((a, b) => b.created_at - a.created_at);
resolve(events[0]);
} else {
resolve(null);
}
}, 5000);
sub = pool.subscribeMany(
FALLBACK_RELAYS,
filters,
{
onevent(event) {
events.push(event);
},
oneose() {
clearTimeout(timeoutId);
if (sub) sub.close();
if (events.length > 0) {
events.sort((a, b) => b.created_at - a.created_at);
resolve(events[0]);
} else {
resolve(null);
}
}
}
);
});
}
// Fetch user's contact list (kind 3) - includes follows and may have relay hints
export async function fetchUserContactList(pubkey) {
console.log(`[nostr] Fetching contact list for pubkey: ${pubkey?.substring(0, 8)}...`);
const filters = [{
kinds: [3],
authors: [pubkey],
limit: 1
}];
// Try local relay first
try {
const events = await fetchEvents(filters, { timeout: 10000, useCache: true });
if (events.length > 0) {
const contactEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
console.log("[nostr] Contact list found on local relay");
return parseContactListFromEvent(contactEvent);
}
} catch (error) {
console.warn("[nostr] Failed to fetch contact list from local relay:", error);
}
// Try fallback relays
console.log("[nostr] Contact list not found locally, trying fallback relays...");
try {
const contactEvent = await fetchFromFallbackRelays(filters);
if (contactEvent) {
await putEvent(contactEvent);
try {
await nostrClient.publish(contactEvent);
} catch (e) {
console.warn("[nostr] Failed to publish contact list to local relay:", e);
}
return parseContactListFromEvent(contactEvent);
}
} catch (error) {
console.warn("[nostr] Failed to fetch contact list from fallback relays:", error);
}
console.log("[nostr] No contact list found for pubkey");
return null;
}
// Parse contact list from kind 3 event
function parseContactListFromEvent(event) {
if (!event || event.kind !== 3) return null;
const follows = [];
const relayHints = {};
for (const tag of event.tags) {
if (tag[0] === 'p' && tag[1]) {
const pubkey = tag[1];
const relayUrl = tag[2] || null;
const petname = tag[3] || null;
follows.push({ pubkey, relayUrl, petname });
if (relayUrl) {
if (!relayHints[relayUrl]) {
relayHints[relayUrl] = [];
}
relayHints[relayUrl].push(pubkey);
}
}
}
// Also parse the content field which may contain relay preferences (legacy format)
let legacyRelays = {};
try {
if (event.content) {
legacyRelays = JSON.parse(event.content);
}
} catch (e) {
// Content is not JSON, ignore
}
console.log(`[nostr] Parsed contact list: ${follows.length} follows, ${Object.keys(relayHints).length} relay hints`);
return { follows, relayHints, legacyRelays, event };
}
// Fetch events // Fetch events
export async function fetchEvents(filters, options = {}) { export async function fetchEvents(filters, options = {}) {
console.log(`Starting event fetch with filters:`, JSON.stringify(filters, null, 2)); console.log(`Starting event fetch with filters:`, JSON.stringify(filters, null, 2));
@ -609,9 +850,11 @@ export async function fetchEvents(filters, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const relayEvents = []; const relayEvents = [];
let sub = null;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
console.log(`Timeout reached after ${timeout}ms, returning ${relayEvents.length} relay events`); console.log(`Timeout reached after ${timeout}ms, returning ${relayEvents.length} relay events`);
sub.close(); if (sub) sub.close();
// Store all received events in IndexedDB before resolving // Store all received events in IndexedDB before resolving
if (relayEvents.length > 0) { if (relayEvents.length > 0) {
@ -626,11 +869,31 @@ export async function fetchEvents(filters, options = {}) {
try { try {
// Generate a subscription ID for logging // Generate a subscription ID for logging
const subId = Math.random().toString(36).substring(7); const subId = Math.random().toString(36).substring(7);
console.log(`📤 REQ [${subId}]:`, JSON.stringify(["REQ", subId, ...filters], null, 2));
const sub = nostrClient.pool.subscribeMany( // Validate filters before sending
if (!Array.isArray(filters) || filters.length === 0) {
console.error(`❌ Invalid filters: not an array or empty`, filters);
resolve(cachedEvents);
return;
}
// Ensure each filter is a valid object
const validFilters = filters.filter(f => f && typeof f === 'object' && !Array.isArray(f));
if (validFilters.length !== filters.length) {
console.warn(` Some filters were invalid, filtered ${filters.length} -> ${validFilters.length}`, filters);
}
if (validFilters.length === 0) {
console.error(`❌ No valid filters remaining`);
resolve(cachedEvents);
return;
}
console.log(`📤 REQ [${subId}] to ${nostrClient.relays.join(', ')}:`, JSON.stringify(["REQ", subId, ...validFilters], null, 2));
sub = nostrClient.pool.subscribeMany(
nostrClient.relays, nostrClient.relays,
filters, validFilters,
{ {
onevent(event) { onevent(event) {
console.log(`📥 EVENT received for REQ [${subId}]:`, { console.log(`📥 EVENT received for REQ [${subId}]:`, {
@ -648,7 +911,7 @@ export async function fetchEvents(filters, options = {}) {
oneose() { oneose() {
console.log(`✅ EOSE received for REQ [${subId}], got ${relayEvents.length} relay events`); console.log(`✅ EOSE received for REQ [${subId}], got ${relayEvents.length} relay events`);
clearTimeout(timeoutId); clearTimeout(timeoutId);
sub.close(); if (sub) sub.close();
// Store all events in IndexedDB before resolving // Store all events in IndexedDB before resolving
if (relayEvents.length > 0) { if (relayEvents.length > 0) {
@ -681,17 +944,34 @@ export async function fetchAllEvents(options = {}) {
...rest ...rest
} = options; } = options;
const filters = [{ ...rest }]; const now = Math.floor(Date.now() / 1000);
const thirtyDaysAgo = now - (30 * 24 * 60 * 60);
const sixMonthsAgo = now - (180 * 24 * 60 * 60);
if (since) filters[0].since = since; // Start with 30 days if no since specified
const initialSince = since || thirtyDaysAgo;
const filters = [{ ...rest }];
filters[0].since = initialSince;
if (until) filters[0].until = until; if (until) filters[0].until = until;
if (authors) filters[0].authors = authors; if (authors) filters[0].authors = authors;
if (kinds) filters[0].kinds = kinds; if (kinds) filters[0].kinds = kinds;
if (limit) filters[0].limit = limit; if (limit) filters[0].limit = limit;
const events = await fetchEvents(filters, { let events = await fetchEvents(filters, {
timeout: 30000
});
// If we got few results and weren't already using a longer window, retry with 6 months
const fewResultsThreshold = Math.min(20, limit / 2);
if (events.length < fewResultsThreshold && initialSince > sixMonthsAgo && !since) {
console.log(`[fetchAllEvents] Only got ${events.length} events, retrying with 6-month window...`);
filters[0].since = sixMonthsAgo;
events = await fetchEvents(filters, {
timeout: 30000 timeout: 30000
}); });
console.log(`[fetchAllEvents] 6-month window returned ${events.length} events`);
}
return events; return events;
} }
@ -841,6 +1121,24 @@ export async function queryEvents(filters, options = {}) {
// Export cache query function for direct access // Export cache query function for direct access
export { queryEventsFromDB }; export { queryEventsFromDB };
// Clear the IndexedDB cache (call when switching relays)
export async function clearIndexedDBCache() {
console.log("[nostr] Clearing IndexedDB cache...");
try {
const db = await openDB();
const tx = db.transaction(STORE_EVENTS, "readwrite");
const store = tx.objectStore(STORE_EVENTS);
await new Promise((resolve, reject) => {
const req = store.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
console.log("[nostr] IndexedDB cache cleared");
} catch (e) {
console.warn("[nostr] Failed to clear IndexedDB cache", e);
}
}
// Debug function to check database contents // Debug function to check database contents
export async function debugIndexedDB() { export async function debugIndexedDB() {
try { try {

79
app/web/src/stores.js

@ -1,5 +1,32 @@
import { writable, derived } from 'svelte/store'; import { writable, derived } from 'svelte/store';
// ==================== Relay Connection State ====================
// Configured relay URL (empty = use same origin / embedded mode)
export const relayUrl = writable(localStorage.getItem("relayUrl") || "");
export const isStandaloneMode = writable(false);
export const relayInfo = writable(null); // NIP-11 relay info
export const relayConnectionStatus = writable("disconnected"); // disconnected, connecting, connected, error
export const isOrlyRelay = writable(true); // true if connected to ORLY relay with API endpoints
// Saved relays list - each entry: { url: string, name: string, lastConnected?: number }
const storedRelays = localStorage.getItem("savedRelays");
export const savedRelays = writable(storedRelays ? JSON.parse(storedRelays) : []);
// Persist relay URL to localStorage
relayUrl.subscribe(url => {
if (url) {
localStorage.setItem("relayUrl", url);
} else {
localStorage.removeItem("relayUrl");
}
});
// Persist saved relays to localStorage
savedRelays.subscribe(relays => {
localStorage.setItem("savedRelays", JSON.stringify(relays));
});
// ==================== User/Auth State ==================== // ==================== User/Auth State ====================
export const isLoggedIn = writable(false); export const isLoggedIn = writable(false);
@ -86,3 +113,55 @@ export function isCacheValid(cacheDuration = 5 * 60 * 1000) {
globalCacheTimestamp.subscribe(v => timestamp = v)(); globalCacheTimestamp.subscribe(v => timestamp = v)();
return Date.now() - timestamp < cacheDuration; return Date.now() - timestamp < cacheDuration;
} }
/**
* Clear relay connection and reset to embedded mode
*/
export function clearRelayConnection() {
relayUrl.set("");
relayInfo.set(null);
relayConnectionStatus.set("disconnected");
// Also clear auth state since we're changing relays
resetAuthState();
clearEventsCache();
}
/**
* Add or update a relay in the saved relays list
* @param {string} url - Relay URL
* @param {string} name - Relay name (from NIP-11 or user input)
*/
export function saveRelay(url, name) {
savedRelays.update(relays => {
const existing = relays.findIndex(r => r.url === url);
const entry = { url, name, lastConnected: Date.now() };
if (existing >= 0) {
relays[existing] = entry;
} else {
relays.unshift(entry);
}
return relays;
});
}
/**
* Remove a relay from the saved relays list
* @param {string} url - Relay URL to remove
*/
export function removeRelay(url) {
savedRelays.update(relays => relays.filter(r => r.url !== url));
}
/**
* Update the last connected timestamp for a relay
* @param {string} url - Relay URL
*/
export function touchRelay(url) {
savedRelays.update(relays => {
const relay = relays.find(r => r.url === url);
if (relay) {
relay.lastConnected = Date.now();
}
return relays;
});
}

70
cmd/dashboard-server/main.go

@ -0,0 +1,70 @@
// Simple static file server for testing the standalone dashboard
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
)
func main() {
port := flag.Int("port", 8080, "port to listen on")
dir := flag.String("dir", "", "directory to serve (default: app/web/dist)")
flag.Parse()
// Find the dist directory
serveDir := *dir
if serveDir == "" {
// Try to find it relative to current directory or executable
candidates := []string{
"app/web/dist",
"../app/web/dist",
"../../app/web/dist",
}
// Also check relative to executable
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(exeDir, "app/web/dist"),
filepath.Join(exeDir, "../app/web/dist"),
)
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
serveDir = candidate
break
}
}
if serveDir == "" {
log.Fatal("Could not find dist directory. Use -dir flag to specify.")
}
}
absDir, _ := filepath.Abs(serveDir)
fmt.Printf("Serving %s on http://localhost:%d\n", absDir, *port)
fmt.Println("Press Ctrl+C to stop")
// Create file server with SPA fallback
fs := http.FileServer(http.Dir(serveDir))
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Try to serve the file
path := filepath.Join(serveDir, r.URL.Path)
if _, err := os.Stat(path); os.IsNotExist(err) {
// File doesn't exist, serve index.html for SPA routing
http.ServeFile(w, r, filepath.Join(serveDir, "index.html"))
return
}
fs.ServeHTTP(w, r)
})
addr := fmt.Sprintf(":%d", *port)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatal(err)
}
}

2
pkg/version/version

@ -1 +1 @@
v0.52.1 v0.52.2

Loading…
Cancel
Save