SkateSpots/NIP.md
shakespeare.diy 2a55d1b3df Crew Agent Phases 0–2: opt-in AI guide (🐝 Sasquatch) per crew
Phase 0 — identity (lib/crewAgent.ts):
- Deterministic per-crew agent keypair derived from the crew coordinate
  (sha256 of a public salt + coord). Documented security tradeoff: the key
  is reproducible by design and holds no funds — used only for the agent to
  speak for itself. Includes kind-0 profile builder (bot:true, NIP-24) and
  a signAsCrewAgent helper for Phase 3.

Phase 1 — opt-in toggle:
- SkateCrew.agentEnabled parsed from a queryable ["t","crew-agent"] marker.
- "Crew Agent" toggle (Beta) in CrewForm; publishes the marker when on.
- NIP.md documents the crew-agent tag and the agent identity derivation.

Phase 2 — crew-scoped Sasquatch with receipts:
- lib/sasquatchBrain.ts: shared AI caller (model fallback) + tool-call parser.
- useCrewAgentChat: reuses the CVM data tools, seeded with crew context
  (name/location) so "near us" resolves to the crew's turf. Ephemeral/local,
  no keys, no publishing yet.
- CrewAgentPanel: chat dialog rendering agent answers PLUS structured receipt
  cards (spots / events / Lab pros / gear / crews) linking into the app.
- CrewDetailPage: "Ask Sasquatch" card + panel, shown only when the crew
  enabled the agent.

Also: Lab category tabs → fixed 5-across grid (no horizontal scroll). Bump 1.0.68.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 19:26:19 +07:00

