feat(settings,map,docs): polish Relays UI, drop desktop Near Me, remove NoGood Radio

- Settings -> Relays: rewritten with sectioned cards (NIP-65 / DM inbox / Local /
  Blocked), inline clickable Read/Write chips (no more Edit popover), tone-coded
  sections with icons + counts + help text, cleaner add rows and empty state.
- Map: removed the desktop 'Near Me' button (mobile floating control remains).
- Removed NoGood Radio everywhere: deleted NOGOOD_RADIO_PROPOSAL.md and
  RADIO_PLAYER_GUIDE.md, dropped the README feature line, scrubbed 'DJ NoGood'
  examples from LIVE_MUSIC_PROPOSAL.md.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
SkateSpots 2026-09-26 23:37:32 +07:00
parent b2a6108eeb
commit 020fc11163
15 changed files with 2995 additions and 1572 deletions

View file

@ -88,7 +88,7 @@ Instead of building a complex streaming platform, we **enhance existing events**
│ ┌──────────────────────────────────┐ │
│ │ │ │
│ │ [Embedded Spotify/YouTube] │ │
│ │ DJ NoGood - Skate Mix Vol. 3 │ │
│ │ DJ LowKey - Skate Mix Vol. 3 │ │
│ │ │ │
│ │ ▶️ 🔊────── 🔁 ♡ │ │
│ │ │ │
@ -129,7 +129,6 @@ Instead of building a complex streaming platform, we **enhance existing events**
// NEW: Music integration
["music", "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M"],
["music", "https://soundcloud.com/nogood/skate-mix-vol3"],
["stream", "https://youtube.com/live/abc123xyz"],
["streaming", "https://twitch.tv/veniceskate"]
],
@ -168,7 +167,7 @@ Instead of building a complex streaming platform, we **enhance existing events**
│ ┌───────────────────────────────────────┐ │
│ │ 🎵 Event Soundtrack │ │
│ │ │ │
│ │ DJ NoGood - Skate Mix Vol. 3 │ │
│ │ DJ LowKey - Skate Mix Vol. 3 │ │
│ │ [Spotify Player Embed] │ │
│ │ ▶️ ⏸️ ⏭️ 🔊───────── 2:34 / 45:12 │ │
│ │ │ │

View file

