# Project Overview This project is a Nostr client application built with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify. ## Technology Stack - **React 18.x**: Stable version of React with hooks, concurrent rendering, and improved performance - **TailwindCSS 3.x**: Utility-first CSS framework for styling - **Vite**: Fast build tool and development server - **shadcn/ui**: Unstyled, accessible UI components built with Radix UI and Tailwind - **Nostrify**: Nostr protocol framework for Deno and web - **React Router**: `react-router-dom` with **HashRouter** (routes live in the URL hash, e.g. `/#/settings`) and ScrollToTop functionality - **TanStack Query**: For data fetching, caching, and state management - **TypeScript**: For type-safe JavaScript development ## 🧠 This file is the ONLY persistent memory β€” keep it in sync `AGENTS.md` is the single thing carried across sessions; a model starts every session with **no other memory of this project**. So: - **Write important things down here in the same session you learn them.** If it isn't in this file, the next agent doesn't know it and will re-derive (or get) it wrong. - **Record the non-obvious**: exact commands + flags, file paths, gotchas, environment quirks, verified facts, and *why* a decision was made. Skip what anyone can read from the code. - **Fix stale entries immediately.** A wrong note here makes every future session go off the rails β€” check claims against the repo before trusting them. - **Keep it tight and factual**: commands/paths over prose, no essays. - When you change something this file describes (scripts, kinds, deploy, hooks), update that section as part of the same change. Date non-trivial claims: `(verified 2026-09-26)`. ### Project map (what exists right now) - **Router**: `react-router-dom` **HashRouter** β€” real routes live in the URL hash (`/#/settings`, `/#/marketplace`). A bare `/settings` renders the homepage (this silently broke the boot smoke test once). `src/AppRouter.tsx`. - **Custom protocol**: `NIP.md` is the source of truth for every custom/adopted kind; the constants live in `src/lib/types.ts`. Highlights: - `30818` skate spot Β· `30017` shop Β· `31923`/`31925` events + RSVP Β· `30823` tours - `30402` NIP-99 listing = marketplace item, and the **Assertion Event** for trades - `30403` marketplace offer (+ seller response) β€” see `NIP.md` - `31871` trade attestation (sale/purchase) Β· `31872` trade confirmation request Β· `31873` trusted-trader recommendation β†’ `src/hooks/useTradeAttestations.ts` / `useTraderRecommendations.ts`; reputation UI in `TradeReputationCard` / `TradeRecommendationsCard` - `35000–35014` role profiles (skater/filmer/coach/…/event-organizer/barber) Β· `35006` crews Β· `30003` spot collections - **Lab offer flows** (`/become/*`): the 7 craft roles share `CraftProfileForm`; coach/filmer/editor/photographer/builder are bespoke but follow the same conventions. Forms **prefill from the existing profile** to edit, and **preserve the `g` geohash + paid `featured` tag on edit**. Bare domains (`example.com`) are coerced to `https://` by **`normalizeUrlInput()`** (`src/lib/sanitizeUrl.ts`) before storage, so links aren't silently hidden by `sanitizeUrl()` at render (the extra-field `url` type used to be dropped outright). `MIN_TITLE_LEN` is **1**: a profile title falls back to the display name, and a 1-char name must not fail validation and vanish. Contact fields are validated on submit: a non-empty email must satisfy `isValidEmail()`, and website/portfolio must resolve through `sanitizeUrl(normalizeUrlInput(...))`. Invalid input shows an inline hint and blocks publish (`social` is exempt β€” it may be a bare `@handle`). - **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared image input β€” **drag & drop + multi-select**, emits `File[]` to its caller (which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and `AddPhotoToSpot`, replacing the old one-file-at-a-time inputs. - `24899` spot chat Β· `2259` spot status Β· `32671` Sasquatch Pro Β· `7103` event updates - **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in the shared **`ListingTradeActions`** (used by BOTH the card and the detail page β€” one implementation). Buyer/seller attestations build mutual, WoT-weighted reputation. **Anti-collusion (2026-09-27):** two accounts can fake mutual confirmations, so reputation leads with **`verifiedCounterparties`** (distinct people mutually confirmed with) instead of the raw `verified` count, weights by WoT with a **per-partner cap of 3**, and shows a warning when several confirmations all come from one account (`TradeReputationCard`). `NIP.md` β†’ "Trust model, sybil/collusion limits" documents that attestations are a signal, **not proof of payment** β€” never present them as trustless. Confirming a trade also records an optional **1–10 deck `rating`** (see `rating` tag). **No payments are implemented** β€” "Buy" is intentionally absent; buyers contact the seller and settle off-app. A **`/trades`** page ("My Trades") manages incoming requests, pending confirmations, and the user's trade records, and can **revoke** a record (`s=revoked`, excluded from reputation) for a dispute or mistake. Structured **offers** (kind `30403`, `useMarketplaceOffers` + the `ListingOffersPanel`/`OfferDialog`) let a buyer propose an amount and the seller accept/decline. - **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer refund key + locktime) β€” see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a `cashu_escrow` pubkey in kind 0. UI wired: `useCashuEscrow`, `EscrowKeyCard` (Wallet), `EscrowRequestDialog` (listing card), `EscrowPanel` + redeem (DM request banner), `Refund escrow` (listing card). **Native-only and UNVERIFIED on a device** β€” verify with the on-device checklist in `ESCROW.md` (tiny amounts) before trusting it with real money. - **Wallet**: `useCashu` (coco-cashu) is **native-only** β€” it returns early on web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`). - **Relays**: NIP-65 read/write (kind 10002), NIP-17 **DM inbox relays** (kind 10050; set in the Relays UI, fetched on login by `NostrSync`, delivered to on send and read from in `useDirectMessages`), plus two **app-local** categories β€” `localRelays` (never published; added to reads+writes) and `blockedRelays` (excluded everywhere). `resolveRelays()` in `src/lib/relays.ts` applies them; `NostrProvider` uses it for its req/event routers. Deliberately **not** shipped: KeyPackage relays (`10051`, no MLS consumer yet), "Trusted" (no distinct behaviour β€” would be empty UI), and Amethyst's proxy/ broadcast/private-home (we don't have that relay-discovery model) β€” don't add UI for features we don't implement. - All relay consumers now go through **`useRelays()`** (read/write β€” blocklist applied, local included): listing reports, seller info, skate relays, zaps, Sasquatch purchase, collections, crew form, and DMs. AppProvider's empty-relays defaults check is the only intentional direct reader. - DM **inbox** relays (kind 10050) are also **block-filtered** now (reads and delivery), so the blocklist applies to DMs too. - **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`). - ⚠️ **Zod strips undeclared keys β€” it cost us the DM relay list (fixed 2026-09-27).** `AppProvider` validates the persisted config with `AppConfigSchema.partial().parse(...)`, and `RelayMetadataSchema` only declared `relays` + `updatedAt`. Zod's default behaviour is to **drop unknown keys**, so `dmRelays`, `localRelays` and `blockedRelays` were **wiped on every load**. The DM inbox relays (kind 10050) are usually NOT in a user's NIP-65 list, so with `dmRelays` gone the app read/wrote DMs only over NIP-65 relays β€” where the other side isn't listening β€” and inbox messages appeared to never arrive. **Rule: any new `RelayMetadata`/`AppConfig` field MUST be added to the Zod schema** or it silently won't persist. Verified end-to-end: before the fix the gift wrap reached only write relays (recipient found 0); after, it lands on the configured inbox relays (recipient and self-fetch both find it). - ⚠️ **DM history read from the wrong relays β€” "No messages yet" with a full inbox (fixed 2026-09-28).** All four DM read hooks built their relay list from `config.relayMetadata.dmRelays` (login-time sync only). If that was stale/empty, only the NIP-65 read relays were queried β€” and gift wraps almost never live there (real case: 94 addressed wraps on the user's inbox relay `nostr.mom`, **0–1** on their NIP-65 relays). Fix: **`useResolvedDmRelays()`** in `useDirectMessages.ts` discovers the user's own **kind-10050** inbox relays at query time (per-relay, 6s each, over the read relays + `INBOX_DISCOVERY_RELAYS`) and unions them in β€” so a stale config can never silently hide history. Used by `useConversation`/`useConversations`/`useGroupConversations`/`useGroupConversation`. **`useConversations` now returns `ConversationsResult` (`{ conversations, undecryptable, wraps }`)** β€” consumers are `DirectMessagesPage` and `ContactsPage` β€” and the DM page shows "N encrypted messages couldn't be opened" so a decrypt failure is no longer indistinguishable from an empty inbox. Diagnostic recipe: count `{#p:[pubkey], kinds:[1059]}` per relay (per-relay, not one pool query) before assuming a code bug. - **DM send/read robustness (same date):** gift-wrap publishing and gift-wrap reading were both **sequential with one shared timeout**, so a single dead relay (e.g. `wotr.relatr.xyz`) or an auth-required one (`auth.nostr1.com`) stalled the send or aborted the remaining reads β€” hiding every message. Both are now **parallel with per-relay timeouts**; the send **resolves on the first relay that accepts** and continues the rest in the background, and a recipient with no discoverable 10050 falls back to their **NIP-65 write relays** (previously the NIP-65 fallback was effectively dead code because our own DM relays are always non-empty). `NRelay1.event()` DOES throw on `OK:false`, so per-relay failures are real; the composer only clears on success. - **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and does **not** use the ContextVM server. AI goes through our own **server-side proxy** β€” the Netlify Function `netlify/functions/ai.mjs` at `/api/ai` β€” which runs against **Netlify's AI Gateway**. No key is managed or shipped: Netlify injects `OPENROUTER_API_KEY` + `OPENROUTER_BASE_URL` into Functions, and the function reads the base URL (so it also works if you later set your own key). Model choice is a curated allowlist (the gateway enforces Zero Data Retention, so gemini/anthropic/gpt-4o-mini are **not** routable) β€” keep `ALLOWED_MODELS` in the function in sync with `PROXY_MODELS` in `sasquatchBrain.ts`. Keyless **Pollinations** is the fallback. The proxy has an **env-gated Pro paywall** and it is **ENABLED in production** (`SASQUATCH_REQUIRE_PRO=1`): `/api/ai` requires `x-sasquatch-auth` (a NIP-98 kind-27235 event) plus `x-sasquatch-pro` (the Pro zap receipt, verified offline incl. paid-amountβ†’tier); platform owners bypass. Disable with `netlify env:unset SASQUATCH_REQUIRE_PRO`. The client (`src/lib/proAuth.ts`) signs NIP-98 once per session (no Amber pop per message) and attaches the receipt β€” used by BOTH `SasquatchGuide` and the crew agent (`useCrewAgentChat`). Callers without credentials 401 and fall back to keyless **Pollinations**. The pay-to address is single-sourced in `src/lib/platform.ts`. The upstream is **provider-agnostic**: `SASQUATCH_AI_BASE` + `SASQUATCH_AI_KEY` override the Netlify gateway (any OpenAI-compatible endpoint). For a **sats-only** provider use Routstr (`SASQUATCH_AI_BASE=https://api.routstr.com/v1`, key = a cashu…/sk- session token; `SASQUATCH_MODELS` must match `PROXY_MODELS`). ⚠️ The **aggregator `api.routstr.com` is DOWN** (404) β€” use a **node**. Nodes announce on Nostr as **kind 38421** with the endpoint in a `u` tag. **Live + working (2026-09-27): `https://routstr.githappens.space`** (accepts the minibits mint; `gemini-3-6-flash` answers). Others were up too but `ai.orangesync.tech` issued an `sk-` key that its own inference endpoint rejected ("User not found") β€” always test inference before committing funds. Funding: `POST /lightning/invoice {amount_sats,model}` β†’ pay bolt11 β†’ `GET /lightning/invoice//status` β†’ `api_key` (`sk-…`). Refund: `POST /v1/balance/refund` β†’ a Cashu token (recoverable/portable). Then set `SASQUATCH_AI_BASE=/v1`, `SASQUATCH_AI_KEY=sk-…`, `SASQUATCH_MODELS=` β€” **Netlify snapshots env at deploy, so redeploy after changing it**. The proxy substitutes its first allowed model when the client's slug isn't allowed, so the client list need not match. Owner Settings shows the upstream balance (`SasquatchAiStatusCard`) via the owner-only `POST /api/ai/status` (NIP-98 + owner pubkey β†’ `handleStatus`), with a "low balance" warning below 500 sats β€” the top-up nudge (no webhook needed). The same balance shows as a chip in the Sasquatch panel header (owner-only, fetched only while the panel is open so it never prompts at startup). **AI answers are Pro-only**: free users get the static FAQ/glossary + local relay lookups only (`if (isPro)` gates the AI block in `SasquatchGuide`). Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt, and its **expiry is derived from the paid amount β†’ tier**, never the user-signed `pro-expiry` tag. - **Pro proofs STACK (fixed 2026-09-28).** `useSasquatchPro` now collects ALL valid proofs and accumulates `expiry = max(expiry, proofTime) + tierDays` (renewal semantics) β€” before, it took the **max** of `receipt.created_at + days`, so buying again before expiry silently under-delivered ("Extend" did not extend). The proof sent to the proxy is now `{ receipts, grants }` (base64) instead of one receipt; **both servers (`ai-proxy/server.ts` and `netlify/functions/ai.mjs`) accept the new payload AND a bare receipt**, so installed APKs keep working. ⚠️ Deploy the **proxy before the web client** (old proxy + new payload = 402). - **Owner-grant fallback (NIP-32, same date):** `org.skatespots.pro` kind-1985 labels authored by an owner pubkey, tagged `p` = user + `expiry`, are a trusted Pro source (`useSasquatchPro` + both servers honour them) β€” the safety net if the LN provider ever stops emitting zap receipts. Published by the owner-only **`GrantProCard`** (Settings β†’ Advanced), written to `getWriteRelays()` and read via `querySkateContent` (community relay + user relays); the self-signed `32671` remains advisory-only. `sasquatchBrain.ts` caps each request (15 s) and the whole attempt (35 s); the system prompt is a real `system` message; relay results are signature-verified (`verifyEvent`) before use. The first pass **streams** (`streamSasquatchAI`, OpenAI-style SSE) into a live bubble β€” raw JSON tool calls are suppressed so partial JSON never renders. Pollinations' `mistral` slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` + `getCommunityStats` cover **all** Lab roles (kinds 35001–35014). - **ContextVM (CVM) server** (`cvm-server/`, Bun): standalone MCP-over-Nostr endpoint for *external* agents. Runs on this machine as a **system** service (`/etc/systemd/system/skatespots-cvm.service`, `sudo systemctl restart skatespots-cvm`) + a daily-restart timer. Re-publishes its discovery announcements on a **heartbeat** (`CVM_HEARTBEAT_MS`, default 15 min) so the owner Settings health card (stale after 45 min) stays green; `LOG_LEVEL` is now wired to the transport. Relays come from `NOSTR_RELAYS` in the gitignored `cvm-server/.env` β€” policy is **single community relay** (`wss://goskatespots.nostr1.com`). The tracked manifest is `package.cvm.json`. Tools: `find_spots` / `find_events` / `find_coaches` (all Lab kinds 35001–35014) / `find_gear` / `find_crews` / `find_shops` / `find_tours` + `get_community_stats` β€” kept at parity with the app's `useCVMClient`. Has a per-pubkey sliding-window + in-flight throttle, a global upstream semaphore and a short-TTL query cache (`CVM_RATE_*`, `CVM_MAX_*`, `CVM_QUERY_CACHE_MS`). Type-checked by `cvm-server/tsconfig.json` via the `typecheck:cvm` script (wired into `npm test`); `check:cvm` runs a Bun transpile. **The server runs on one external host (not this box)** β€” deploy with `npm run deploy:cvm`; the host/SSH values come from the never-committed `~/.config/skatespots/cvm.env` (never put them in a tracked file β€” this repo is public). `scripts/deploy-cvm.sh --rollback` restores the newest backup. The discovery relay (`relay.contextvm.org`) is the real single point of failure, not the server. - **Routing / directions**: `RouteDialog` is the single in-app router, opened from the map spot sheet, the food cards, and the spot / shop / event detail pages (MapPage's old external "Open with" Google/OSM/Waze menu was removed). Road modes (skate-walk / bike / moto / car) come from keyless **OSRM** (`src/lib/routing.ts`). ⚠️ **Use the per-profile FOSSGIS servers, not the demo one:** `router.project-osrm.org` hosts a SINGLE car dataset and **ignores the profile in the URL**, so foot/bike/car all returned identical distance+time (the "every transport option shows the same time" report). We now call `https://routing.openstreetmap.de/routed-{foot|bike|car}/route/v1/{foot|bike| driving}/…` (CORS `*`, direct first, `proxy.shakespeare.diy` proxy only as fallback), request `steps=true` and expose `RouteStep[]` (verified 2026-09-27: same trip = walk 13 min / bike 5 / drive 2). The demo server survives only as a last-resort fallback and its result is flagged `approximate` ("driving estimate") so we never pass driving numbers off as walking. Motoβ‰ˆcar is labelled honestly; the foot summary also shows a ~10 km/h `skateTime` cruise estimate ("~6 min on a board") next to the walking time. The dialog also remembers the last mode (`localStorage: skatespots:route-mode`), shows an ETA ("arrive ~08:10 PM"), and each step row is **tappable** (pans the map + highlights) with a "Whole route" re-fit button. - ⚠️ `index.css` has a global mobile rule `button, [role=button] { min-height: 44px; min-width: 44px }` for touch targets. Any dense list of `