1993 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SkateSpots Protocol
This document defines the Nostr event kinds used by the SkateSpots application for managing skateboarding locations, shops, and related content.
## Skate Shops (kind 30017)
Skate shops use the NIP-15 marketplace "stall" kind (30017) extended with skateboarding-specific tags. This enables interoperability with existing Nostr marketplace clients.
### Event Format
```json
{
"kind": 30017,
"content": "<shop description>",
"tags": [
["d", "<unique-identifier>"],
["name", "<shop name>"],
["g", "<geohash>"],
["location", "<shop address>"],
["t", "skateshop"],
["phone", "<phone number>"],
["email", "<email address>"],
["website", "<website URL>"],
["instagram", "<instagram handle>"],
["hours", "<hours of operation>"],
["imeta", "url <image-url>", "m image/jpeg"],
["alt", "Skate shop: <name>"]
]
}
```
### Required Tags
| Tag | Description |
|-----|-------------|
| `d` | Unique identifier for the shop |
| `name` | Shop name |
| `g` | Geohash encoding the shop's coordinates |
| `t` | Must include `skateshop` to identify as a skate shop |
| `location` | Physical shop address |
### Optional Tags
| Tag | Description |
|-----|-------------|
| `phone` | Contact phone number |
| `email` | Contact email address |
| `website` | Shop website URL |
| `instagram` | Instagram handle |
| `hours` | Hours of operation (freeform text) |
| `noffer` | Optional CLINK Offer code (`noffer1…`) for self-custodial payments — see the CLINK Payments section |
| `imeta` | Image metadata per NIP-92 |
| `lat` | Latitude (decimal) |
| `lon` | Longitude (decimal) |
### Shop Ownership Verification
Shops can be verified using **NIP-05** domain verification. A shop is considered verified when:
1. The shop has a `website` tag with a domain (e.g., `supremenewyork.com`)
2. The shop owner's NIP-05 identifier matches the shop's domain (e.g., `owner@supremenewyork.com`)
This proves the shop owner controls the domain, establishing ownership.
### Example
```json
{
"kind": 30017,
"pubkey": "abc123...",
"created_at": 1703001600,
"content": "Los Angeles' premier skateboard shop since 1994. Full selection of decks, trucks, wheels, and streetwear.",
"tags": [
["d", "supreme-la"],
["name", "Supreme Los Angeles"],
["g", "9q5ctr"],
["location", "411 N Fairfax Ave, Los Angeles, CA 90036"],
["t", "skateshop"],
["phone", "+1-323-655-6205"],
["website", "https://www.supremenewyork.com"],
["instagram", "@supremenewyork"],
["hours", "Mon-Sat 11AM-7PM, Sun 12PM-6PM"],
["imeta", "url https://example.com/shop-front.jpg", "m image/jpeg"],
["lat", "34.0737"],
["lon", "-118.3614"],
["alt", "Skate shop: Supreme Los Angeles"]
],
"id": "...",
"sig": "..."
}
```
### Querying Shops
```json
{
"kinds": [30017],
"#t": ["skateshop"]
}
```
## Skate Spot (kind 30818)
A skate spot is an addressable event that represents a skateboarding location anywhere in the world. Users can create, share, and discover skate spots.
### Event Format
```json
{
"kind": 30818,
"content": "<detailed description of the skate spot>",
"tags": [
["d", "<unique-identifier>"],
["title", "<name of the skate spot>"],
["g", "<geohash>"],
["location", "<human-readable address>"],
["category", "<spot-category>"],
["t", "skatespot"],
["t", "<feature-tag>"],
["t", "<surface-tag>"],
["t", "<vibe-tag>"],
["imeta", "url <image-url>", "m image/jpeg"],
["alt", "Skate spot: <title>"]
]
}
```
### Required Tags
| Tag | Description |
|-----|-------------|
| `d` | Unique identifier for the spot (UUID or slug) |
| `title` | Human-readable name of the skate spot |
| `g` | Geohash encoding the spot's geographic coordinates (minimum 6 characters for ~1km precision) |
| `category` | Primary spot category (see Spot Categories below) |
| `t` | Must include `skatespot` for discoverability |
| `alt` | Human-readable description for clients that don't understand this kind (NIP-31) |
### Optional Tags
| Tag | Description |
|-----|-------------|
| `location` | Human-readable address or location description |
| `imeta` | Image metadata per NIP-92 |
| `lat` | Latitude (decimal) |
| `lon` | Longitude (decimal) |
| `access` | Access type: `public`, `private`, `locals` |
| `contact` | Contact info for the spot |
| `t` | Freeform hashtags for features, surfaces, vibes (max 20) |
### Spot Categories
The `category` tag defines the primary type of spot. Only ONE category per spot:
| Value | Description |
|-------|-------------|
| `street` | Street spots (stairs, rails, ledges, etc.) |
| `park` | Skateparks (public or private) |
| `diy` | DIY/homemade spots |
| `plaza` | Public plazas with skate-friendly architecture |
### Freeform Tags (`t` tags)
In addition to the required `skatespot` tag, spots can have up to **20 freeform tags** for detailed categorization. Tags are stored lowercase without `#` prefix.
#### Feature Tags (obstacles/terrain)
`ledge`, `rail`, `handrail`, `flatrail`, `kinked-rail`, `rainbow-rail`, `stairs`, `gap`, `bank`, `quarter`, `halfpipe`, `bowl`, `pool`, `miniramp`, `vert`, `hubba`, `manual-pad`, `flatground`, `curb`, `bump`, `pyramid`, `euro-gap`, `kicker`, `hip`, `spine`, `cradle`, `snake-run`, `pump-track`, `wallride`, `drop`, `loading-dock`
#### Surface Tags (materials)
`concrete`, `marble`, `granite`, `brick`, `metal`, `wood`, `asphalt`, `tile`, `smooth`, `rough`, `crusty`, `slippery`, `painted`
#### Size Tags
`small`, `medium`, `large`, `massive`, `mellow`, `steep`, `low`, `high`, `long`, `short`
#### Stair Counts
`2-stair`, `3-stair`, `4-stair`, `5-stair`, `6-stair`, `7-stair`, `8-stair`, `9-stair`, `10-stair`, `10-plus-stair`
#### Vibe/Condition Tags
`chill`, `famous`, `classic`, `hidden-gem`, `locals-only`, `beginner-friendly`, `intermediate`, `advanced`, `pro-spot`, `night-spot`, `covered`, `lit`, `shaded`, `security`, `bust-factor`, `skate-stopped`, `well-maintained`, `run-down`
#### Location Context Tags
`downtown`, `industrial`, `residential`, `campus`, `waterfront`, `rooftop`, `underground`, `parking-garage`, `school`, `business-district`
### Tag Normalization Rules
1. **Lowercase**: All tags stored in lowercase (`Ledge` → `ledge`)
2. **No spaces**: Use hyphens for multi-word tags (`night spot` → `night-spot`)
3. **No special characters**: Alphanumeric and hyphens only
4. **No `#` prefix**: Tags stored without hash (`ledge` not `#ledge`)
5. **Max 20 tags**: Prevent spam/over-tagging
### Example
```json
{
"kind": 30818,
"pubkey": "abc123...",
"created_at": 1703001600,
"content": "Classic LA spot with a perfect 10-stair and hubba ledge. Usually busy on weekends. Security is chill during the day but kicks out after 6pm.",
"tags": [
["d", "hollywood-high"],
["title", "Hollywood High School"],
["g", "9q5ctr"],
["location", "1521 N Highland Ave, Los Angeles, CA"],
["category", "street"],
["access", "public"],
["t", "skatespot"],
["t", "stairs"],
["t", "10-stair"],
["t", "hubba"],
["t", "ledge"],
["t", "marble"],
["t", "famous"],
["t", "classic"],
["t", "pro-spot"],
["t", "security"],
["imeta", "url https://example.com/hollywood-high.jpg", "m image/jpeg"],
["lat", "34.1017"],
["lon", "-118.3385"],
["alt", "Skate spot: Hollywood High School"]
],
"id": "...",
"sig": "..."
}
```
## Querying Skate Spots
### All Spots
```json
{
"kinds": [30818],
"#t": ["skatespot"]
}
```
### Spots by Feature Tag
```json
{
"kinds": [30818],
"#t": ["skatespot", "ledge"]
}
```
### Spots by Multiple Tags
```json
{
"kinds": [30818],
"#t": ["skatespot", "marble", "ledge"]
}
```
### Spots by Geohash Prefix (Area Search)
```json
{
"kinds": [30818],
"#g": ["9q5"]
}
```
Note: Relays may or may not support geohash prefix queries. Clients should fall back to client-side filtering if needed.
### Client-Side Category Filtering
Since `category` is not a single-letter tag, filter by category client-side after fetching spots:
```typescript
const streetSpots = spots.filter(spot =>
spot.tags.find(([k, v]) => k === 'category' && v === 'street')
);
```
## Skate Events
Skateboarding events use the standard NIP-52 time-based calendar event (kind 31923) with the hashtag `#skateboarding`:
```json
{
"kind": 31923,
"content": "<event description>",
"tags": [
["d", "<unique-id>"],
["title", "<event name>"],
["start", "<unix-timestamp>"],
["end", "<unix-timestamp>"],
["g", "<geohash>"],
["location", "<venue address>"],
["t", "skateboarding"],
["t", "<event-type>"]
]
}
```
### Event Types
Common `t` tags for skate events:
- `contest` - Skateboarding competitions
- `demo` - Pro demos
- `session` - Group sessions
- `workshop` - Lessons or workshops
- `premiere` - Video premieres
- `tradeshow` - Industry events
- `gameskate` - Game of S.K.A.T.E. competitions
#### Music & Culture Events
Skateboarding and music culture are deeply connected. These event types support concerts, jams, and cultural gatherings:
- `concert` - Live music performances at skate venues
- `jam` - Skate jams with music, art, and community vibes
- `festival` - Multi-day skateboarding festivals (e.g., Tampa Pro, Dew Tour)
- `popup` - Pop-up events, brand activations, and temporary installations
Events can have multiple type tags. For example, a video premiere with a live band would have both `premiere` and `concert` tags.
## Social Interaction
Comments and reactions to skate spots and events use standard Nostr kinds:
- **Reactions** (kind 7): React to spots or events with emoji
- **Comments** (kind 1111): Comment on spots or events per NIP-22
- **Zaps** (kind 9735): Send sats to spot creators or event organizers
## Spot Collections (kind 30003)
Spot collections use NIP-51 "Bookmark sets" (kind 30003) to allow users to organize skate spots into personalized lists. This enables interoperability with other Nostr bookmark clients.
### Event Format
```json
{
"kind": 30003,
"content": "<collection description>",
"tags": [
["d", "<unique-identifier>"],
["title", "<collection name>"],
["t", "skatespots"],
["description", "<collection description>"],
["image", "<cover image URL>"],
["a", "30818:<pubkey>:<spot-d-tag>"],
["a", "30818:<pubkey>:<spot-d-tag>"],
["alt", "Spot collection: <title>"]
]
}
```
### Required Tags
| Tag | Description |
|-----|-------------|
| `d` | Unique identifier for the collection |
| `title` | Collection name |
| `t` | Must include `skatespots` to identify as a spot collection |
### Optional Tags
| Tag | Description |
|-----|-------------|
| `description` | Description of the collection |
| `image` | Cover image URL |
| `a` | Reference to a skate spot (format: `30818:pubkey:d-tag`) |
| `alt` | Human-readable description for clients that don't understand this kind |
### Example
```json
{
"kind": 30003,
"pubkey": "abc123...",
"created_at": 1703001600,
"content": "My favorite spots from my Barcelona trip in summer 2024",
"tags": [
["d", "barcelona-trip-2024-1703001600"],
["title", "Barcelona Trip 2024"],
["t", "skatespots"],
["description", "My favorite spots from my Barcelona trip in summer 2024"],
["image", "https://example.com/barcelona-cover.jpg"],
["a", "30818:abc123:macba"],
["a", "30818:def456:parallel-plaza"],
["a", "30818:ghi789:sants-station"],
["alt", "Spot collection: Barcelona Trip 2024"]
],
"id": "...",
"sig": "..."
}
```
### Querying Collections
```json
{
"kinds": [30003],
"authors": ["<user-pubkey>"],
"#t": ["skatespots"]
}
```
## Gear Listings (kind 30402)
Gear listings use the NIP-99 Classified Listings format (kind 30402) for peer-to-peer skateboarding gear sales. This enables skaters to buy and sell used equipment directly at skate spots.
### Event Format
```json
{
"kind": 30402,
"content": "<detailed description>",
"tags": [
["d", "<unique-identifier>"],
["title", "<item name>"],
["t", "skateboarding"],
["t", "<category>"],
["price", "<amount>", "<currency>"],
["price_type", "<fixed|free|trade|offer>"],
["condition", "<new|like_new|good|well_used>"],
["listing_type", "<selling|wtb>"],
["size", "<size-value>"],
["g", "<geohash>"],
["location", "<pickup location>"],
["a", "30818:<pubkey>:<spot-d-tag>"],
["available", "<when seller is available>"],
["expiration", "<unix-timestamp>"],
["status", "active"],
["published_at", "<unix-timestamp>"],
["image", "<image-url>"],
["alt", "Gear listing: <title>"]
]
}
```
### Required Tags
| Tag | Description |
|-----|-------------|
| `d` | Unique identifier for the listing |
| `title` | Item name |
| `t` | Must include `skateboarding` for discoverability |
| `t` | Category tag (see categories below) |
| `image` | At least one product photo |
| `price_type` | How the item is being sold: `fixed`, `free`, `trade`, `offer` |
| `listing_type` | Whether selling or buying: `selling` or `wtb` (Want to Buy) |
### Optional Tags
| Tag | Description |
|-----|-------------|
| `price` | Price amount and currency (e.g., `["price", "50", "USD"]`) - required if price_type is `fixed` |
| `condition` | Item condition: `new`, `like_new`, `good`, `well_used` |
| `size` | Item size (varies by category) |
| `g` | Geohash for pickup location |
| `location` | Human-readable pickup location |
| `a` | Reference to a skate spot (format: `30818:pubkey:d-tag`) |
| `available` | When the seller is typically available |
| `expiration` | Unix timestamp when listing expires (default: 30 days) |
| `status` | Listing status: `active`, `sold`, `pending` |
| `summary` | Short one-liner about the item |
### Categories
The following `t` tags are used for categorizing gear:
- `deck` - Skateboard decks
- `trucks` - Trucks
- `wheels` - Wheels
- `bearings` - Bearings
- `shoes` - Skate shoes
- `clothing` - Clothing and apparel
- `protective` - Protective gear (helmets, pads, etc.)
- `stickers` - Stickers
- `wax` - Skate wax
- `tools` - Skate tools
- `griptape` - Griptape
- `rubbrick` - Rub bricks
### Condition Values
| Value | Label | Description |
|-------|-------|-------------|
| `new` | 🆕 New | Never used, still in packaging |
| `like_new` | 👍 Like New | Barely used, excellent condition |
| `good` | ✅ Good | Normal wear, functions perfectly |
| `well_used` | ⚠️ Well Used | Shows wear but still works |
### Price Types
| Value | Label | Description |
|-------|-------|-------------|
| `fixed` | Fixed Price | Set price in fiat or sats |
| `free` | Free | Giving away |
| `trade` | Trade | Looking to swap |
| `offer` | Make Offer | Price negotiable |
### Size Options by Category
**Decks:**
- `7.5"`, `7.75"`, `8.0"`, `8.125"`, `8.25"`, `8.38"`, `8.5"`, `8.75"`, `9.0"+`
**Trucks:**
- Sizes: `129`, `139`, `144`, `149`, `159`, etc.
- Heights: `low`, `mid`, `high`, `inverted`
**Shoes:**
- US sizes: `6`, `6.5`, `7`, `7.5`, ... `14`
- EU sizes: `38`, `39`, `40`, ... `48`
**Clothing:**
- `XS`, `S`, `M`, `L`, `XL`, `XXL`
**Wheels:**
- Diameter: `50mm`, `52mm`, `54mm`, `56mm`, `58mm`, `60mm`
- Hardness: `78a`, `87a`, `92a`, `97a`, `99a`, `101a`
### Listing Type
| Value | Description |
|-------|-------------|
| `selling` | User is selling gear |
| `wtb` | Want to Buy - User is looking for specific gear |
### Expiration
Listings should include an `expiration` tag with a Unix timestamp. Default expiration periods:
- 7 days
- 14 days
- 30 days (default)
Expired listings should be filtered out by clients.
### Spot Association
Gear listings can be tied to specific skate spots using an `a` tag referencing a spot event. This allows:
- Listings to appear on spot detail pages
- Easy meetup coordination at familiar locations
- Geographic filtering of gear listings
**Important:** Gear listings should NOT appear at shop locations (conflict of interest). Only show gear at regular skate spots or dual shop+spot locations.
### NIP-05 Verification
For trust and spam prevention, sellers should have a verified NIP-05 identifier. Clients may:
- Require NIP-05 to create listings
- Display verification badges on listings
- Allow filtering by verified sellers
### Example: Selling a Deck
```json
{
"kind": 30402,
"pubkey": "abc123...",
"created_at": 1703001600,
"content": "Got this deck for Christmas but prefer a smaller size. Happy to let you try it before buying! Some minor scratches from one session but graphics are clean.",
"tags": [
["d", "listing-1703001600"],
["title", "Palace Deck 8.25\""],
["t", "skateboarding"],
["t", "deck"],
["price", "40", "USD"],
["price_type", "fixed"],
["condition", "like_new"],
["listing_type", "selling"],
["size", "8.25\""],
["g", "9q5ctr"],
["location", "Venice Beach Skatepark"],
["a", "30818:def456:venice-beach-park"],
["available", "Weekends 10am-2pm"],
["expiration", "1705593600"],
["status", "active"],
["published_at", "1703001600"],
["image", "https://example.com/deck-front.jpg"],
["image", "https://example.com/deck-back.jpg"],
["alt", "Gear listing: Palace Deck 8.25\""]
],
"id": "...",
"sig": "..."
}
```
### Example: Want to Buy (WTB)
```json
{
"kind": 30402,
"pubkey": "xyz789...",
"created_at": 1703001600,
"content": "Looking for a solid 8.0\" deck, any brand. Prefer minimal graphics or blank. Can pay cash or trade wheels.",
"tags": [
["d", "listing-1703001700"],
["title", "Looking for 8.0\" Deck"],
["t", "skateboarding"],
["t", "deck"],
["price_type", "offer"],
["listing_type", "wtb"],
["size", "8.0\""],
["g", "9q5ctr"],
["location", "Near Downtown Rails"],
["expiration", "1705593600"],
["status", "active"],
["published_at", "1703001600"],
["alt", "WTB: Looking for 8.0\" Deck"]
],
"id": "...",
"sig": "..."
}
```
### Querying Gear Listings
**All Active Listings:**
```json
{
"kinds": [30402],
"#t": ["skateboarding"]
}
```
**Listings by Category:**
```json
{
"kinds": [30402],
"#t": ["skateboarding", "deck"]
}
```
**Listings at a Spot:**
```json
{
"kinds": [30402],
"#a": ["30818:pubkey:spot-id"],
"#t": ["skateboarding"]
}
```
**Listings Near Location (using geohash prefix):**
```json
{
"kinds": [30402],
"#g": ["9q5"],
"#t": ["skateboarding"]
}
```
## Community Contributions
Community members can contribute to spots without being the owner. Contributions are stored as separate events that reference the original spot.
### Photo Contributions (kind 1063)
Community photos use NIP-94 File Metadata (kind 1063) with an `a` tag referencing the spot.
```json
{
"kind": 1063,
"content": "Great ledge for slappies!",
"tags": [
["url", "https://example.com/photo.jpg"],
["m", "image/jpeg"],
["a", "30818:<spot-pubkey>:<spot-d-tag>"],
["alt", "Community photo for Hollywood High"]
]
}
```
### Tag Suggestions (kind 1111)
Tag suggestions use NIP-22 Comments (kind 1111) with a special marker tag.
```json
{
"kind": 1111,
"content": "Suggested tags for Hollywood High: #marble #ledge #10-stair",
"tags": [
["a", "30818:<spot-pubkey>:<spot-d-tag>"],
["K", "30818"],
["t", "skatespot-tags"],
["t", "marble"],
["t", "ledge"],
["t", "10-stair"]
]
}
```
The `skatespot-tags` marker distinguishes tag suggestions from regular comments.
### Querying Contributions
**Community Photos:**
```json
{
"kinds": [1063],
"#a": ["30818:pubkey:spot-id"]
}
```
**Tag Suggestions:**
```json
{
"kinds": [1111],
"#a": ["30818:pubkey:spot-id"],
"#t": ["skatespot-tags"]
}
```
## Spot Ownership Transfer
Spot ownership can be transferred in two ways:
### Voluntary Transfer
The owner publishes a transfer request as a kind 1111 comment:
```json
{
"kind": 1111,
"content": "Ownership transfer request for spot \"Hollywood High\"",
"tags": [
["a", "30818:<owner-pubkey>:<spot-d-tag>"],
["K", "30818"],
["t", "spot-transfer"],
["p", "<new-owner-pubkey>", "", "new-owner"]
]
}
```
### Abandoned Spot Claim
If a spot hasn't been updated for 6+ months, any user can claim it by:
1. Re-publishing the spot under their own pubkey
2. Adding `claim-from` and `claim-reason` tags
```json
{
"kind": 30818,
"content": "<original description>",
"tags": [
["d", "<same-d-tag>"],
["title", "<same-title>"],
// ... other original tags ...
["claim-from", "<original-owner-pubkey>"],
["claim-reason", "abandoned"]
]
}
```
Note: Since Nostr is decentralized, the original spot remains. Clients should prefer the most recently updated version or allow users to choose.
## Skate Tours (kind 30823)
Skate tours allow experienced skaters to organize and host various types of skateboarding adventures. Tours range from local hidden spot tours to multi-country expeditions and KOTR-style World Destruction competitions.
### Tour Categories
| Category | Emoji | Description | Typical Duration |
|----------|-------|-------------|------------------|
| `local` | 🏠 | Show hidden spots in your city | 2-8 hours |
| `national` | 🚐 | Multi-city road trips across the country | 2-14 days |
| `international` | ✈️ | Cross-border adventures across multiple countries | 1-8 weeks (up to 2 months) |
| `destruction` | 💀 | World Destruction Tour - KOTR-style global team competition | 1-12 months |
### Event Format (Base)
```json
{
"kind": 30823,
"content": "<tour description - what skaters will experience>",
"tags": [
["d", "<unique-identifier>"],
["title", "<tour name>"],
["t", "skate-tour"],
["tour-category", "<local|national|international|destruction>"],
["t", "<spot-type>"],
["g", "<geohash>"],
["location", "<city/region>"],
["meeting-point", "<public meeting location name>"],
["meeting-point-coords", "<lat>", "<lon>"],
["duration", "<number>", "<hours|days|weeks>"],
["skill-level", "<beginner|intermediate|advanced|all>"],
["max-group", "<max participants>"],
["price", "<sats>"],
["available", "<availability description>"],
["languages", "<lang1>", "<lang2>"],
["shop", "<partner-shop-naddr>"],
["shop-discount", "<percentage>"],
["zap-split", "skatespots@minibits.cash", "10"],
["image", "<tour-image-url>"],
["alt", "Skate Tour: <title> in <location>"]
]
}
```
### Required Tags
| Tag | Description |
|-----|-------------|
| `d` | Unique identifier for the tour |
| `title` | Tour name |
| `t` | Must include `skate-tour` to identify as a tour event |
| `tour-category` | Tour type: `local`, `national`, `international`, or `destruction` |
| `location` | City or region where tour operates/starts |
### Optional Tags (All Categories)
| Tag | Description |
|-----|-------------|
| `g` | Geohash for location (low precision - 4 chars recommended) |
| `meeting-point` | Public meeting location name |
| `meeting-point-coords` | Latitude and longitude of meeting point |
| `duration` | Tour length with unit: `["duration", "3", "hours"]` or `["duration", "5", "days"]` |
| `skill-level` | Recommended skill level: `beginner`, `intermediate`, `advanced`, or `all` |
| `max-group` | Maximum number of participants |
| `price` | Price per person in satoshis (host decides) |
| `available` | When host is available (e.g., "Weekends", "By appointment") |
| `languages` | Languages the host speaks |
| `shop` | Reference to partner skate shop (naddr) |
| `shop-discount` | Percentage discount at partner shop for tour participants |
| `zap-split` | Platform fee recipient and percentage |
| `image` | Tour or host photo |
### Spot Type Tags
Tours can include multiple spot type tags to describe what kind of terrain will be covered:
- `street` - Street spots
- `park` - Skateparks
- `diy` - DIY spots
- `transition` - Transition/bowl spots
- `hidden` - Hidden gems
### National & International Tour Tags
| Tag | Description |
|-----|-------------|
| `cities` | List of cities: `["cities", "LA", "SF", "Portland", "Seattle"]` |
| `countries` | List of countries: `["countries", "Spain", "France", "Germany"]` |
| `transport` | Transport information/recommendations |
| `accommodation` | Accommodation information/recommendations |
### World Destruction Tour Tags
World Destruction Tours are KOTR-style global team competitions where organizers define EVERYTHING - challenges, rules, prizes. The platform only provides infrastructure.
**Key Characteristics:**
- Duration: 1-12 months
- Skill level: Always **advanced** (hardcoded)
- Spot types: **ALL types included** automatically
- Challenges: **SECRET until competition start date**
| Tag | Description |
|-----|-------------|
| `cities` | List of competition cities: `["cities", "Berlin", "Barcelona", "Tokyo"]` |
| `countries` | List of countries: `["countries", "Germany", "Spain", "Japan"]` |
| `organizer-contact` | Organizer contact details (Nostr npub, email, Telegram, etc.) |
| `transport` | Transport information/recommendations |
| `start-date` | Unix timestamp when competition starts (**challenges revealed on this date**) |
| `end-date` | Unix timestamp when competition ends |
| `entry-fee` | Entry fee per team in satoshis (organizer decides, can be 0) |
| `prize-pool` | Total prize pool in satoshis |
| `prize-dist` | Prize distribution: `["prize-dist", "1", "50", "2", "30", "3", "20"]` (place, percentage pairs) |
| `max-teams` | Maximum number of teams allowed |
| `min-team-size` | Minimum skaters per team |
| `max-team-size` | Maximum skaters per team |
| `challenge` | Challenge definition (multiple allowed): `["challenge", "id", "title", "description", "points", "revealDate"]` |
| `clip-sources` | Accepted clip sources: `["clip-sources", "blossom", "youtube", "instagram", "any"]` |
### Challenge Tag Format
```json
["challenge", "<id>", "<title>", "<description>", "<points>", "<revealDate>"]
```
- `id`: Unique challenge identifier
- `title`: Challenge name (e.g., "Kickflip a 10 stair")
- `description`: Optional detailed description
- `points`: Points awarded for completion
- `revealDate`: Unix timestamp when challenge becomes visible (defaults to tour start date - **ALL challenges are SECRET until this date**)
### Revenue Model
**Hosts set their own prices.** Platform commission varies by tour type via `zap-split` tag.
| Tour Type | Platform Fee | Host Keeps |
|-----------|--------------|------------|
| Local | 10% | 90% |
| National | 7% | 93% |
| International | 5% | 95% |
| World Destruction | 5% | 95% |
**Important:** For World Destruction Tours, the 5% fee applies only to **entry fees**, NOT to sponsor money or prize pools.
Example zap-split tags:
- Local: `["zap-split", "skatespots@minibits.cash", "10"]`
- National: `["zap-split", "skatespots@minibits.cash", "7"]`
- International/Destruction: `["zap-split", "skatespots@minibits.cash", "5"]`
### Sponsor Tags
Tours can have sponsors. Sponsors are stored as tags and displayed on tour pages.
```json
["sponsor", "<tier>", "<name>", "<logo-url>", "<website-url>", "<challengeId?>"]
```
| Field | Description |
|-------|-------------|
| `tier` | Sponsor level: `title`, `gold`, `silver`, `challenge`, `partner` |
| `name` | Sponsor name (required) |
| `logo-url` | URL to sponsor logo image (optional) |
| `website-url` | Sponsor website URL (optional) |
| `challengeId` | For challenge sponsors only - which challenge they sponsor |
**Sponsor Tiers:**
- `title` - Title/naming sponsor (e.g., "The Vans World Destruction Tour")
- `gold` - Major sponsor with prominent placement
- `silver` - Secondary sponsor
- `challenge` - Sponsors a specific challenge
- `partner` - General partner/supporter
Example:
```json
["sponsor", "title", "Vans", "https://vans.com/logo.png", "https://vans.com"],
["sponsor", "gold", "Monster Energy", "https://monster.com/logo.png", "https://monster.com"],
["sponsor", "challenge", "Red Bull", "https://redbull.com/logo.png", "https://redbull.com", "ch3"]
```
### Privacy Model
**Critical**: Hidden spots are NEVER published to Nostr. They are shared in-person during the tour only.
- Meeting point is a known public location
- City-level geohash provides general location for discovery
- Actual hidden spots are revealed only during the tour
### Video/Clip Submissions
For destruction tours, organizers can accept clip submissions as proof of challenge completion:
1. **Blossom uploads** - For clips under 50MB
2. **External links** - YouTube, Instagram, or any URL
Organizers review submissions and award points manually. The platform does NOT host video - it only stores references.
### Example: Local Tour
```json
{
"kind": 30823,
"pubkey": "abc123...",
"created_at": 1703001600,
"content": "Join me for a 3-hour street spot tour of Barcelona's Gothic Quarter.",
"tags": [
["d", "barcelona-gothic-hidden-tour"],
["title", "Barcelona Gothic Quarter - Hidden Gems Tour"],
["t", "skate-tour"],
["tour-category", "local"],
["t", "street"],
["t", "hidden"],
["g", "sp3e"],
["location", "Barcelona, Spain"],
["meeting-point", "MACBA Museum Steps"],
["meeting-point-coords", "41.3833", "2.1670"],
["duration", "3", "hours"],
["skill-level", "intermediate"],
["max-group", "4"],
["price", "25000"],
["languages", "en", "es", "ca"],
["zap-split", "skatespots@minibits.cash", "10"],
["alt", "Skate Tour: Barcelona Gothic Quarter in Barcelona, Spain"]
]
}
```
### Example: National Tour
```json
{
"kind": 30823,
"pubkey": "def456...",
"created_at": 1703001600,
"content": "5-day California coast tour hitting the best spots from LA to SF. Van included, bring your own board!",
"tags": [
["d", "california-coast-tour-2024"],
["title", "California Coast Tour 2024"],
["t", "skate-tour"],
["tour-category", "national"],
["t", "street"],
["t", "park"],
["g", "9q5"],
["location", "California, USA"],
["cities", "Los Angeles", "Santa Barbara", "San Luis Obispo", "Santa Cruz", "San Francisco"],
["meeting-point", "Venice Beach Skatepark"],
["meeting-point-coords", "33.9850", "-118.4695"],
["duration", "5", "days"],
["skill-level", "intermediate"],
["max-group", "8"],
["price", "500000"],
["transport", "15-passenger van, driver included"],
["accommodation", "Budget hostels along the route - not included in price"],
["languages", "en", "es"],
["zap-split", "skatespots@minibits.cash", "10"],
["alt", "Skate Tour: California Coast Tour 2024"]
]
}
```
### Example: World Destruction Tour
```json
{
"kind": 30823,
"pubkey": "ghi789...",
"created_at": 1703001600,
"content": "EUROPEAN WORLD DESTRUCTION TOUR 2024. 4 teams battle across Europe's gnarliest spots for 3 months. Complete challenges, film clips, earn points. Winner takes all!",
"tags": [
["d", "europe-destruction-2024"],
["title", "European World Destruction Tour 2024"],
["t", "skate-tour"],
["tour-category", "destruction"],
["t", "street"],
["t", "park"],
["t", "diy"],
["t", "transition"],
["t", "hidden"],
["cities", "Berlin", "Barcelona", "Paris", "London", "Amsterdam"],
["countries", "Germany", "Spain", "France", "UK", "Netherlands"],
["organizer-contact", "DM on Nostr: npub1... or email: destruction@skatespots.org"],
["transport", "Teams arrange own transport between cities"],
["duration", "12", "weeks"],
["skill-level", "advanced"],
["start-date", "1720000000"],
["end-date", "1727776000"],
["entry-fee", "100000"],
["prize-pool", "5000000"],
["prize-dist", "1", "50", "2", "30", "3", "20"],
["max-teams", "8"],
["min-team-size", "4"],
["max-team-size", "6"],
["challenge", "ch1", "First kickflip at Warschauer", "Land a kickflip on the famous Warschauer banks", "100", "1720000000"],
["challenge", "ch2", "Get a local to join", "Convince a local skater to join your session", "200", "1720000000"],
["challenge", "ch3", "Fountain trick", "Land a trick into the Alexanderplatz fountain", "500", "1720000000"],
["challenge", "ch4", "3-country video part", "Film a video part spanning at least 3 countries", "1000", "1720000000"],
["challenge", "ch5", "MACBA line", "Film a full line at MACBA Barcelona", "777", "1720000000"],
["clip-sources", "blossom", "youtube", "instagram", "any"],
["languages", "en", "de", "es", "fr"],
["zap-split", "skatespots@minibits.cash", "10"],
["alt", "World Destruction Tour: European World Destruction Tour 2024"]
]
}
```
**Note:** All challenges have the same `revealDate` (1720000000) which is the tour start date. Teams cannot see challenge details until this date.
### Querying Tours
**All Tours:**
```json
{
"kinds": [30823],
"#t": ["skate-tour"]
}
```
**Tours by Category:**
```json
{
"kinds": [30823],
"#t": ["skate-tour"]
}
```
Note: Filter by `tour-category` tag client-side.
**Tours in a City (by geohash prefix):**
```json
{
"kinds": [30823],
"#t": ["skate-tour"],
"#g": ["sp3"]
}
```
**Tours by a Host:**
```json
{
"kinds": [30823],
"authors": ["<host-pubkey>"],
"#t": ["skate-tour"]
}
```
## Food Recommendations
Food spots near skate locations are handled client-side by querying external APIs (like OpenStreetMap/Overpass) using the geohash coordinates from skate spots.
## Private Stash (kind 30001)
The Stash feature uses NIP-51 Follow Sets (kind 30001) to store a user's private gear inventory. The content is NIP-44 encrypted to self, making it private by default with selective disclosure options.
### Event Format
```json
{
"kind": 30001,
"content": "<NIP-44 encrypted JSON>",
"tags": [
["d", "skatespots-stash"],
["alt", "Private gear stash (encrypted)"]
]
}
```
### Encrypted Content Structure
When decrypted, the content contains a JSON object with the user's inventory:
```json
{
"items": [
{
"id": "<uuid>",
"title": "Powell Peralta Flight Deck 8.25",
"category": "deck",
"condition": "like_new",
"size": "8.25\"",
"brand": "Powell Peralta",
"description": "Barely used, no chips or cracks",
"minPrice": 50000,
"maxPrice": null,
"images": ["https://example.com/deck.jpg"],
"willingToTrade": true,
"willingToSell": true,
"addedAt": 1703001600,
"visibility": "friends",
"itemType": "stash",
"rarity": "rare"
},
{
"id": "<uuid>",
"title": "Looking for 8.0\" deck",
"category": "deck",
"condition": "good",
"size": "8.0\"",
"brand": "",
"description": "Any brand, prefer minimal graphics",
"minPrice": null,
"maxPrice": 100000,
"images": [],
"willingToTrade": true,
"willingToSell": false,
"addedAt": 1703002600,
"visibility": "friends",
"itemType": "wtb",
"rarity": null
}
],
"updatedAt": 1703002600
}
```
### Item Types
| Value | Description |
|-------|-------------|
| `stash` | Regular inventory item that the user owns |
| `wtb` | Want To Buy - item the user is looking for |
### Visibility Levels
| Value | Description |
|-------|-------------|
| `private` | Only visible to the owner. Shared manually via DM. |
| `friends` | Visible to mutual follows. Enables P2P matching. |
| `public` | Visible to anyone who views the user's profile. |
### Categories
Same as Gear Listings: `deck`, `trucks`, `wheels`, `bearings`, `shoes`, `clothing`, `protective`, `complete`, `other`
### Conditions
| Value | Description |
|-------|-------------|
| `new` | Brand new, never used |
| `like_new` | Barely used, excellent condition |
| `good` | Normal wear, functions perfectly |
| `well_used` | Shows wear but still works |
### Rarity (Optional)
Items can have an optional rarity tier for RPG-style display:
| Value | Description |
|-------|-------------|
| `common` | Standard items |
| `uncommon` | Slightly rare brands/models |
| `rare` | Hard to find items |
| `epic` | Very rare or valuable items |
| `legendary` | Extremely rare collector items |
### WTB Matching
The app performs client-side matching between WTB items and stash items from friends (mutual follows). Matches are based on:
- Category (required match)
- Brand (fuzzy match)
- Size (within tolerance)
- Price compatibility (WTB maxPrice >= stash minPrice)
When a match is found, the app can facilitate P2P trading via encrypted DMs (NIP-17).
### Privacy Model
- All stash data is NIP-44 encrypted to self
- Private items are never shared without explicit user action
- "Friends" visibility items can be queried by mutual follows through a discovery protocol
- WTB matching happens client-side with no central server
### Security Considerations
1. The stash is encrypted at rest using the user's Nostr keys
2. Selective disclosure allows users to share specific items via DM
3. No price or inventory data is publicly visible unless explicitly set to "public"
4. Group DMs (NIP-17) can be used for coordinating trades between multiple parties
## Rider Cards
SkateSpots supports physical-print cards that are cryptographically signed on Nostr and verifiable by anyone.
### Park Access Card (kind 35679)
Issued by a private skatepark owner to a specific rider, granting access rights. The park's Nostr key signs the event — anyone can verify the card is genuine by scanning the QR code.
**Constraint:** Park Access Cards for private parks (`access=private` in kind 30818) should only be issued by the park's owner pubkey. Clients verify the issuer's pubkey matches the park event's `pubkey` field.
```json
{
"kind": 35679,
"content": "",
"tags": [
["d", "<unique-id>"],
["p", "<rider-pubkey>"],
["t", "skatepark-access"],
["issuer", "<park name>"],
["access-level", "<Full Access|Supervised|Beginner Zone|Street Only|Bowl Only|Members Only>"],
["theme", "<thug|lime|chrome|wood|midnight|sunset>"],
["valid-thru", "<YYYY-MM-DD>"],
["alt", "Skatepark Access Card"]
]
}
```
### Card Themes
| Value | Description |
|-------|-------------|
| `thug` | Black + Neon Red (default) |
| `lime` | Black + Neon Lime |
| `chrome` | Steel & Silver |
| `wood` | Woodgrain Classic |
| `midnight` | Deep Blue |
| `sunset` | Orange & Pink |
### Rider License (client-side only)
The personal Rider License is generated client-side from the user's kind 0 profile metadata and on-chain stats. It is not a separate Nostr event — it renders from existing data:
- Name, avatar, NIP-05: from kind 0
- Spots added: count of kind 30818 authored by the user
- Zaps received: count of kind 9735 targeting the user
- Events created: count of kind 31923 authored by the user
- Park cards held: count of kind 35679 with `#p` = user
- Witnessed skill badges: count of kind 1293 with `#p` = user
The Rider License includes two categories of skill badges:
- **Self-claimed** — selected by the rider themselves, shown in theme color
- **Witnessed** — awarded by others (kind 1293), shown in gold ⭐
The "Rides For" field displays the local shop the rider represents, distinct from sponsors (brands).
### Verification
The QR code on printed Park Access cards encodes the Nostr address (`naddr`) of the issuing event. Verifiers scan the QR, fetch the event from any relay, and confirm the issuer's signature.
```
URL format: https://skate-spots.org/verify/<naddr>
```
## Skill Badge Awards (kind 1293)
Anyone who witnesses a rider performing a trick can award them a cryptographically signed skill badge. This creates an on-chain proof of skill — unfakeable because the witness's Nostr key signs the event.
### Design Rationale
Skill badges are **regular events** (not addressable) because:
- Multiple witnesses can each award the same skill to the same rider independently
- Each badge is a distinct attestation with its own timestamp and witness
- The full history of attestations is valuable (not just the latest)
### Event Format
```json
{
"kind": 1293,
"content": "",
"tags": [
["p", "<rider-pubkey>"],
["skill", "<skill-id>"],
["skill", "<skill-id>"],
["spot", "<spot name (optional)>"],
["t", "skill-badge"],
["alt", "Skill badge awarded: <skill>, <skill>"]
]
}
```
### Required Tags
| Tag | Description |
|-----|-------------|
| `p` | Pubkey of the rider receiving the badge |
| `skill` | Skill identifier (one tag per skill, multiple allowed) |
| `t` | Must be `skill-badge` for discoverability |
| `alt` | NIP-31 human-readable description |
### Optional Tags
| Tag | Description |
|-----|-------------|
| `spot` | Name of the spot where the trick was witnessed |
| `a` | Reference to a kind 30818 spot event |
### Skill Identifiers
| Value | Label |
|-------|-------|
| `kickflip` | Kickflip |
| `heelflip` | Heelflip |
| `switch` | Switch stance |
| `nollie` | Nollie |
| `fakie` | Fakie |
| `grind` | Grinds |
| `slide` | Slides |
| `manual` | Manuals |
| `vert` | Vert |
| `bowl` | Bowl |
| `street` | Street |
| `park` | Park |
| `film` | Filmer |
| `diy` | DIY Builder |
### Trust Model
The value of a badge is proportional to the reputation of the witness:
- NIP-05 verified witnesses carry more weight
- Witnesses known in the community (follows, mutual follows) carry more weight
- The skill badge feed is public — anyone can query badges for any rider
### Querying
**All badges received by a rider:**
```json
{
"kinds": [1293],
"#p": ["<rider-pubkey>"],
"#t": ["skill-badge"]
}
```
**All badges issued by a witness:**
```json
{
"kinds": [1293],
"authors": ["<witness-pubkey>"],
"#t": ["skill-badge"]
}
```
**Badges for a specific skill:**
```json
{
"kinds": [1293],
"#p": ["<rider-pubkey>"],
"#skill": ["kickflip"]
}
```
### Example
```json
{
"kind": 1293,
"pubkey": "witness-pubkey-hex",
"created_at": 1703001600,
"content": "",
"tags": [
["p", "rider-pubkey-hex"],
["skill", "kickflip"],
["skill", "switch"],
["spot", "MACBA Barcelona"],
["a", "30818:abc123:macba"],
["t", "skill-badge"],
["alt", "Skill badge awarded: kickflip, switch"]
],
"id": "...",
"sig": "..."
}
```
## The Lab — Service Profiles
The Lab connects skaters with service providers. Each provider type is an
addressable event (kind 35001–35004) identified by `pubkey + kind + d-tag`.
They share a common base schema and are distinguished by kind and a self-tag.
| Kind | Provider | Self-tag |
|------|----------|----------|
| 35001 | Coach | `skatecoach` |
| 35002 | Filmer (video) | `skatefilmer` |
| 35003 | Video Editor | `skateeditor` |
| 35004 | Photographer | `skatephotographer` |
| 35005 | Builder (ramp/park/DIY) | `skatebuilder` |
### Common Base Schema
```json
{
"kind": 35004,
"content": "<bio / about the provider>",
"tags": [
["d", "<profile-identifier>"],
["title", "<display name>"],
["t", "<self-tag>"],
["t", "<service/skill/gear tag>"],
["location", "<city / area>"],
["g", "<geohash>"],
["image", "<profile image url>"],
["experience", "<years>"],
["rate", "<freeform rate, e.g. 20000 sats/shoot>"],
["phone", "<phone>"],
["email", "<email>"],
["website", "<url>"],
["entity", "individual"],
["a", "30818:<pubkey>:<d-tag>"],
["alt", "<human-readable description>"]
]
}
```
#### Required Tags
| Tag | Description |
|-----|-------------|
| `d` | Unique profile identifier (e.g. `photography-profile`) |
| `title` | Display name |
| `t` | Must include the provider self-tag (see table above) |
#### Shared Optional Tags
`location`, `g` (geohash), `image`, `experience`, `rate`, `phone`, `email`,
`website`, `noffer` (CLINK payment code — see below), `entity` (see Entity Type
below), `p` (collective member pubkeys — see Collective Membership below),
`a` (spot references to kind 30818), `alt`.
### Pro / Featured Listings (`featured` tag)
Lab profiles, calendar events (kind 31923), and skate tours (kind 30823)
support an optional **Pro / featured** upgrade. Basic listings are always free;
paying the optional platform fee adds a `featured` tag whose value is the
**unix-seconds expiry** of the featured window:
```json
["featured", "1767225600"]
```
Clients that see an unexpired `featured` timestamp render a gold **"Pro"** badge
and sort the listing ahead of non-featured ones. Once the timestamp passes, the
listing simply reverts to a normal free listing (no badge, no boost) — it is
never unpublished.
#### Duration tiers
The featured window is purchased at one of four duration tiers, with a
per-day discount that grows with the commitment length (price discrimination).
The base is a 30-day monthly price per listing type; the charged amount is
`base × multiplier`:
| Tier | Days | Multiplier | Note |
|------|------|-----------|------|
| 7 days | 7 | 0.4× | short "try it" window |
| 1 month | 30 | 1.0× | baseline |
| 3 months | 90 | 2.4× | best value (~20%/day off) |
| 12 months | 365 | 8.0× | biggest discount (~33%/day off) |
The chosen tier only determines the computed expiry written into the
`featured` tag — the tag schema itself is unchanged.
#### Verification via NIP-57 zap receipts (layered / best-effort)
To make Pro tamper-proof where possible, payment is attempted as a **NIP-57
zap** rather than a plain LNURL-pay. The client signs a kind `9734` zap request
tagging the platform pubkey (`p`) and, when known, the featured entity's
addressable coordinate (`a` = `kind:pubkey:d`), and passes it to the platform
Lightning address's LNURL callback. If the provider is zap-enabled
(`allowsNostr` + `nostrPubkey`), it publishes a kind `9735` zap receipt.
Clients verify a `featured` listing by querying for a matching kind `9735`
receipt (`#a` = entity coordinate, `#p` = platform pubkey). Three states result:
| State | Meaning | Badge |
|-------|---------|-------|
| `verified` | A matching zap receipt was found. Cryptographically backed. | Green ✓ Pro |
| `claimed` | Unexpired `featured` tag but no receipt found (provider doesn't publish receipts, or relays lacked it). Self-attested. | Gold Pro |
| `none` | Not featured / window expired. | (none) |
> **Trust note:** the fallback is **graceful** — if the platform's Lightning
> provider does not publish zap receipts (as with some custodial/ecash
> addresses), verification degrades to the older *self-attested* `claimed`
> level and Pro never silently breaks. When receipts are available the badge
> becomes genuinely verifiable. For events and tours whose `d` identifier is
> generated at publish time (non-deterministic), the zap request omits the `a`
> coordinate, so those fall back to `claimed`.
### Entity Type (individual vs. collective)
Every Lab profile carries an optional `entity` tag declaring whether it
represents a single person or a group:
| Tag | Value | Meaning |
|-----|-------|---------|
| `entity` | `individual` | A single person (default if omitted). |
| `entity` | `collective` | A multi-person entity: crew, studio, or company. |
"Company" is presented as cosmetic sub-styling of a collective, **not** a
distinct trust tier — on Nostr a company and a collective share the same trust
model (a shared brand backed by verifiable member keys).
```json
["entity", "collective"]
```
### Collective Membership (NIP-32 attestations)
A collective profile lists its claimed members via `p` tags (member pubkeys in
hex). Because the collective's founder key alone signs the profile, a bare `p`
tag is only a **claim** — it cannot prove the listed person consented.
To make membership **unforgeable**, each member independently publishes a
[NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) label event
(kind `1985`) signed with their **own** key, referencing the collective's
addressable coordinate:
```json
{
"kind": 1985,
"content": "I'm a member of Barcelona Filmers",
"tags": [
["L", "org.skatespots.membership"],
["l", "member", "org.skatespots.membership"],
["a", "35002:<founderPubkey>:<d-tag>"],
["p", "<founderPubkey>"],
["alt", "Skate collective membership confirmation for Barcelona Filmers"]
]
}
```
**Client logic:** cross-reference the collective's `p` tags with member
attestations. A member is:
- **Confirmed** — listed by the collective **AND** has published a matching
membership label. Rendered with a green "Confirmed" chip.
- **Pending** — listed by the collective but has not attested. Amber "Pending"
chip.
- A stray attestation from someone the collective does **not** list is ignored
(prevents unsolicited membership claims).
Removing a member: the founder drops the `p` tag, or the member deletes their
label event (NIP-09). Either direction flips the member out of "confirmed".
### Photographer (kind 35004)
Skate photography is a distinct craft from filming — it is shot-, light-, and
print-oriented. A photographer profile records services and gear as queryable
`t` tags, plus a freeform `gear` tag and a `portfolio`/`social` link.
Categories encoded as `t` tags:
- **Services**: `action`, `sequence`, `portraits`, `editorial`, `product`, `events`, `lifestyle`, `prints`
- **Camera bodies**: `dslr`, `mirrorless`, `medium`, `film35`, `filmmedium`, `point`, `phone`
- **Lenses**: `fisheye`, `wide`, `standard`, `telephoto`, `macro`, `zoom`
- **Lighting**: `strobes`, `speedlights`, `profoto`, `elinchrom`, `godox`, `triggers`, `continuous`, `reflectors`, `naturallight`
- **Support**: `tripod`, `monopod`, `lightstands`, `gimbal`, `bag`, `laptop`
- **Style**: `flashfisheye`, `natural`, `bw`, `film`, `longlens`, `studio`, `street`
Photographer-specific optional tags:
| Tag | Description |
|-----|-------------|
| `gear` | Freeform "what's in the bag" (exact bodies, lenses, lights) |
| `portfolio` | Portfolio link (Instagram, personal site, etc.) |
| `social` | Additional social handle or link |
#### Example: Photographer
```json
{
"kind": 35004,
"content": "Shooting street and park sessions for 8 years. Love a lit flash fisheye at dusk.",
"tags": [
["d", "photography-profile"],
["title", "Ana Flash"],
["t", "skatephotographer"],
["t", "action"], ["t", "sequence"], ["t", "editorial"],
["t", "mirrorless"], ["t", "fisheye"], ["t", "telephoto"],
["t", "godox"], ["t", "triggers"],
["t", "tripod"], ["t", "lightstands"],
["t", "flashfisheye"], ["t", "bw"],
["location", "Barcelona, ES"],
["g", "sp3e9"],
["experience", "8"],
["rate", "25000 sats/shoot"],
["gear", "Sony A7 IV, 15mm fisheye, 70-200mm f/2.8, 2× Godox AD200 Pro, remote triggers, Manfrotto tripod"],
["portfolio", "https://instagram.com/anaflash"],
["alt", "Skate photographer: Ana Flash"]
]
}
```
### Builder (kind 35005)
Skate ramp / park / DIY builders. Most builders are crews or companies, so this
category defaults to `entity: collective`. Build types, materials and scale are
stored as queryable `t` tags, plus builder-specific optional tags.
Categories encoded as `t` tags:
- **What they build**: `diy`, `concrete`, `wood`, `bowls`, `transitions`, `rails`, `boxes`, `minis`, `pumptrack`, `foam`, `repairs`, `design`
- **Materials / trade**: `concretework`, `welding`, `carpentry`, `formwork`, `masonry`, `coping`, `surfacing`, `earthworks`
- **Project scale**: `backyard`, `diycommunity`, `publicpark`, `indoor`, `event`
Builder-specific optional tags:
| Tag | Description |
|-----|-------------|
| `gear` | Freeform "how we work / equipment" |
| `service_area` | How far they travel (freeform) |
| `insured` | `"true"` if licensed/insured (relevant for public park work) |
| `portfolio` | Link to past builds (Instagram, site) |
| `social` | Additional social handle or link |
The `a` (spot reference) tags are especially meaningful for builders — they link
to the actual spots/parks (kind 30818) the builder has made, surfacing their work
on the map.
#### Example: Builder (collective)
```json
{
"kind": 35005,
"content": "Concrete crew out of Malmö. 12 years pouring parks and DIY across the EU.",
"tags": [
["d", "builder-profile"],
["title", "Betong Bros"],
["t", "skatebuilder"],
["t", "concrete"], ["t", "bowls"], ["t", "transitions"], ["t", "repairs"],
["t", "concretework"], ["t", "formwork"], ["t", "coping"],
["t", "publicpark"], ["t", "diycommunity"],
["location", "Malmö, SE"],
["g", "u3bu5"],
["experience", "12"],
["rate", "per m² / by project"],
["service_area", "Scandinavia + will travel EU"],
["insured", "true"],
["gear", "Own mixer + shotcrete rig, mobile welding, handle drainage & permits"],
["portfolio", "https://instagram.com/betongbros"],
["entity", "collective"],
["p", "<member1-pubkey>"],
["p", "<member2-pubkey>"],
["a", "30818:<pubkey>:<d-tag>"],
["alt", "Skate ramp/park builder: Betong Bros"]
]
}
```
### Querying Lab Profiles
```json
{
"kinds": [35001, 35002, 35003, 35004, 35005],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder"],
"limit": 100
}
```
## Skate Crews (kind 35006)
A **Crew** is a map-anchored collective identity — the crew you skate/roll with,
not a service you hire. It answers *"who skates here?"* rather than *"who can I
hire?"* (that's The Lab). Crews are addressable events identified by
`pubkey + kind + d-tag`.
A crew is structurally a **collective** (it reuses the exact NIP-32 membership
attestation mechanism defined in [The Lab § Collective Membership](#collective-membership-nip-32-attestations))
minus the service/rate fields, plus a **home turf** (map anchor) and optional
**home spots**. Members are always attested per-member with their own key — a
bare `p` tag on the crew is only a claim until the member self-labels.
### Event Format
```json
{
"kind": 35006,
"content": "Late-night ledge crew. We haunt the plaza. All welcome, no kooks.",
"tags": [
["d", "barcelona-night-crew"],
["title", "Barcelona Night Crew"],
["t", "skatecrew"],
["image", "https://blossom.example/crew-logo.png"],
["g", "sp3e"],
["location", "Barcelona, Spain"],
["founded", "2019"],
["t", "street"],
["t", "transition"],
["t", "crew-focus-photo"],
["p", "<memberPubkey1>", "", "admin"],
["p", "<memberPubkey2>"],
["a", "30818:<spotFounder>:<spotDtag>"],
["nip05", "barcelonanightcrew@bcnnightcrew.example"],
["website", "https://bcnnightcrew.example"],
["noffer", "noffer1qqs..."],
["alt", "Skate crew: Barcelona Night Crew"]
]
}
```
### Required Tags
| Tag | Description |
|-----|-------------|
| `d` | Unique crew identifier |
| `title` | Crew name |
| `t: skatecrew` | Self-tag for discovery/querying |
### Optional Tags
| Tag | Description |
|-----|-------------|
| `image` | Crew logo / photo |
| `g` | Geohash of the crew's **home turf** (map anchor) |
| `location` | Human-readable home location |
| `founded` | Year founded (cosmetic) |
| `t: <vibe>` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. |
| `t: crew-focus-<scene>` | Optional **Lab scene** for the crew, surfacing it inside The Lab's matching category. One of `crew-focus-builder` (DIY & Builders), `crew-focus-photo` (Photo & Film — covers filming, photography & editing), or `crew-focus-coaching`. Absent = a "Mixed / General" crew that only appears on the Crews page. Queryable at the relay level via `#t`. |
| `t: crew-agent` | Optional marker that the crew has enabled its **AI Crew Agent** ("🐝 Sasquatch"). When present, clients may derive the crew's agent identity (see below) and expose an "Ask Sasquatch" experience for members. Absent = no agent. |
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). |
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. |
| `website`, `instagram`, `social` | Links |
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
### Membership
Crews reuse the collective membership model verbatim. A member confirms
belonging by publishing a NIP-32 label (kind `1985`) referencing the crew's
`a`-coordinate, `["l", "member", "org.skatespots.membership"]`. Confirmed /
pending chips and removal semantics are identical to Lab collectives.
### Governance (founder + delegated admins)
A crew's identity is a single **addressable event authored by the founder**.
On Nostr, an addressable event can only be overwritten by its author's key, so
the founder's pubkey is the crew's single source of truth — **conflicts are
impossible at the data layer** (a fork by anyone else is a *different* event
under a *different* author and is ignored by clients querying the founder's
coordinate).
Roles:
| Role | Determined by | Powers |
|------|---------------|--------|
| **Founder** | Authored the crew event | Sole owner of the crew identity. Only role that can **edit the profile** (name, logo, members, admins, NIP-05, etc.). |
| **Admin** | A confirmed member whose `p` tag carries the `"admin"` marker | Delegated *powers* exercised with the admin's **own key**: invite/vouch members, moderate the crew feed & chat, represent the crew. Cannot rewrite the profile. |
| **Member** | Listed `p` tag + self-attested | Appears in the crew, posts to the feed/chat. |
Admins are always a **subset of members** — a pubkey marked `admin` that is not
also a confirmed member is treated as a plain (pending) member until it attests.
> **Why not egalitarian shared editing?** Letting arbitrary members overwrite
> the crew profile would require either a **shared private key** (a single leak
> hijacks the crew) or a **consensus/multisig layer** (heavy and
> non-interoperable). SkateSpots deliberately uses the owner + delegated-mods
> model (like GitHub orgs, Discord, and NIP-72 communities): the founder owns
> the record, admins wield delegated powers with their own keys.
#### Moderation (NIP-32 `hidden` labels)
Moderators (founder + admins) can hide an item from the **crew feed** *or* the
**crew chat** by publishing a kind `1985` label with their **own key**:
```json
{
"kind": 1985,
"tags": [
["L", "org.skatespots.moderation"],
["l", "hidden", "org.skatespots.moderation"],
["e", "<hiddenItemId>"],
["a", "35006:<founder>:<crewDtag>"],
["p", "<itemAuthor>"],
["alt", "Crew moderation: post hidden"]
]
}
```
- **Feed** (public kind-1 notes): `e` is the note id. Hiding removes it from
*this crew's feed only*; the note remains on Nostr.
- **Chat** (NIP-17 E2EE group DMs): `e` is the rumor (kind-14) message id.
Because chat is end-to-end encrypted, messages **cannot be deleted** from
relays — moderation is enforced **client-side**: every member's client hides a
message carried by a valid moderator label. The label is public and reveals
only that *a* message id was hidden, never its content.
A client honors a label only when its **author is the crew founder or a listed
admin**, so outsiders can't forge moderation. Moderators still see hidden items
(greyed, marked "Hidden") so the action is transparent and reversible.
#### Invites
Because only the founder can write the crew's `p` (member) list, an admin cannot
silently add members. Every moderator can **invite** — share a link/message to
the crew page where the invitee, once listed by the founder, confirms membership
with their own key (the standard bilateral attestation above). Founders get an
extra shortcut to add a member key directly in crew settings.
### NIP-05 Verification
The optional `nip05` tag is a crew handle on **any domain the crew controls**
(fully decentralized — SkateSpots does not gatekeep the handle). Because the
crew event is authored by the founder, a client verifies the handle by fetching
`https://<domain>/.well-known/nostr.json?name=<local>` and checking that the
returned pubkey equals the **founder** pubkey:
| Result | Meaning | UI |
|--------|---------|----|
| name → founder pubkey | Verified | Green ✓ handle |
| name → different pubkey | Impersonation / stale | Red ⚠ handle |
| domain/JSON unreachable | Can't verify right now | Muted handle |
| no `nip05` | — | (nothing) |
Verification is fetched directly with a CORS-proxy fallback and cached
client-side.
### Map Integration
Crews with a `g` (home turf) render on the SkateSpots map as teal pins. Clicking
a crew pin opens its profile. A crew's `a`-referenced home spots remain regular
spots on the map and cross-link back to the crew.
### Querying Crews
```json
{
"kinds": [35006],
"#t": ["skatecrew"],
"limit": 200
}
```
### Crew Agent identity (🐝 Sasquatch)
When a crew carries the `["t","crew-agent"]` marker, it opts into an **AI Crew
Agent** — a Nostr identity that belongs to the crew and can answer members'
questions with live SkateSpots data (spots, events, Lab pros, gear, weather).
The agent's keypair is **deterministically derived** from the crew coordinate:
```
secret = sha256("skatespots:crew-agent:v1" | "35006:<founderPubkey>:<dTag>")
pubkey = secp256k1(secret)
```
Any client can reproduce the same agent identity for a given crew with no stored
state. The agent publishes a kind-0 profile with `bot: true` (NIP-24) named
`🐝 Sasquatch · <CrewName>`.
> **Security note:** because the salt is public and the seed is public crew data,
> this key is *reproducible by anyone* and is **not a secure identity**. It is used
> only for the agent to speak for itself (its profile, and — in a later phase —
> helper replies in its own crew chat). It holds no funds and must never be trusted
> for sensitive signing. A future revision may replace this with a founder-controlled
> stored key without changing the querying model (the `crew-agent` marker stays the
> same).
## CLINK Payments (`noffer` tag)
SkateSpots supports an **optional, opt-in** self-custodial payment code on Lab
provider profiles (kinds 35001–35004), skate crews (kind 35006), and skate shop
stalls (kind 30017). This
is layered on top of — never a replacement for — the existing Lightning Address
(`lud16` from kind 0) and zap flows.
[CLINK](https://clinkme.dev) (Common Lightning Interface for Nostr Keys) defines
Nostr-native Lightning standards. A **CLINK Offer** is a reusable, static payment
code encoded as a bech32 string with an `noffer1…` prefix (CLINK kind 21001),
analogous to an LNURL-Pay / Lightning Address but requiring no web server or
custodian.
### The `noffer` tag
| Tag | Value | Description |
|-----|-------|-------------|
| `noffer` | `noffer1…` | A CLINK Offer code the provider/shop controls. |
```json
["noffer", "noffer1qqs..."]
```
### Client behavior
- The tag is **optional**. Its absence changes nothing.
- Clients **MUST** validate the `noffer1…` prefix before rendering a payment
affordance. SkateSpots stores it only if it matches
`/^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/`.
- SkateSpots does **not** decode or transact the offer itself. It surfaces a
"Pay via CLINK" affordance (QR of the code, copy button, and a `nostr:noffer1…`
deep link) so a CLINK-compatible wallet (e.g. ShockWallet, Lightning.Pub) can
request an invoice directly over Nostr.
- The QR scanner also classifies scanned `noffer1…` (and `ndebit1…`) strings so
they are recognized as CLINK codes.
> Only `noffer` (CLINK Offers) is supported in this release. `ndebit` (CLINK
> Debits) and `nmanage` (CLINK Manage) are recognized by the scanner but not yet
> actioned in-app.
## Spot Chat (kind 24899)
Spot Chat provides **ephemeral, geohash-anchored local chat channels**. Every
location on the map — and every skate spot — has a live public channel keyed to
its geohash, giving skaters a "here and now" side-groupchat for a place ("anyone
skating tonight?").
This is inspired by BitChat's location-channel concept but is **SkateSpots-native**:
it uses its own kind and tag conventions and is not intended to interoperate with
BitChat's channel format.
### Event Format
```json
{
"kind": 24899,
"content": "<message text>",
"tags": [
["g", "<channel geohash>"],
["n", "skate"],
["alt", "Spot Chat message in geohash <channel>"]
]
}
```
### Behavior
- **Ephemeral** (kind in the 20000–29999 range): relays are not expected to store
these events permanently, matching the transient nature of local chat.
- **Channel = geohash prefix.** The `g` tag holds a geohash truncated to a
precision level. Clients query `#g` for the channel key. Because a more precise
geohash *starts with* a coarser one, a block-level message naturally appears in
the enclosing neighborhood/city channel.
| Precision | Geohash length | Approx. area |
|-----------|----------------|--------------|
| Block | 8 | ~40 m |
| Neighborhood | 6 | ~1.2 km |
| City | 5 | ~5 km |
- Clients display roughly the last 24 hours of messages.
- Message content is limited to 500 characters.
### IRC-style commands (client convention)
These are a light, optional flavor layer handled entirely client-side; plain
typing always works.
| Command | Effect |
|---------|--------|
| `/me <action>` | Content is published verbatim (prefixed with `/me `); clients render it as a third-person action line ("✳ alice does a kickflip"). |
| `/who` | Not published — the client shows the count of skaters who posted in the channel in the last 24h. |
### Required Tags
| Tag | Description |
|-----|-------------|
| `g` | Channel geohash (the truncated location key) |
### Optional Tags
| Tag | Description |
|-----|-------------|
| `n` | Namespace marker, always `skate` for SkateSpots channels |
| `alt` | NIP-31 human-readable description |