@ -1,781 +0,0 @@
# NoGood Radio Integration - Background Music Player
## Concept
Add a **persistent music player** that plays NoGood Radio while users browse the app on desktop. Think of it like Spotify's mini-player, but for skate culture radio.
---
## How It Would Look (Desktop)
### Option 1: Bottom Bar Player (Spotify-style) ✅ RECOMMENDED
```
┌──────────────────────────────────────────────────────┐
│ Regular App Content │
│ (Map, Spots, Events, etc.) │
│ │
│ │
│ │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ 🎵 NoGood Radio 🔊──── ▶️ ⏸️│
│ Now Playing: Lo-Fi Skate Beats │
└──────────────────────────────────────────────────────┘
↑
Persistent player bar (always visible)
```
**Layout Details:**
```
┌───────────────────────────────────────────────────────────┐
│ 🎧 NoGood Radio | Now Playing: Chill Skate Mix | 🔊──────── | ▶️ ⏸️ 🔇 ✕ │
└───────────────────────────────────────────────────────────┘
↑ ↑ ↑ ↑
Logo/Icon Track info Volume Controls
```
**Features:**
- Sticks to bottom of screen
- Plays while navigating between pages
- Volume slider
- Play/Pause button
- Minimize/Close button
- Shows current track/show name
- Doesn't interrupt browsing
---
### Option 2: Floating Mini Player (Draggable)
```
Regular App Content
┌──────────────────────┐
│ 🎵 NoGood Radio │
│ ▶️ Lo-Fi Beats │
│ 🔊────── [–] [✕] │
└──────────────────────┘
↑
Draggable, can be moved anywhere
```
**Features:**
- Floats above content
- User can drag to preferred position
- Minimizes to small icon
- Expands on hover
- Less obtrusive than bottom bar
---
### Option 3: Sidebar Docked Player
```
┌────────┬─────────────────────────────────┐
│ Nav │ Main Content │
│ Links │ (Spots, Events, etc.) │
│ │ │
│ ────── │ │
│ │ │
│ 🎵 ▶️ │ │
│ NoGood │ │
│ Radio │ │
│ │ │
│ 🔊──── │ │
└────────┴─────────────────────────────────┘
↑
Docked in sidebar (desktop only)
```
**Features:**
- Always visible in navigation
- Compact vertical design
- Doesn't cover content
- Desktop-only (sidebar space)
---
## Detailed UI/UX Design (Option 1 - Bottom Bar)
### Collapsed State (Default)
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎧 NoGood Radio • Now Playing: Sunset Session Mix 🔊──── ▶️ [–]│
└─────────────────────────────────────────────────────────────────┘
```
**Elements:**
- **Logo**: 🎧 or NoGood brand icon
- **Station name**: "NoGood Radio"
- **Track info**: Scrolling text if too long
- **Volume slider**: 🔊──────
- **Play/Pause**: ▶️ / ⏸️
- **Minimize**: [–] (collapses to icon only)
**Colors:**
- Background: Dark gradient (matches app theme)
- Accent: `#ccff00` (brand yellow)
- Text: White/gray
---
### Minimized State (When User Clicks [–])
```
┌──────────────────┐
│ 🎧 ▶️ 🔊──── [+]│
└──────────────────┘
↑
Small bar, bottom-right
```
**Elements:**
- Just icon, play button, volume
- Expand button [+] to restore
- Stays out of the way
---
### Expanded State (When User Clicks for More Info)
```
┌─────────────────────────────────────────────────────┐
│ 🎧 NoGood Radio [–] [✕]│
│ │
│ Now Playing: │
│ 🎵 Sunset Session Mix - Track 04 │
│ │
│ ▶️ ⏸️ | 🔊──────────── | 12:34 / 45:00 │
│ │
│ 📻 About NoGood Radio: │
│ Curated skate culture music 24/7 │
│ │
│ ⚡ Support NoGood │
│ [Zap 100] [Zap 500] [Zap 1000] │
│ │
│ 🔗 [Visit zap.stream] [Share Radio] │
└─────────────────────────────────────────────────────┘
```
**Height**: ~250px (slides up from bottom)
**Features**:
- Show more info about current track
- Zap button to support NoGood
- Links to their platform
- About section
---
## Mobile Experience
**Recommendation**: **Don't show on mobile**
**Why:**
- Mobile users often have their own music playing
- Takes up valuable screen space
- Audio conflicts with phone calls
- Data usage concerns
- Better UX to keep mobile clean
**How to detect**:
```typescript
const isDesktop = window.innerWidth > 768;
// Only render player if isDesktop === true
```
---
## Technical Implementation
### Component Structure
```
App.tsx
└─ Layout.tsx
└─ AppRouter (pages)
└─ RadioPlayer.tsx (NEW - conditional, desktop only)
```
### RadioPlayer Component
```tsx
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Play, Pause, Volume2, VolumeX, Minimize2, Maximize2, X } from 'lucide-react';
export function RadioPlayer() {
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(0.7);
const [isMinimized, setIsMinimized] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
// Stream URL (NoGood Radio on zap.stream)
const streamUrl = "https://stream.zap.stream/nogoodradio";
// Only show on desktop
const isDesktop = window.innerWidth > 768;
if (!isDesktop) return null;
// Audio controls
const togglePlay = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleVolumeChange = (value: number[]) => {
const newVolume = value[0];
setVolume(newVolume);
if (audioRef.current) {
audioRef.current.volume = newVolume;
}
setIsMuted(newVolume === 0);
};
const toggleMute = () => {
if (audioRef.current) {
audioRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
return (
<>
{/* Audio element (hidden) */}
<audio ref={audioRef} src={streamUrl} preload="none" />
{/* Player UI */}
{!isMinimized ? (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gradient-to-r from-black/95 to-gray-900/95 backdrop-blur border-t-2 border-[#ccff00]/30">
<div className="container px-4 py-3 flex items-center gap-4">
{/* Logo & Info */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="text-2xl">🎧</div>
<div className="min-w-0">
<div className="font-bold text-sm">NoGood Radio</div>
<div className="text-xs text-gray-400 truncate">
Now Playing: Lo-Fi Skate Beats
</div>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-3">
{/* Volume */}
<div className="hidden sm:flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={toggleMute}
>
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</Button>
<Slider
value={[volume]}
min={0}
max={1}
step={0.01}
onValueChange={handleVolumeChange}
className="w-24"
/>
</div>
{/* Play/Pause */}
<Button
variant="outline"
size="icon"
className="h-8 w-8 bg-[#ccff00] hover:bg-[#ccff00]/90 text-black border-[#ccff00]"
onClick={togglePlay}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
{/* Minimize */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setIsMinimized(true)}
>
<Minimize2 className="h-4 w-4" />
</Button>
{/* Close */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {/* close player */}}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
) : (
// Minimized version (bottom-right)
<div className="fixed bottom-4 right-4 z-50">
<Button
className="flex items-center gap-2 bg-gradient-to-r from-black/95 to-gray-900/95 backdrop-blur border-2 border-[#ccff00]/30 hover:border-[#ccff00]"
onClick={() => setIsMinimized(false)}
>
<span className="text-xl">🎧</span>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
<span className="text-xs">Radio</span>
</Button>
</div>
)}
</>
);
}
```
---
## Visual Mockups
### Default State (Bottom Bar)
```
┌──────────────────────────────────────────────────────────────────┐
│ │
│ Regular App Content │
│ (Map, Spots, Events, etc.) │
│ │
│ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 🎧 NoGood Radio │
│ Now Playing: Lo-Fi Skate Beats Mix 🔊──── ▶️ [–] [✕] │
└──────────────────────────────────────────────────────────────────┘
↑
Always visible at bottom
Plays while you navigate
```
**Key Features:**
- Height: ~60px (minimal intrusion)
- Dark background with yellow accent
- Sticky positioning (scrolls with page)
- Persistent across page navigation
- Desktop only (hidden on mobile)
---
### Minimized State (Small Floating Button)
```
┌──────────┐
│ 🎧 ▶️ │
│ Radio │
└──────────┘
↑
Bottom-right corner
Click to expand
```
**When to use:**
- User clicks minimize button
- Saves screen space
- Still shows play/pause
- Click to restore full bar
---
### Expanded State (More Info)
```
┌──────────────────────────────────────────────────────────────────┐
│ Regular App Content │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 🎧 NoGood Radio [–] [✕] │
│ │
│ Now Playing: Lo-Fi Skate Beats Mix - Track 04 │
│ │
│ ▶️ ⏸️ 🔊────────────── │
│ │
│ 📻 About NoGood Radio │
│ 24/7 curated music for skate culture. Beats, hip-hop, punk, │
│ and everything that makes you want to push. │
│ │
│ ⚡ Support NoGood Radio │
│ [Zap 100 sats] [Zap 500 sats] [Zap 1000 sats] │
│ │
│ 🔗 [Visit zap.stream] [Share Radio] │
└──────────────────────────────────────────────────────────────────┘
↑
Slides up to ~250px when clicked
Shows more details and zap buttons
```
**When to show:**
- User clicks on player bar
- Shows description
- Zap buttons to support NoGood
- Links to their platform
---
## User Experience Flow
### First Visit (Not Playing)
```
User opens app
↓
Sees bottom bar: "🎧 NoGood Radio [▶️ Play]"
↓
Clicks Play button
↓
Music starts streaming
↓
Continues while browsing
↓
User browses spots, map, events
↓
Music keeps playing (uninterrupted)
```
### Returning Visit (Remembers Preference)
```
User opens app
↓
If they were playing last time:
→ Auto-starts playing
If they closed it last time:
→ Shows collapsed bar
→ User can opt-in to play
```
---
## Technical Details
### Stream Source
**NoGood Radio streams on zap.stream:**
- Platform: https://zap.stream
- Profile: NoGood (@nogood.store)
- Stream format: HLS (HTTP Live Streaming) or Icecast
- Accessible via: Direct stream URL or iframe embed
**How to find stream URL:**
1. Query Nostr for NoGood's kind:30311 events (Live Streaming Event)
2. Extract `streaming` tag with URL
3. Use that URL in HTML5 audio player
**Fallback**: If live stream not available, use:
- Pre-recorded NoGood mixes from SoundCloud
- NoGood's Spotify playlists
- Or disable player gracefully
---
### Audio Player Implementation
```tsx
// Simple HTML5 audio approach
<audio
ref={audioRef}
src="https://stream.zap.stream/nogoodradio" // Example URL
preload="none"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onError={(e) => handleError(e)}
/>
```
**Browser Support:**
- ✅ Chrome, Firefox, Safari, Edge
- ✅ HLS support (most modern browsers)
- ✅ Fallback for older browsers
---
## State Management
### What to Store in localStorage
```typescript
{
"radio-enabled": true, // User wants radio available
"radio-autoplay": false, // Auto-start on load
"radio-volume": 0.7, // Volume level (0-1)
"radio-minimized": false, // UI state
"radio-last-played": 1234567890 // Timestamp
}
```
**Behavior:**
- Remember if user closed radio permanently (don't show again)
- Remember volume preference
- Remember minimized state
- Optional: Remember last position (if draggable)
---
## Design Variants
### Variant A: Minimal (Clean & Simple)
```
┌──────────────────────────────────────────────────┐
│ 🎧 NoGood Radio • Lo-Fi Mix 🔊─── ▶️ ✕ │
└──────────────────────────────────────────────────┘
```
**Pros:**
- Takes minimal space
- Clean and unobtrusive
- Fast to implement
**Cons:**
- Less engaging
- No zap integration
---
### Variant B: Branded (Full Featured) ✅ RECOMMENDED
```
┌─────────────────────────────────────────────────────────────┐
│ ┌───┐ NoGood Radio │
│ │🎧 │ Now Playing: Skate Mix Vol. 3 🔊──── ▶️ ⚡ [–] [✕]│
│ └───┘ Curated skate culture music ^^^ │
└─────────────────────────────────────────────────────────────┘
Zap button!
```
**Pros:**
- Shows branding (NoGood logo/icon)
- Integrated zap support
- Subtitle text for context
- More engaging
**Cons:**
- Slightly taller (~80px vs 60px)
---
### Variant C: Card Style (Modern)
```
┌─────────────────────────────────────────────────────────────┐
│ ╔═══════════════════════════════════════════════════════╗ │
│ ║ 🎧 NoGood Radio [–] [✕] ║ │
│ ║ ║ │
│ ║ 🎵 Sunset Session Mix ║ │
│ ║ ▶️ ⏸️ 🔊────────── ⚡ Zap ║ │
│ ╚═══════════════════════════════════════════════════════╝ │
└─────────────────────────────────────────────────────────────┘
```
**Pros:**
- Looks like a component (card design)
- Polished appearance
- Clear boundaries
**Cons:**
- Takes more space (~100px)
---
## Page-Specific Behavior
### On Different Pages
**Home Page (`/`)**:
- Show player
- Auto-collapse to minimize screen clutter
**Map Page (`/map`)**:
- Show player
- Slightly transparent to not block map
**Event Pages (`/events`, `/event/:id`)**:
- Show player
- If event has its own music, allow switching
**Spot Pages (`/spot/:id`)**:
- Show player normally
- Unobtrusive
**Settings (`/settings`)**:
- Hide player (less distraction)
---
## Advanced Features (Future)
### Track Metadata (If Available)
```
🎵 Now Playing:
Artist: Unknown Brain
Track: Skate or Die
Album: Street Sessions Vol. 2
```
### Playlist Integration
```
📻 Currently playing from:
"NoGood's Weekend Mix" playlist
[View Full Playlist] →
```
### Listener Count
```
🎧 NoGood Radio • 1,234 listeners worldwide
```
### Share What You're Hearing
```
🔗 Share: "I'm listening to NoGood Radio on GoSkateSpots!"
[Share on Twitter] [Share on Nostr]
```
---
## Implementation Steps
### Step 1: Basic Player (3-4 hours)
1. Create RadioPlayer component
2. HTML5 audio element with stream URL
3. Play/pause button
4. Volume slider
5. Close button
### Step 2: UI Polish (2 hours)
6. Add branding (NoGood logo/colors)
7. Smooth animations
8. Responsive sizing
9. Loading states
### Step 3: State Persistence (1 hour)
10. localStorage integration
11. Remember user preferences
12. Auto-restore on page load
### Step 4: Integration (1 hour)
13. Add to Layout component
14. Desktop-only logic
15. Test across all pages
**Total: 6-8 hours**
---
## Questions to Answer
### 1. Stream URL
**Q**: What's the actual NoGood Radio stream URL?
**A**: Need to query Nostr for their kind:30311 event, extract `streaming` tag
### 2. Permissions
**Q**: Does NoGood Radio allow embedding?
**A**: Should check with NoGood or zap.stream ToS
### 3. Attribution
**Q**: How to credit NoGood?
**A**: Show their name, link to their profile, enable zaps
### 4. Fallback
**Q**: What if stream is offline?
**A**: Show "Radio offline" message or hide player
---
## Cost Analysis
### Development Cost
- **Time**: 6-8 hours
- **Complexity**: Low-Medium
- **Maintenance**: Minimal
### Ongoing Costs
- **Bandwidth**: $0 (stream hosted by zap.stream)
- **Storage**: $0 (no files)
- **API**: $0 (just audio streaming)
### User Bandwidth
- **Audio quality**: ~128kbps
- **Per hour**: ~60MB
- **Consideration**: Some users may have data caps
---
## Recommended Design (Final)
### Bottom Bar - Collapsed (Default)
```
┌────────────────────────────────────────────────────────────────────┐
│ Main App Content │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ 🎧 NoGood Radio • Lo-Fi Skate Mix 🔊────── ▶️ ⚡ [–] [✕]│
└────────────────────────────────────────────────────────────────────┘
```
**Height**: 60px
**Background**: `bg-gradient-to-r from-black/95 to-gray-900/95 backdrop-blur`
**Border**: `border-t-2 border-[#ccff00]/30`
**Position**: `fixed bottom-0 left-0 right-0 z-50`
### Elements:
1. **🎧 Icon** - Visual identifier
2. **Station Name** - "NoGood Radio"
3. **Track Info** - "Lo-Fi Skate Mix" (scrolls if long)
4. **Volume** - Slider (🔊────── )
5. **Play/Pause** - Yellow button (brand color)
6. **Zap** - Quick support button
7. **Minimize** - [–] collapses to corner
8. **Close** - [✕] hides completely
### Interactions:
- **Click bar** → Expands to show more info
- **Click Play** → Starts streaming
- **Drag volume** → Adjusts sound
- **Click Zap** → Opens ZapDialog for NoGood
- **Click Minimize** → Collapses to floating button
- **Click Close** → Hides player (remember preference)
---
## Bottom Line
### What You Get:
- 🎵 **Background music** while browsing
- 🎧 **NoGood Radio** streaming 24/7
- 🖥️ **Desktop only** (respects mobile UX)
- ⚡ **Zap integration** (support NoGood)
- 🎨 **Beautiful UI** (matches app design)
- 💾 **Remembers preferences** (volume, state)
- 🔄 **Persistent** (plays across pages)
### Implementation:
- **Time**: 6-8 hours
- **Cost**: $0
- **Complexity**: Low-Medium
- **Maintenance**: Minimal
### The Vibe:
Imagine browsing skate spots with curated beats in the background. That's the feeling. It makes the app feel **alive** and creates a **skate culture atmosphere**.
Like walking into a skate shop with music playing - it just feels right. 🎵🛹
Would you like me to implement this?

View file

@ -1,260 +0,0 @@
# NoGood Radio Player - User Guide
## What It Is
A persistent background music player that streams **NoGood Radio** while you browse the app on desktop. Think Spotify's mini-player, but for skate culture.
---
## How to Use
### Desktop (Automatically Appears)
1. **Open the app on desktop** (any page)
2. **See the player** at the bottom of the screen:
```
🎧 NoGood Radio • Curated skate culture music [▶️ Play]
```
3. **Click Play** ▶️ to start streaming
4. **Music plays** while you browse spots, events, coaches, etc.
5. **Adjust volume** with the slider 🔊────
6. **Minimize** to small floating button [–]
7. **Close** completely if you prefer silence [✕]
### Mobile (Hidden)
The radio player **does not appear on mobile** to:
- Respect data usage
- Avoid conflicts with phone music
- Preserve screen space
- Better UX for mobile users
---
## Player States
### 1. Normal Bar (Default)
```
┌──────────────────────────────────────────────────────────┐
│ 🎧 NoGood Radio • Lo-Fi Mix 🔊──── ▶️ ⚡ [–] [✕] │
└──────────────────────────────────────────────────────────┘
```
**Features:**
- Station name and current track
- Volume slider
- Play/Pause button (yellow)
- Zap button (support NoGood)
- Minimize button
- Close button
- Click anywhere to expand
---
### 2. Expanded Panel (Click Bar)
```
┌──────────────────────────────────────────────────────────┐
│ 🎧 NoGood Radio [–] [✕] │
│ Curated skate culture music │
│ │
│ ▶️ 🔊────────── │
│ │
│ 📻 About NoGood Radio │
│ Curated skate culture music streaming 24/7. │
│ Beats, hip-hop, punk, and everything that makes │
│ you want to push. │
│ │
│ ⚡ Support NoGood Radio │
│ [Zap 100] [Zap 500] [Zap 1000] │
│ │
│ 🔗 Visit zap.stream • NoGood's Profile │
└──────────────────────────────────────────────────────────┘
```
**Shows:**
- Full description
- Quick zap buttons
- Links to NoGood's platforms
- More context about the station
**To collapse**: Click the bar again or press [–]
---
### 3. Minimized Button (Click [–])
```
┌──────────┐
│ 🎧 ▶️ │
│ Radio │
└──────────┘
↑
Bottom-right corner
```
**Features:**
- Small floating button
- Still shows play/pause
- Animated equalizer when playing
- Click to restore full bar
- Stays out of the way
---
## Visual Indicators
### When Playing
- 🟢 Pulsing green dot on radio icon
- 📊 Animated equalizer bars (3 bars bouncing)
- 🔴 "LIVE" badge (red, pulsing)
- ▶️ Pause button visible
### When Paused
- ⏸️ Play button visible
- No animations
- No LIVE badge
### When Loading
- ⏳ Spinner icon
- "Loading..." state
- Controls disabled
### When Error
- ⚠️ Error message shown
- "Stream unavailable" text
- Retry or close options
---
## Controls Reference
| Button | Icon | Function |
|--------|------|----------|
| Play/Pause | ▶️ / ⏸️ | Start/stop streaming |
| Volume | 🔊──── | Adjust sound (0-100%) |
| Mute | 🔇 | Quick mute toggle |
| Zap | ⚡ | Support NoGood with Bitcoin |
| Minimize | [–] | Collapse to small button |
| Close | [✕] | Hide player completely |
| Expand | ⌃ | Show more info & zap options |
---
## Keyboard Shortcuts (Future Enhancement)
Could add:
- **Space**: Play/Pause
- **M**: Mute/Unmute
- **↑/↓**: Volume up/down
- **Esc**: Minimize player
---
## Preferences (Saved Automatically)
The player remembers:
- ✅ **Volume level** - Your preferred volume
- ✅ **Minimized state** - Bar vs floating button
- ✅ **Closed state** - If you closed it, stays closed
- ✅ **Last played** - When you last used it
**Reset**: Click [✕] to close, refresh page and click Play again to reset
---
## Technical Details
### Stream Source
- **Platform**: zap.stream
- **Artist**: NoGood (@nogood.store)
- **Format**: Live audio stream (HLS or Icecast)
- **Fetching**: Queries Nostr for kind:30311 events
- **Fallback**: Uses default stream URL if no events found
### Browser Support
- ✅ Chrome, Firefox, Safari, Edge (modern versions)
- ✅ HTML5 audio element
- ✅ HLS streaming support
- ❌ Internet Explorer (not supported)
### Data Usage
- **Audio quality**: ~128kbps
- **Per hour**: ~60MB
- **Desktop only**: Mobile users protected
---
## Troubleshooting
### No sound?
1. Check browser volume (not muted)
2. Check player volume slider (not at 0)
3. Check if stream is actually live
4. Try refreshing the page
### Player not showing?
1. Are you on desktop? (hidden on mobile)
2. Did you close it? (click area where it was to reopen)
3. Check browser width (must be > 768px)
### Stream error?
- Stream may be offline temporarily
- Try again later
- Check zap.stream status
### Can't zap NoGood?
- Need to be logged in
- Need Lightning wallet set up
- Check wallet connection on /wallet page
---
## The Experience
### What Users Feel
**Before**: Silent browsing, static content
**After**: Curated beats, skate culture atmosphere
**It's like**:
- Walking into a skate shop (music playing)
- Skating with headphones (personal soundtrack)
- Being at a session (DJ in the background)
### Why It Matters
- 🎵 **Sets the mood** - Skate culture vibe
- 🎧 **Engaging** - More time in app
- 💰 **Supports creator** - NoGood gets exposure + zaps
- 🌍 **Community** - Everyone hears the same beats
- 🚀 **Unique** - No other skate app has this
---
## Future Enhancements (Don't Build Yet)
- Show current track metadata (if available)
- Listener count ("1,234 listening worldwide")
- Share button ("Share what you're listening to")
- Multiple radio stations (NoGood, others)
- Playlist view (if stream provides it)
- Request songs feature
- Schedule display (show program times)
- Full NIP-53 live chat integration
- Radio discovery (find other skate music streams)
---
## Credits
- **Radio**: NoGood Radio on zap.stream
- **Artist**: NoGood (@nogood.store)
- **Platform**: zap.stream
- **Protocol**: NIP-53 (Live Activities)
- **Integration**: GoSkateSpots
---
**Enjoy the vibes! 🎵🛹**

View file

@ -19,7 +19,6 @@ A decentralized skateboarding community platform built on Nostr. Discover skate
- 🛍️ **Skate Shops** - Discover local skate shops and support the community
- 🔄 **Buy/Sell Gear** - Marketplace for used skate gear (NIP-99 classified listings)
- 🎒 **My Stash** - Private encrypted gear inventory with location tracking
- 🎵 **NoGood Radio** - 24/7 vibing music (lofi, synthwave, electronica)
- ⚡ **Lightning Zaps** - Send Bitcoin tips to content creators
- 🌤️ **Weather Integration** - Check conditions before you skate
- 🔐 **Nostr Authentication** - Decentralized login with your Nostr keys

BIN
assets/icon-background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/icon-foreground.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
assets/icon-only.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

1585
bun.lock Normal file

File diff suppressed because it is too large Load diff

333
cvm-server/bun.lock Normal file
View file

@ -0,0 +1,333 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "skatespots-cvm-server",
"dependencies": {
"@contextvm/sdk": "latest",
"@modelcontextprotocol/sdk": "latest",
"@noble/hashes": "latest",
"@types/ws": "^8.18.1",
"nostr-tools": "latest",
"ws": "^8.21.3",
"zod": "latest",
},
},
},
"packages": {
"@contextvm/mcp-sdk": ["@contextvm/mcp-sdk@1.30.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "cross-spawn": "^7.0.5", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" } }, "sha512-Z4xyxxxtKnczEgVzLagaUdT1EtoVzc1bjr6kpIfTlw7mP5ESAzXeDUAMO9leIwituphz2ELE6ad9BjW7blDjjw=="],
"@contextvm/sdk": ["@contextvm/sdk@0.13.10", "", { "dependencies": { "@contextvm/mcp-sdk": "^1.30.0", "@noble/hashes": "^2.2.0", "applesauce-relay": "^6.2.1", "canonicalize": "^2.1.0", "nostr-tools": "^2.23.9", "pino": "^10.3.1", "rxjs": "^7.8.2", "ws": "^8.20.0", "zod": "^4.4.3" } }, "sha512-2g0WrkqZxaI47PkwwD0tgMGYZ4tuU9pVmdF85FZykImCGn11pXrsfRACGnfXQPCbwFEF1sP/CAOkGpoeGqt4EQ=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
"@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="],
"@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="],
"@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="],
"@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"applesauce-core": ["applesauce-core@6.2.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw=="],
"applesauce-relay": ["applesauce-relay@6.2.1", "", { "dependencies": { "@noble/hashes": "^2.2.0", "applesauce-core": "^6.2.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"canonicalize": ["canonicalize@2.1.0", "", { "bin": { "canonicalize": "bin/canonicalize.js" } }, "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hash-sum": ["hash-sum@2.0.0", "", {}, "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"nostr-tools": ["nostr-tools@2.24.1", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-KdrKjC74n/rr6J3eCSfZj8dcbZFvolHYe4S22SefNZ5YWbhHiB0KL/mmJjEZ0u6B9mZK0YcQtl+WQ46KzwapeQ=="],
"nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@contextvm/sdk/ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
"@noble/curves/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@scure/bip32/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@scure/bip39/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"applesauce-core/nostr-tools": ["nostr-tools@2.19.4", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg=="],
"applesauce-relay/nostr-tools": ["nostr-tools@2.19.4", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg=="],
"body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"nostr-tools/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"applesauce-core/nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="],
"applesauce-core/nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
"applesauce-core/nostr-tools/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
"applesauce-core/nostr-tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
"applesauce-core/nostr-tools/@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="],
"applesauce-core/nostr-tools/@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="],
"applesauce-relay/nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="],
"applesauce-relay/nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
"applesauce-relay/nostr-tools/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
"applesauce-relay/nostr-tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
"applesauce-relay/nostr-tools/@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="],
"applesauce-relay/nostr-tools/@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="],
"applesauce-core/nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"applesauce-core/nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="],
"applesauce-core/nostr-tools/@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"applesauce-core/nostr-tools/@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"applesauce-relay/nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"applesauce-relay/nostr-tools/@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="],
"applesauce-relay/nostr-tools/@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"applesauce-relay/nostr-tools/@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"applesauce-core/nostr-tools/@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
"applesauce-relay/nostr-tools/@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
}
}

