Browse Source
- 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
32 changed files with 4624 additions and 181 deletions
@ -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 |
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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}>×</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> |
||||||
@ -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]; |
||||||
|
} |
||||||
|
} |
||||||
@ -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'); |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue