scripts/archive-relay.mjs snapshots the app's own content off
goskatespots.nostr1.com to gzipped JSONL of the raw signed events, so a
restore can republish them verbatim to any relay. scripts/restore-relay.mjs
re-verifies signatures and publishes to an explicit --to (no default, to
avoid replaying a snapshot onto the live relay). scripts/backup-relay.sh
wraps it and pushes with restic when a repo is configured; a weekly systemd
user timer runs it.
Scoped on purpose: that relay is PUBLIC and carries general traffic (15.7k
kind-0 profiles, 2k kind-30078 app-data from other clients, 408 foreign
NIP-99 listings), so the archive keeps only the app's own kinds/tags plus
contributor profiles, SkateSpots labels, and reports that reference archived
content. ~114 events / 63 KiB.
Not configured yet: offsite (restic.env) and a full restore drill.
SkateSpots now runs its own NIP-05 registry, so a new skater gets a human
handle from minute one instead of an npub.
- netlify/functions/nip05.mjs: /.well-known/nostr.json plus
/api/nip05/{check,claim,release}, backed by Netlify Blobs (first-party,
no new vendor). Claims are NIP-98 signed and written onlyIfNew, so only
the owner can claim a name for themselves. Platform names are seeded in
code and cannot be taken or released.
- src/lib/skateNip05.ts + src/hooks/useSkateNip05.ts +
src/components/SkateNip05Input.tsx: shared validation, debounced
availability, and the claim control.
- SignupDialog: a 'SkateSpots name' field, prefilled from the display
name. Claimed best-effort before the profile publish; a failure never
blocks signup, and the profile only advertises nip05 once confirmed.
- EditProfileForm: claim a free handle from the NIP-05 field.
Verified on a draft deploy: claim -> resolves -> a second key gets 409 ->
release -> gone. tsc/eslint/101 tests green.
The sticker + poster were their own 'Free to print' section, which made the shop
read as two lists. They now live inside the 'Sticker packs' card in the 'In the
works' grid (full-width, FREE badge, compact previews + Print/Save), between
Decks and Shop tools — so a category shows what it already offers.
Verified: no 'Free to print' heading remains, both printables still render, and
the page reads hero -> in the works (5 entries) -> how buying works.
- Remove MKStack and Shakespeare credits (owner request). AGENTS.md updated so a
future session does not re-add the old 'Vibed with MKStack' instruction.
- 'Nostr' now links to the protocol repo (github.com/nostr-protocol/nostr) in
both the Credits card and the 'Powered by' footer.
- Add: Cordn (cordn.net), Cashu (cashu.space), NWC (docs.nwc.dev), Amber
(github.com/greenart7c3/Amber), BTCMap (btcmap.org).
Verified in the browser: no MKStack/Shakespeare text, all five new links present
with the right hrefs, BTCMap spelled correctly.
- Report INBOX: /review now defaults to a Reports tab. useRecentReports fetches
recent NIP-56 (1984) and resolves the referenced event by id (one extra ids:
query), so ANY report is actionable — content that isn't flagged, comments,
profiles, anything. reportTargetMeta maps kind -> label + route for the link.
Actions: Hide / Dismiss report (verify) / Undo / Look at it.
- Bulk undo: when a tab holds >1 verdict, a toolbar offers 'Undo all N' with an
AlertDialog that warns each undo is its own label publish (one Amber approval
each). Built for the 13 old hidden labels from Sep 3.
- Crews + Lab tabs: added includeHidden to useCrews/useLabProfiles. Hiding these
removes a community / a person's listing, so their Hide goes through a
confirmation (confirmHide/confirmHideNote on QueueList).
Verified in the browser: 8 tabs render; the inbox shows and resolves a real
report (target + reason); the Spots tab shows the bulk-undo toolbar.
tsc/eslint clean, 101 tests, smoke PASS.
(a) Review queue covers spots/shops/events/tours/gear: added { includeHidden }
(admin-tooling only) to useSkateShops/useSkateEvents/useTours/
useMarketplaceItems; generalised the flags to contentFlags() in spotQuality.ts
(trust/status/photo signals stay spot-only); one component per kind so only the
OPEN tab queries. Reports + trust are kind-agnostic; the label mechanism never
cared about kind.
(b) Photo gate softened: removed the hard block (it punished an honest mapper with
no photo). Publish without a photo is allowed; the spot stays Unverified, the
success toast says so, and the queue's 'no photo' flag lets a human look.
(c) Empty-map nudge: MapPage now distinguishes a FILTER empty ('No spots match')
from a genuinely EMPTY map ('No spots mapped yet — be the first to map your
park', with Add-spot + Near-me). Note: must key off allLocations, not mapSpots
(which derives from filteredSpots).
Verified in the browser: the empty-map nudge renders with its CTAs when no spots
load; the review queue renders 15 spot rows and the tabs switch (Gear shows its
own empty state). tsc/eslint clean, 101 tests, smoke PASS.
1) Reports were a VOID: ReportDialog publishes NIP-56 (1984) but the only reader
in the app was a count on marketplace cards, so an admin never saw one. Now
useSpotReports batch-reads kind 1984 by '#e' (one query for every spot) and
the review queue ranks reported spots ABOVE the heuristics with a red
'REPORTED: ...' chip. useNotASpotReport publishes to getWriteRelays()
(community relay + the reporter's), not the default pool — the admin reads
from the community relay, so reports written only to personal relays would
never arrive. Verified end-to-end: published a report from Node, saw it
surface in the queue.
2) Duplicate guard in CreateSpotPage: on submit, existing spots within 60 m are
listed with distances + links, with 'It's a different spot — publish anyway'.
Targets the clone-spam failure mode (five near-identical pins).
3) Contributor feedback: useSkateSpot no longer nulls a hidden spot for its
AUTHOR (it used to silently 404 for the person who made it), and
SpotDetailPage shows an author-only banner for hidden/verified.
tsc/eslint clean, 101 tests, smoke PASS.
Undo (latest verdict wins):
- MODERATION_LABEL_CLEARED = 'cleared' — the neutral undo verdict.
- useAdminModeration no longer UNIONS labels (which made a hide permanent in the
UI); it keeps the NEWEST verdict per target (created_at, with device-local
verdicts overlaid by timestamp), so 'cleared' supersedes an earlier
'hidden'/'verified'. useAdminClear() publishes it.
- Local mirror is now a verdict map (skatespots_local_admin_verdicts_v1: key ->
{verdict, at}) with migration from the legacy hidden/confirmed arrays.
- useSkateSpots(limit, { includeHidden: true }) — ADMIN TOOLING ONLY: without it
a hidden spot leaves the list, so a verdict could never be undone. The review
queue uses it, keeps actioned rows (Hidden/Confirmed badge + Undo), and sorts
un-actioned first.
Community photos as a signal:
- useBatchSpotPhotos: ONE #a query for every spot (the single-spot hook already
queried by #a, so batching is free), counting only photos from people OTHER
than the mapper. Folded into the 'no community signals' flag.
Verified by seeding local verdicts: none -> flags + Confirm/Hide; hidden ->
Undo; verified -> Undo; cleared -> back to neutral. tsc/eslint clean, 101 tests,
smoke PASS.
- MODERATION_LABEL_VERIFIED: the positive counterpart to 'hidden'. Both labels
are fetched in ONE query; useAdminModeration now exposes verifiedCoords/
verifiedIds/isVerified() with a local mirror (addLocalConfirmed/
getLocalConfirmed). useAdminVerify() publishes it (shares the publish path
with useAdminHide).
- Review queue: a human review can now CONFIRM a spot, not just hide it. A
confirmed spot clears from the queue and gets a 'Verified by SkateSpots' badge
on the spot page (staff verdict, shown separately from the community badge).
- Two more signals: mapper trust (useBatchTrustScores) and whether any skater has
filed a status report (useBatchSpotStatus reporterCount) -> 'mapper not in your
web of trust' / 'no community signals'. The latter only fires alongside another
flag so a brand-new real spot isn't punished for being new.
- Flag logic moved to src/lib/spotQuality.ts so the page and AdminReviewCard share it.
Verified: the queue lists the airport entry with suspicious name + no photo + no
community signals, shows mapper trust, and offers Confirm/Hide/Not a skate
spot/Look at it.
The 'test of secret island airport' problem: a junk pin wearing an app-endorsed
'Skateability 95/100'.
- Rename the weather score to 'Conditions · weather only' (SpotDetailPage +
the SpotMap popup). It was never a verdict on the place.
- New Community verified / Unverified badge from signals only OTHER skaters can
produce: community photos + useSpotStatus reporterCount (kind 2259).
- CreateSpotPage now REQUIRES at least one photo (guard + disabled submit +
a required hint on the label).
- useNotASpotReport: one-tap NIP-56 (1984) report tagged 'not-a-skate-spot'
(NIP-56 'other' + our L/l/t taxonomy), wired into the spot's More menu and
added to the shared ReportDialog reasons.
- useAdminHide: the MISSING WRITE SIDE for admin moderation. useAdminModeration
always read NIP-32 kind-1985 'hidden' labels but nothing published one, so the
only working hide was device-local. Now publishes the label (+ optimistic
local hide). Soft and reversible — the event stays on relays.
- /review (AdminReviewPage, admin-only → 404 otherwise): review queue over
useSkateSpots with cheap flags (suspicious title, name <=3 chars, no photo, no
description, no geohash), sorted by flag count, one-tap Hide or Not a skate
spot. Entry via the admin-only AdminReviewCard in Settings → Advanced.
- /review added to the smoke-test route list.
Verified: the airport spot shows UNVERIFIED with 'Conditions · weather only' and
no 'Skateability'; the review queue lists it with flags + Hide/report actions;
create-spot blocks without a photo.
Per request, the page is now: hero -> in the works -> free to print -> how
buying works. Nothing else changed.
Honesty is still carried by the remaining copy: the COMING SOON badge, the
'IN THE WORKS / not for sale yet' label on the teasers, and the hero's 'sold
locally, on request'. No prices, no buy buttons, no stock list.
Also drops the now-unused NewsletterSubscribeButton/ArrowRight imports, and
updates release-notes + AGENTS.md (they described the removed copy).
- public/images/print/skatespots-sticker.svg (55 x 86 mm) and
skatespots-poster.svg (A3) — the free artwork to print and stick up. Physical
size is baked into the SVG (mm units), so 'Actual size' printing is exact.
- Self-contained: Outfit/Barlow fonts + the logo embedded as data URIs, and a
real vector QR. VERIFIED both QRs decode to https://skate-spots.org (decoded
with jsQR from a rendered PNG; logo centre, error-correction H).
- New 'Free to print' section in the App Shop: previews, FREE badge, sizes,
cutting tip, 'Open & print' + 'Save file' buttons, and a 100%/Actual-size note.
- scripts/make-print-art.mjs regenerates the artwork (committed output).
- AGENTS.md documents the assets + the re-verify-the-QR rule.
- Remove the 'Shipped worldwide' and 'At cost, no markup theatre' chips; the
remaining chips are Lightning, Cashu, Local handover.
- Copy now says sold locally, on request: message me -> we sort the details ->
pay in sats at handover. No shipping, at all.
- 'How it'll work' -> 'How buying works' (it's a DM, not a cart): Message me /
We sort it out / Pay in sats.
- New 'Request an item' CTA in the hero that deep-links into a private DM with
the shop owner (/messages/<PLATFORM_OWNER_PUBKEYS[0] npub>), so a buyer can
start the conversation with zero checkout machinery.
- Honesty footer states local handover only / no shipping of any kind.
- AGENTS.md documents the model (no cart/checkout/shipping without a decision).
New /app-shop page for SkateSpots merch, to be paid in sats only (Lightning or
Cashu) — no cards, no accounts, no middleman.
Design: gradient/glow hero ('Wear the scene' + Coming soon badge), an honest
stock strip (0 items / None pre-orders / Nothing for sale today), an 'In the
works' teaser grid (tees, decks, stickers, tools — dashed + 'soon', no prices),
a three-step 'How it'll work' (pick -> pay in sats -> ships), a newsletter
notify band (the drop lands there first) and a plain-English honesty footer.
Deliberately contains no items: no stock, no prices, no pre-orders, no buy
buttons. AGENTS.md documents that rule so nobody invents a catalogue later.
Wiring: registered in NAV_CATALOG as id 'app-shop' (appears in the desktop
'More' menu AND the mobile burger menu automatically), lazy route in AppRouter,
and added to the smoke-test route list so a broken page aborts deploys.
Verified: page renders desktop + mobile (all sections), listed in both navs,
smoke PASS with /app-shop.
Root cause: AppProvider validates the persisted config with
AppConfigSchema.partial().parse(), and RelayMetadataSchema only declared
'relays' + 'updatedAt'. Zod drops unknown keys by default, so dmRelays (and
localRelays + blockedRelays) were SILENTLY STRIPPED on every page load. The DM
inbox relays (kind 10050) are usually not in a user's NIP-65 list, so DM reads
and writes fell back to NIP-65 relays - where the other side isn't listening -
and messages looked like they never went through.
- Declare localRelays/blockedRelays/dmRelays in RelayMetadataSchema.
- Send: publish gift wraps in PARALLEL with per-relay timeouts and resolve on
the first relay that accepts (a dead relay or auth.nostr1.com no longer
stalls the send); deliver to the recipient's NIP-65 WRITE relays when they
have no discoverable 10050 (the old fallback was dead code because our own DM
relays are always non-empty); discovery of 10050/10002 now runs in parallel.
- Read: query all DM relays in parallel with per-relay timeouts. Previously one
slow/dead relay burned the shared 15s budget and then the fired abort made
every remaining relay fail instantly.
Verified with an nsec login + a hostile relay list (dead + auth-required):
before, the wrap reached only write relays and the recipient found 0; after, it
lands on the configured inbox relays and both the recipient and our own copy are
found. Send worst case 20s -> 4.5s.
- Map is now the hero: h-[38vh] mobile / 400px desktop (was 256), with a metric
Leaflet scale control.
- New layout: flex column with header/modes/From/map pinned, the details as the
ONLY scroll region, and Start/Stop pinned at the bottom so it's always
reachable. Previously the dialog was a single overflow-hidden box, so once the
map grew everything below the fold (Start, steps) was unreachable.
- Summary rebuilt as stat tiles (distance / time / arrive) plus a detail line:
step count, honest 'uses driving roads'/'driving estimate' notes, and the
average speed the engine assumed ('21 km/h avg'; foot keeps the skate cruise
estimate). Steps header shows the count; step list scrolls taller (max-h-72).
- Wider dialog on desktop (sm:max-w-xl).
Verified in-app: tiles + 9/8/5/5 steps per mode, follow banner still tracks
movement, offline chip still appears, map 371px -> 321px with the new layout.
Follow mode (explicit Start; off by default — a watcher drains battery):
watchPosition in geolocation.ts, live dot, 'NEXT · step N' banner with
distance-to-turn + distance-to-go, current step highlighted, map follows.
A transient watch error (timeout/unavailable) no longer kills it — only a
permission denial does; the watcher recovers on its own.
Choose your start: 'From → My location / Choose on map' — tapping the map sets
the origin, and the route recomputes from there.
Offline: src/lib/routeCache.ts keeps the last route per destination+mode with
the line downsampled to <=250 points. If a fetch fails (no signal / routing
server down) the dialog falls back to it with an 'Offline · saved route {age}'
badge instead of a dead error.
Also tidied the map overlays (offline badge bottom-left, Whole route
bottom-right) so nothing covers Leaflet's zoom control.
Verified with CDP: controls present; follow banner tracks movement (step 1 ->
step 7, distances update); blocking routing shows the cached route + badge;
picking a start on the map changed the route (968 m -> 922 m). Still no voice
guidance or re-routing if you leave the route (documented).
Answering 'are we giving the best navigation experience?': no, it was a preview
with dead ends. Fixes the concrete gaps:
- ETA: 'arrive ~08:10 PM' alongside the duration.
- Mode memory: the last travel mode is remembered (skatespots:route-mode), so
cyclists/drivers aren't reset to Skate/Walk every trip.
- Tappable steps: each step pans the map and highlights; added a 'Whole route'
re-fit button to zoom back out.
- Taller route map on desktop (h-64 sm:h-80).
- min-h-0 on step rows: the app-wide 44px mobile touch-target rule was making
every step a 44px slab (rows now 29px).
Still out of scope (documented, not faked): live turn-by-turn following, voice
guidance and offline routing.
AMBER — 'pops on every screen':
staleTime: Infinity only stops REFETCHING, never the FIRST decrypt. So the
encrypted useSyncedAppData blobs (read-state, preferences) each ran a queryFn on
mount (racing consumers multiplied it) and relay responses landing during
navigation made it look per-page. useSyncedAppData now has interactiveNetwork
(default false): for an interactive signer the blob is LOCAL-ONLY — query not
even enabled, patch() a no-op — no decrypt on mount, no publish on a nav-tab
change. Verified with a mocked x-amber login: network-on => Amber op at startup,
with the fix => 0. (useCrewDismissals/useStash were already gated.)
ROUTING — 'same time for every transport option':
router.project-osrm.org hosts ONE car dataset and ignores the profile in the URL,
so foot/bike/car returned identical numbers. Switched to the FOSSGIS per-profile
servers (routing.openstreetmap.de/routed-{foot,bike,car}); the demo server is now
only a last-resort fallback and its result is flagged approximate ('driving
estimate'). Request steps=true and surface turn-by-turn steps for orientation,
plus a '~X min on a board' skate estimate next to the walking time. Verified:
same trip => walk 13 min / bike 5 / moto+car 2, with 5-9 steps each.
Audit of the newsletter section found two real bugs.
1) /newsletter rendered in an infinite loop (100% CPU, React 'Maximum update
depth exceeded'). Chain: useSyncedAppData returned a fresh object each render
-> useReadState.apply (dep: synced) changed identity -> markNewsletterRead /
markAllRead changed every render -> the page effect (deps include it) re-ran
after every render -> the mark set a NEW state object -> loop. The
notification bell looped the same way while open.
- useLocalStorage: setter is now referentially stable (ref + functional set).
- useSyncedAppData: returned object is memoized.
- useReadState: apply reads synced/interactive from refs (stable); marks are
idempotent (same object when already marked) so React bails.
- NewsletterPage marks read once per visit (mount-once ref guard).
2) Issues + subscription markers were published via the default pool (the
author's own NIP-65 relays) but READ via useSkateRelays (community relay +
reader's relays; logged-out = community relay only), so an admin without
goskatespots in their list would publish drops nobody could see. Composer,
backfill panel and the subscribe hook now publish to getWriteRelays() and
fail loudly if every relay rejects (useSpotChat pattern). NIP.md untouched
(no schema change).
Also: guard the overlay pill/reminder against an empty archive, and drop a
redundant canPublish clause.
On the native app the shell is served from https://localhost, so the relative
/api/mints/known hit the local asset server and returned index.html, surfacing as
'Unexpected token < ... not valid JSON' on /mints.
- Native now fetches https://skate-spots.org/api/mints/known via CapacitorHttp
(bundled core plugin, no WebView CORS), with a plain-fetch fallback.
- Web/dev unchanged (same-origin relative path via the Netlify redirect / Vite
proxy).
- HTML or unparseable bodies now become a friendly 'unavailable' message instead
of a raw JSON parse error.
- Desktop: the nav's overflow-x-auto clips its trailing item when the nav grows,
which is exactly the 'More' menu. Widened the header (max-w-[1700px] vs the
1400px page container), tightened nav padding to px-3 and nowrap. 'More' is
fully visible at 1280/1440/2000px with Marketplace pinned (was missing).
- Mobile: the burger menu hard-coded only Map/Lab/Events/Crews + a few extras, so
Marketplace (and Mints) were unreachable. It now appends every nav-catalog item
not already listed, giving mobile/desktop parity (Marketplace, Mints, …).
RouteDialog was effectively broken — users got an empty dialog / 'no route'.
- Map init: the Leaflet map lives in Radix Dialog content, which mounts AFTER
the first effect pass, so the mapDivRef guard was null, the init effect bailed,
and the map was never created (no route line, ever). Hold the node in state
with a callback ref and add it to the effect deps.
- Height: the global .leaflet-container{height:100%} override beats h-64; against
a height-less parent it resolved to 0 (invisible map). Height now lives on the
wrapper (relative h-64) and the map is h-full.
- Stacking: from the map, Directions rendered the dialog inside the spot Sheet
(two nested Radix modals; the sheet covered the route). The map now owns the
RouteDialog at page level and closes the sheet first.
- Routing transport: call OSRM directly (it sends CORS '*') with the cors proxy
only as fallback; distinguish busy / no-route / unreachable errors and guard
against NaN coordinates.
- Header: shrink-0 logo + whitespace-nowrap + gap, nav shrinks/scrolls so it
can never collide with (or glow over) the SkateSpots wordmark; tighter item
padding. Fixes the top-left logo/nav overlap.
- Amber: useReadState keeps read markers LOCAL-ONLY for interactive signers, so
merely opening the bell/DM no longer publishes (encrypt + sign = 2 popups).
- Reputation: lead with verifiedCounterparties (distinct people), cap each
partner at 3 in the WoT-weighted score, warn when every confirmation is with
one account. NIP.md documents the collusion limit honestly.
- Directions: skip the live GPS call when the cached fix is <2 min old; the
blocking 'Finding your location' overlay only shows with no seed (silent
refine chip otherwise). Near Me also flips the sort to proximity.
- .gitignore: cvm-server/package.json (local dev copy of package.cvm.json).
Owner-only chip (next to the Pro/CVM chips) showing the upstream sats balance,
amber when low. useSasquatchAiStatus gains an 'enabled' option; the panel only
fetches while it is open, so the NIP-98 signature never fires at app startup.
Record the working node, the fund/refund recipe, the aggregator outage, the
'orangesync bad key' trap, and that Netlify snapshots env at deploy (redeploy
after env changes).
If the client asks for a model not in the allowlist, the proxy now uses its
configured default instead of 400-ing. So switching the upstream to a Routstr
node is purely server-env (SASQUATCH_AI_BASE/KEY/MODELS) with no client redeploy.
AGENTS.md documents Routstr discovery (kind 38421, endpoints in 'u' tags), the
live nodes found, and the fund->key recipe.
The proxy now reads its upstream from SASQUATCH_AI_BASE + SASQUATCH_AI_KEY
(falling back to Netlify's injected OPENROUTER_* vars), and the model allowlist
is overridable via SASQUATCH_MODELS. Default behaviour is unchanged (gateway).
This makes switching to a sats-only provider a config change, e.g. Routstr:
SASQUATCH_AI_BASE=https://api.routstr.com/v1
SASQUATCH_AI_KEY=cashuA… (raw token) or sk-… (session)
NOTE: api.routstr.com was returning 404 (empty models) on 2026-09-27, so we are
NOT switching yet. Documented in AGENTS.md.
- netlify/functions/ai.mjs: optional Pro enforcement (SASQUATCH_REQUIRE_PRO=1).
Requires a NIP-98 kind-27235 auth header (x-sasquatch-auth) and, unless the
caller is a platform owner, an active Pro pass (x-sasquatch-pro = a
provider-authored zap receipt) verified OFFLINE — signature, user binding,
platform tag, and paid-amount → tier. Off by default.
- src/lib/proAuth.ts: signs the NIP-98 once per session (no Amber popup per
message) and attaches the Pro receipt.
- sasquatchBrain: threads auth headers through the proxy (streaming + non).
- useSasquatchPro: exposes the winning receipt event.
- Verified: 200 with the gate off, 401 with it on (env reset afterwards).
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
<Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
fragments instead of <Layout> (structure-preserving), imports dropped.
Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
Root cause: Layout is NOT a router layout — ~62 pages wrap themselves in
<Layout>, so its chrome remounts on every route change. The header's
NotificationBell -> useNotificationsRead -> useReadState -> useSyncedAppData
re-decrypts an encrypted kind-30078 event on mount; with Amber (NIP-55) every
nip44.decrypt opens the signer app, so it popped on each menu click.
Fix: for interactive signers, decrypt once per session and serve remounts from
cache (staleTime/gcTime Infinity, refetchOnMount/refetchOnReconnect false) in
useSyncedAppData and useStash. Non-interactive signers refetch as before.
Follow-up (noted in AGENTS.md): hoist Layout into the router so the chrome mounts
once — removes the whole remount class (and SasquatchGuide state resets).
- netlify/functions/ai.mjs: POST /api/ai runs against Netlify's AI Gateway —
Netlify injects OPENROUTER_API_KEY + OPENROUTER_BASE_URL into Functions, so no
key is managed or shipped. Curated model allowlist, best-effort per-IP
throttle, CORS for web + Capacitor, and SSE streamed straight through.
- sasquatchBrain: call the proxy instead of OpenRouter directly; drop
VITE_OPENROUTER_API_KEY entirely; PROXY_MODELS set to the slugs that actually
work via the gateway (its ZDR policy blocks gemini/anthropic/gpt-4o-mini).
- netlify.toml: [functions] directory.
- Verified in production: POST https://skate-spots.org/api/ai -> 200.
Policy: the CVM server queries a single community relay
(wss://goskatespots.nostr1.com). Align .env.example (and the local .env) to it.
The local duplicate unit has been retired; the server runs on one host only.
- scripts/deploy-cvm.sh: backup remote server.ts -> upload -> restart -> confirm
the heartbeat, with --rollback. Host/SSH values come from the never-committed
~/.config/skatespots/cvm.env (or CVM_HOST), so no host details ever enter this
public repo.
- package.json: add `npm run deploy:cvm`.
- AGENTS.md: record that the CVM server runs on ONE external host (deploy via
deploy:cvm) and that the discovery relay, not the server, is the SPOF.
Load protection (public read-only endpoint — protects relays + this box):
- Per-pubkey sliding-window throttle and in-flight cap via inbound middleware.
The transport can't return a JSON-RPC error, so over-budget callers are dropped
and time out (logged).
- Global semaphore caps concurrent upstream relay queries.
- Short-TTL cache keyed by filter set, so repeated identical queries (e.g. a
burst of get_community_stats) don't re-hit the relays.
- All tunable via CVM_RATE_* / CVM_MAX_* / CVM_QUERY_CACHE_MS (documented in
.env.example); maps pruned by a janitor.
CI:
- cvm-server/tsconfig.json type-checks the standalone server; new
typecheck:cvm / check:cvm scripts, with typecheck:cvm wired into npm test so
the server can't rot unnoticed (it was excluded from tsc + eslint).
- MapPage: replace the external 'Open with' menu (Google/OSM/Waze) with the
shared in-app RouteDialog, so the map spot sheet routes natively (incl. the
Transitous transit mode).
- FoodPage: replace the Google Maps handoff with RouteDialog.
- RouteDialog: remove the opt-in external 'Open in' handoff and delete
src/lib/directions.ts — no external map apps anywhere (product decision).
useCVMClient's findCoaches ROLE_KIND, self-tag set and getCommunityStats stopped
at 35012, so Sasquatch could not find the event-organizer (35013) or barber
(35014) profiles we added. Extend LabRole, ROLE_KIND, SELF_TAGS and the stats
queries/counts (lab_total) to cover all Lab kinds 35001-35014.
- sasquatchBrain: new streamSasquatchAI — OpenAI-style SSE via OpenRouter (if
the key is set) then Pollinations; falls back to the non-streaming caller only
when nothing at all was delivered, so blocked-stream environments still answer.
- SasquatchGuide: stream the first pass into a live bubble (raw JSON tool calls
are suppressed so partial JSON never shows; prose-then-tool output is dropped),
finalise the bubble in place, show a blinking cursor, and hide the generic
'thinking...' bubble while a reply streams.
- Drop Pollinations 'mistral' (now a 404) leaving the live 'openai' slug.
- routing.ts: add a 'transit' travel mode via Transitous (MOTIS 2 API, keyless,
open CORS), with a local precision-7 polyline decoder and per-leg parsing.
Road modes stay on keyless OSRM; moto remains ≈car and honestly labelled.
- RouteDialog: transit draws each leg (walk dashed grey, transit coloured by
mode) and summarises depart→arrive, duration, transfers and line chips, with
Transitous/OpenStreetMap attribution.
- directions.ts: opt-in 'Open in' handoff to Google / Apple / OpenStreetMap
(+ Organic Maps via geo: on Android), mode-aware. In-app routing stays the
default; the description notes transit shares the trip with Transitous.
- No scooter/moped profile (deliberate).
- sasquatchBrain: abort each provider request after 15s and give up the whole
multi-provider attempt after 35s, so a hung OpenRouter/Pollinations call can no
longer wedge the chat (it now falls through to the static/local fallbacks).
- SasquatchGuide: send the system prompt as a real system message (both the
first pass and the tool-synthesis pass) instead of smuggling it into a user
turn, and mark tool output as untrusted — blunts prompt injection via event
content.
- useCVMClient: signature-verify (verifyEvent) every relay result, matching the
standalone server and the release-notes claim.
- useCVMServerHealth: stale threshold 1h -> 45m to match the server heartbeat.
- Lab forms: a non-empty email must be valid and website/portfolio must
resolve to an https URL; invalid input shows an inline hint and blocks
publish. Adds isValidEmail() (reused by sanitizeEmail). social/instagram
are exempt since they may be bare @handles.
- New PhotoDropzone component: drag & drop plus multi-select image input,
emitting File[] to the caller. Wired into CreateSpotPage + EditSpotPage
(drop several photos at once) and AddPhotoToSpot, replacing the old
one-file-at-a-time inputs.
- Spot photo uploads run sequentially and dedupe URLs.
- Preserve the geo 'g' tag and paid 'featured' tag when editing any Lab
profile (all 7 craft roles via CraftProfileForm + 5 bespoke pages).
- Render previously collected-but-hidden fields on the Lab profile page:
judge 'events_judged', organizer 'events_hosted', editor hardware/software,
and spot references.
- Include Event Organizer (35013) in Community Stats + counts.
- Cap Title inputs at 100 (was 120) so profiles no longer fail the parser
and silently vanish; MIN_TITLE_LEN 2 -> 1 for 1-char display names.
- Normalize bare-domain URLs (example.com) to https:// before storing instead
of dropping/hiding them (new normalizeUrlInput helper; fixes extra url fields).
- No crew strip for judging/organizing (was wrongly showing Photo & Film);
scrollable Offer Services FAB; live bio counters.