18
cvm-server/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "skatespots-cvm-server",
"version": "1.0.0",
"description": "SkateSpots CVM (ContextVM) server — structured MCP tools over Nostr",
"scripts": {
"start": "bun run server.ts",
"dev": "bun --watch run server.ts"
},
"dependencies": {
"@contextvm/sdk": "latest",
"@modelcontextprotocol/sdk": "latest",
"@noble/hashes": "latest",
"@types/ws": "^8.18.1",
"nostr-tools": "latest",
"ws": "^8.21.3",
"zod": "latest"
}
}

622
cvm-server/server.ts.bak Normal file
View file

@ -0,0 +1,622 @@
/**
* SkateSpots CVM Server
* ─────────────────────────────────────────────────────────────────────────────
* Exposes structured MCP tools over Nostr (ContextVM protocol).
* AI agents discover this server on Nostr and call its tools to query
* skateboarding spots, events, coaches, filmers, and gear listings.
*
* Tools:
* • find_spots — filter by city, difficulty, features
* • find_events — upcoming skate events by location/date
* • find_coaches — coaches, filmers, editors by skill/location
* • find_gear — marketplace listings by category/condition
* • get_community_stats — live counts of all community content
*
* Deploy: bun run server.ts (or via PM2, see README)
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
NostrServerTransport,
PrivateKeySigner,
ApplesauceRelayPool,
} from "@contextvm/sdk";
import { z } from "zod";
import { SimplePool } from "nostr-tools";
import type { Filter } from "nostr-tools";
// ─── Config ──────────────────────────────────────────────────────────────────
const NSEC_HEX = process.env.CVM_SERVER_NSEC_HEX;
if (!NSEC_HEX) {
console.error("❌ Missing CVM_SERVER_NSEC_HEX in environment. Check your .env file.");
process.exit(1);
}
const NOSTR_RELAYS = (process.env.NOSTR_RELAYS || "wss://goskatespots.nostr1.com")
.split(",")
.map((r) => r.trim())
.filter(Boolean);
const CVM_RELAY = process.env.CVM_RELAY || "wss://relay.contextvm.org";
// ─── Nostr data pool ─────────────────────────────────────────────────────────
// Used to query skate data from the community relays
const dataPool = new SimplePool();
async function queryNostr(filters: Filter[], timeoutMs = 8000): Promise<any[]> {
// nostr-tools v2: use querySync which handles eose cleanly
const allEvents: any[] = [];
await Promise.all(
filters.map(async (filter) => {
try {
const events = await dataPool.querySync(NOSTR_RELAYS, filter, {
maxWait: timeoutMs,
});
allEvents.push(...events);
} catch (err) {
console.error("[queryNostr] error:", err);
}
})
);
return allEvents;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getTag(event: any, name: string): string | undefined {
return event.tags.find((t: string[]) => t[0] === name)?.[1];
}
function getTags(event: any, name: string): string[] {
return event.tags
.filter((t: string[]) => t[0] === name)
.map((t: string[]) => t[1])
.filter(Boolean);
}
/** Deduplicate addressable events (kind 30xxx) — keep newest per pubkey+kind+d */
function deduplicateAddressable(events: any[]): any[] {
const seen = new Map<string, any>();
for (const e of events) {
const d = getTag(e, "d") ?? "";
const key = `${e.pubkey}:${e.kind}:${d}`;
const existing = seen.get(key);
if (!existing || e.created_at > existing.created_at) {
seen.set(key, e);
}
}
return Array.from(seen.values());
}
function buildNaddr(event: any): string {
return `${event.kind}:${event.pubkey}:${getTag(event, "d") ?? ""}`;
}
// ─── MCP Server ──────────────────────────────────────────────────────────────
const mcpServer = new McpServer({
name: "SkateSpots",
version: "1.0.0",
});
// ── Tool: find_spots ─────────────────────────────────────────────────────────
mcpServer.registerTool(
"find_spots",
{
description:
"Find skateboarding spots worldwide. Filter by city, country, difficulty level, or specific features like rails, ledges, stairs, manual pads, bowls, halfpipes. Returns spot names, locations, features, and deep-link identifiers.",
inputSchema: {
city: z.string().optional().describe("City name, e.g. 'Barcelona', 'Tokyo'"),
country: z.string().optional().describe("Country name or code, e.g. 'Spain', 'ES'"),
difficulty: z
.enum(["beginner", "intermediate", "advanced", "all"])
.optional()
.default("all")
.describe("Skill level required for the spot"),
features: z
.array(z.string())
.optional()
.describe(
"Required features, e.g. ['rails', 'ledges', 'stairs', 'manual pad', 'bowl', 'halfpipe']"
),
search: z
.string()
.optional()
.describe("Free text search in spot name or description"),
limit: z.number().min(1).max(50).optional().default(10),
},
},
async ({ city, country, difficulty, features, search, limit }) => {
// Query all kind 30818 events; also try with #t filter for relays that support it
const raw = await queryNostr([{ kinds: [30818], limit: 500 }]);
let spots = deduplicateAddressable(raw);
// Only keep valid skate spots (must have 't: skatespot' tag — same rule as the app)
spots = spots.filter((e) =>
e.tags.some((t: string[]) => t[0] === "t" && t[1] === "skatespot")
);
if (difficulty && difficulty !== "all") {
spots = spots.filter(
(e) => getTag(e, "difficulty")?.toLowerCase() === difficulty
);
}
if (city) {
const q = city.toLowerCase();
spots = spots.filter(
(e) =>
// 'city' dedicated tag (if present)
(getTag(e, "city") ?? "").toLowerCase().includes(q) ||
// 'location' free-text field (most spots have this)
(getTag(e, "location") ?? "").toLowerCase().includes(q) ||
// 'title' tag (spot name may include city)
(getTag(e, "title") ?? "").toLowerCase().includes(q) ||
// Event content / description
e.content.toLowerCase().includes(q)
);
}
if (country) {
const q = country.toLowerCase();
spots = spots.filter(
(e) =>
(getTag(e, "country") ?? "").toLowerCase().includes(q) ||
(getTag(e, "location") ?? "").toLowerCase().includes(q) ||
e.content.toLowerCase().includes(q)
);
}
if (features && features.length > 0) {
spots = spots.filter((e) => {
// Check 'feature' tags AND generic 't' tags (app uses 't' for features)
const spotFeatures = [
...getTags(e, "feature"),
...getTags(e, "t"),
].map((f) => f.toLowerCase());
const spotContent = e.content.toLowerCase();
return features.some(
(f) =>
spotFeatures.some((sf) => sf.includes(f.toLowerCase())) ||
spotContent.includes(f.toLowerCase())
);
});
}
if (search) {
const q = search.toLowerCase();
spots = spots.filter(
(e) =>
// App publishes 'title' tag (not 'name') — check both for compatibility
(getTag(e, "title") ?? "").toLowerCase().includes(q) ||
(getTag(e, "name") ?? "").toLowerCase().includes(q) ||
e.content.toLowerCase().includes(q)
);
}
spots.sort((a, b) => b.created_at - a.created_at);
spots = spots.slice(0, limit!);
const result = spots.map((e) => ({
// App uses 'title' tag; fall back to 'name' for legacy events
name: getTag(e, "title") ?? getTag(e, "name") ?? "Unnamed Spot",
description: e.content.slice(0, 300),
city: getTag(e, "city") ?? getTag(e, "c"),
country: getTag(e, "country"),
location: getTag(e, "location"),
difficulty: getTag(e, "difficulty"),
category: getTag(e, "category"),
// App uses 't' tags for features (street, park, rails, etc.)
features: getTags(e, "t").filter((t) => t !== "skatespot"),
geohash: getTag(e, "g"),
// Support both 'image' (legacy) and 'imeta' (NIP-92, new)
image: getTag(e, "image") ??
(() => {
const imeta = e.tags.find((t: string[]) => t[0] === "imeta");
const urlPart = imeta?.find((p: string) => p.startsWith("url "));
return urlPart ? urlPart.replace("url ", "") : undefined;
})(),
author_pubkey: e.pubkey,
naddr: buildNaddr(e),
url: `https://skate-spots.org/spot/${e.pubkey}/${getTag(e, "d") ?? ""}`,
created_at: new Date(e.created_at * 1000).toISOString(),
}));
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ count: result.length, spots: result }, null, 2),
},
],
};
}
);
// ── Tool: find_events ────────────────────────────────────────────────────────
mcpServer.registerTool(
"find_events",
{
description:
"Find upcoming skateboarding events — competitions, meetups, sessions, jams, contests. Filter by location or date range. Only returns future events by default.",
inputSchema: {
city: z.string().optional().describe("City or location keyword"),
country: z.string().optional(),
event_type: z
.string()
.optional()
.describe("Type keyword: competition, meetup, session, contest, jam"),
from_date: z
.string()
.optional()
.describe("ISO date, e.g. '2025-06-01'. Defaults to today."),
to_date: z.string().optional().describe("ISO date, e.g. '2025-12-31'"),
limit: z.number().min(1).max(50).optional().default(10),
},
},
async ({ city, country, event_type, from_date, to_date, limit }) => {
const now = Math.floor(Date.now() / 1000);
const filters: Filter[] = [{ kinds: [31923], limit: 500, since: now }];
const raw = await queryNostr(filters);
let events = deduplicateAddressable(raw);
if (city) {
const q = city.toLowerCase();
events = events.filter(
(e) =>
(getTag(e, "location") ?? "").toLowerCase().includes(q) ||
(getTag(e, "city") ?? "").toLowerCase().includes(q) ||
e.content.toLowerCase().includes(q)
);
}
if (country) {
const q = country.toLowerCase();
events = events.filter((e) =>
(getTag(e, "location") ?? "").toLowerCase().includes(q)
);
}
if (event_type) {
const q = event_type.toLowerCase();
events = events.filter(
(e) =>
getTags(e, "t").some((t) => t.toLowerCase().includes(q)) ||
(getTag(e, "title") ?? "").toLowerCase().includes(q) ||
e.content.toLowerCase().includes(q)
);
}
if (from_date) {
const fromTs = Math.floor(new Date(from_date).getTime() / 1000);
events = events.filter(() => {
// start tag is unix timestamp for kind 31923
return true; // already filtered by since: now above
});
void fromTs;
}
if (to_date) {
const toTs = Math.floor(new Date(to_date).getTime() / 1000);
events = events.filter((e) => {
const start = parseInt(getTag(e, "start") ?? "0");
return !start || start <= toTs;
});
}
// Sort by start date ascending
events.sort((a, b) => {
const startA = parseInt(getTag(a, "start") ?? "0");
const startB = parseInt(getTag(b, "start") ?? "0");
return startA - startB;
});
events = events.slice(0, limit!);
const result = events.map((e) => ({
title: getTag(e, "title") ?? "Untitled Event",
description: e.content.slice(0, 300),
location: getTag(e, "location"),
start: getTag(e, "start")
? new Date(parseInt(getTag(e, "start")!) * 1000).toISOString()
: null,
end: getTag(e, "end")
? new Date(parseInt(getTag(e, "end")!) * 1000).toISOString()
: null,
type: getTags(e, "t"),
image: getTag(e, "image"),
author_pubkey: e.pubkey,
naddr: buildNaddr(e),
url: `https://skate-spots.org/event/${e.pubkey}/${getTag(e, "d") ?? ""}`,
}));
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ count: result.length, events: result }, null, 2),
},
],
};
}
);
// ── Tool: find_coaches ───────────────────────────────────────────────────────
mcpServer.registerTool(
"find_coaches",
{
description:
"Find skateboarding coaches, filmers, and video editors in The Lab. Filter by role, skills, or location.",
inputSchema: {
role: z
.enum(["coach", "filmer", "editor", "any"])
.optional()
.default("any")
.describe("coach = skateboarding instructor, filmer = videographer, editor = video editor"),
skills: z
.array(z.string())
.optional()
.describe(
"Skill specializations, e.g. ['kickflip', 'grind', 'vert', 'bowl', 'street', 'film', 'edit']"
),
location: z.string().optional().describe("City or country"),
limit: z.number().min(1).max(30).optional().default(10),
},
},
async ({ role, skills, location, limit }) => {
const kindMap: Record<string, number[]> = {
coach: [35001],
filmer: [35002],
editor: [35003],
any: [35001, 35002, 35003],
};
const kinds = kindMap[role ?? "any"];
const raw = await queryNostr([{ kinds, limit: 500 }]);
let profiles = deduplicateAddressable(raw);
if (skills && skills.length > 0) {
profiles = profiles.filter((e) => {
const profileSkills = getTags(e, "skill").map((s) => s.toLowerCase());
const bio = e.content.toLowerCase();
return skills.some(
(s) =>
profileSkills.some((ps) => ps.includes(s.toLowerCase())) ||
bio.includes(s.toLowerCase())
);
});
}
if (location) {
const q = location.toLowerCase();
profiles = profiles.filter(
(e) =>
(getTag(e, "location") ?? "").toLowerCase().includes(q) ||
(getTag(e, "city") ?? "").toLowerCase().includes(q) ||
e.content.toLowerCase().includes(q)
);
}
profiles = profiles.slice(0, limit!);
const roleLabel = (kind: number) =>
kind === 35001 ? "coach" : kind === 35002 ? "filmer" : "editor";
const result = profiles.map((e) => ({
role: roleLabel(e.kind),
pubkey: e.pubkey,
bio: e.content.slice(0, 300),
location: getTag(e, "location"),
skills: getTags(e, "skill"),
experience: getTag(e, "experience"),
lightning_address: getTag(e, "lud16") ?? getTag(e, "contact"),
naddr: buildNaddr(e),
url: `https://skate-spots.org/coach/${e.pubkey}/${getTag(e, "d") ?? ""}`,
}));
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ count: result.length, profiles: result }, null, 2),
},
],
};
}
);
// ── Tool: find_gear ──────────────────────────────────────────────────────────
mcpServer.registerTool(
"find_gear",
{
description:
"Find used skate gear for sale or trade on the SkateSpots marketplace. Filter by category, condition, price, or keyword.",
inputSchema: {
category: z
.string()
.optional()
.describe(
"Category keyword: deck, trucks, wheels, shoes, bearings, complete, helmet, pads, apparel"
),
condition: z
.enum(["new", "like_new", "good", "fair", "any"])
.optional()
.default("any"),
max_price_sats: z
.number()
.optional()
.describe("Maximum price in satoshis (for sats-priced listings)"),
search: z.string().optional().describe("Brand, model, or keyword search"),
limit: z.number().min(1).max(50).optional().default(10),
},
},
async ({ category, condition, max_price_sats, search, limit }) => {
const filters: Filter[] = [
{ kinds: [30402], limit: 500, "#t": ["skateboarding"] },
];
const raw = await queryNostr(filters);
let listings = deduplicateAddressable(raw);
if (category) {
const q = category.toLowerCase();
listings = listings.filter(
(e) =>
getTags(e, "t").some((t) => t.toLowerCase().includes(q)) ||
(getTag(e, "title") ?? "").toLowerCase().includes(q) ||
e.content.toLowerCase().includes(q)
);
}
if (condition && condition !== "any") {
listings = listings.filter((e) =>
(getTag(e, "condition") ?? "")
.toLowerCase()
.replace(/\s+/g, "_")
.includes(condition)
);
}
if (max_price_sats) {
listings = listings.filter((e) => {
const price = parseFloat(getTag(e, "price") ?? "");
const currency = (getTag(e, "currency") ?? "").toLowerCase();
if (!isNaN(price) && (currency === "sat" || currency === "sats")) {
return price <= max_price_sats;
}
return true; // don't filter non-sats listings
});
}
if (search) {
const q = search.toLowerCase();
listings = listings.filter(
(e) =>
(getTag(e, "title") ?? "").toLowerCase().includes(q) ||
e.content.toLowerCase().includes(q)
);
}
listings.sort((a, b) => b.created_at - a.created_at);
listings = listings.slice(0, limit!);
const result = listings.map((e) => ({
title: getTag(e, "title") ?? "Untitled Listing",
description: e.content.slice(0, 300),
price: getTag(e, "price"),
currency: getTag(e, "currency"),
condition: getTag(e, "condition"),
categories: getTags(e, "t"),
image: getTag(e, "image") ?? getTag(e, "thumb"),
seller_pubkey: e.pubkey,
naddr: buildNaddr(e),
created_at: new Date(e.created_at * 1000).toISOString(),
}));
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
{ count: result.length, listings: result },
null,
2
),
},
],
};
}
);
// ── Tool: get_community_stats ────────────────────────────────────────────────
mcpServer.registerTool(
"get_community_stats",
{
description:
"Get live SkateSpots community statistics: total spots, upcoming events, coaches, filmers, editors, and gear listings across all connected relays.",
inputSchema: {},
},
async () => {
const [spots, events, coaches, filmers, editors, gear] = await Promise.all([
queryNostr([{ kinds: [30818], limit: 2000 }]),
queryNostr([{ kinds: [31923], limit: 2000, since: Math.floor(Date.now() / 1000) }]),
queryNostr([{ kinds: [35001], limit: 2000 }]),
queryNostr([{ kinds: [35002], limit: 2000 }]),
queryNostr([{ kinds: [35003], limit: 2000 }]),
queryNostr([{ kinds: [30402], "#t": ["skateboarding"], limit: 2000 }]),
]);
return {
content: [
{
type: "text" as const,
text: JSON.stringify(
{
spots: deduplicateAddressable(spots).length,
upcoming_events: deduplicateAddressable(events).length,
coaches: deduplicateAddressable(coaches).length,
filmers: deduplicateAddressable(filmers).length,
editors: deduplicateAddressable(editors).length,
gear_listings: deduplicateAddressable(gear).length,
relays_queried: NOSTR_RELAYS,
queried_at: new Date().toISOString(),
},
null,
2
),
},
],
};
}
);
// ─── Start ───────────────────────────────────────────────────────────────────
async function main() {
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(" 🛹 SkateSpots CVM Server");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(" Data relays :", NOSTR_RELAYS.join(", "));
console.log(" CVM relay :", CVM_RELAY);
const signer = new PrivateKeySigner(NSEC_HEX!);
const pubkey = await signer.getPublicKey();
console.log(" Server npub :", pubkey);
// All relays combined for the CVM transport (discovery + data)
const allRelays = [CVM_RELAY, ...NOSTR_RELAYS];
const cvmRelayPool = new ApplesauceRelayPool(allRelays);
const transport = new NostrServerTransport({
signer,
relayHandler: cvmRelayPool,
isAnnouncedServer: true,
serverInfo: {
name: "SkateSpots Data Server",
about:
"Structured query access to the global SkateSpots database on Nostr. Find skate spots, events, coaches, filmers, video editors, and gear listings.",
picture: "https://skate-spots.org/images/logo.png",
website: "https://skate-spots.org",
},
});
await mcpServer.connect(transport);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(" ✅ Server running — listening for agents on Nostr");
console.log("");
console.log(" Verify discovery:");
console.log(` nak req -k 11316 -a ${pubkey} ${CVM_RELAY}`);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
}
main().catch((e) => {
console.error("❌ Fatal:", e);
process.exit(1);
});

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
resources/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because it is too large Load diff

View file

@ -1444,29 +1444,6 @@ export default function MapPage() {
compact
/>
{/* Near Me — desktop parity (mobile has the floating control) */}
<Button
variant="outline"
size="sm"
onClick={() => {
if (nearMeError === 'denied' && !isNativeGeolocation()) {
setShowLocationHelp(true);
return;
}
handleNearMe();
}}
disabled={nearMeLoading}
title={nearMeError === 'denied' ? 'Enable location access' : 'Find my location'}
className="hidden md:inline-flex h-8 gap-1.5 border-white/20 flex-shrink-0 touch-manipulation"
>
{nearMeError === 'denied' ? (
<LocateOff className="h-4 w-4 text-red-400" />
) : (
<LocateFixed className={cn('h-4 w-4', userLocation ? 'text-[#ccff00]' : 'text-gray-300')} />
)}
<span className="hidden lg:inline">Near Me</span>
</Button>
{/* Clear filters */}
{hasActiveFilters && (
<Button