An OS alarm an hour before each upcoming event the user RSVP'd going or
interested to. Because the OS holds the alarm it fires with the app fully
closed — the only closed-app notification achievable without a server.
- src/lib/reminders.ts: schedule/cancel via @capacitor/local-notifications,
tracking handed-out ids in localStorage so withdrawn RSVPs and passed events
are cancelled without disturbing one-off notifications. Web is a no-op.
- src/hooks/useLocalReminders.ts: reuses the existing upcoming-events and
my-RSVPs queries; no new relay destination, no signing.
- Bridge mounts the hook only on native and only when the new 'reminders'
pref is on; unmount cancels everything (opt-out/logout).
- Skipped the Pro-expiry reminder on purpose: it would drag the heavy
useSasquatchPro query into a nudge.
Bumps to v1.5.7 (versionCode 10507); APK rebuilt.
Mirrors the activity the app already computes (bell, crews, DMs, newsletter)
to the OS notification shade, with no new relay traffic and no new network
destination.
- src/lib/osNotify.ts: one delivery API. Web uses the Notification API; native
uses @capacitor/local-notifications (lazily imported, so the web bundle never
pays for it). Tap-through navigates in-app.
- src/components/OsNotificationBridge.tsx: mounted once in Layout, inert until
opted in. Per-source silent priming + a seen-id set means history is never
replayed as a flood, and it stays quiet while the app is visible (the bell
already covers that).
- DMs notify from the gift-wrap ENVELOPE only (src/hooks/useDmEnvelopes.ts,
no decryption) — so Amber is never prompted just to be told you have a
message. Preview is honestly generic.
- src/lib/notificationPrefs.ts + useNotificationPrefs: device-local, opt-in,
default off, per-category toggles. Settings -> Notifications UI.
Deliberately local-only: a fully closed app needs a server push, which is not
built (Phase 2 = on-device background runner; Phase 3 = content-free FCM).
- lib/badges.ts + ProfileBadges: a profile shows (1) the built-in SkateSpots
badge, derived from a nip05 ending @skate-spots.org (app logo linking to
https://skate-spots.org/ — no storage, works for existing users), and (2) user
badges from a kind-0 badges array ({label,url?}) rendered as clickable chips.
- NIP19Page: app badge in the header, user badges in a 'Badges' card (replaces
the plain Sponsors section).
- EditProfileForm: Sponsors editor becomes a Badges editor (label + optional
link); saves the badges array and retires the legacy newline sponsors field.
Existing sponsors are read back as label-only badges, so nothing disappears.
- #3 usePersonalContext decrypts the Stash ON DEMAND (unlockStash, called by
getMyStash) instead of never — 'what's in my stash?' is now correct, while the
globally-mounted guide still never decrypts on mount. stashRef avoids the
stale-closure bug.
- #4 new self-contained action draft wtb_gear (NIP-99 listing_type=wtb) — a
'wanted' gear ad. SYSTEM_PROMPT + tool handler + publish branch.
- #5 panel header shows 'Pro · Nd' days left (title = exact expiry).
- #6 tier picker computes live USD via useExchangeRate; removed the hardcoded
~$60k/BTC usd field from PRO_TIERS.
- #7 copy: 'skate status' not 'spot status'; trip planning stated as free.
#1 Extend now actually extends. useSasquatchPro collected receipts and took the
max of (receipt.created_at + tierDays), so buying again before expiry
under-delivered. It now stacks every valid proof with renewal semantics
(expiry = max(expiry, proofTime) + days), client and server alike.
#2 Owner-grant fallback. Pro can now also be granted by an owner-authored NIP-32
kind-1985 label (org.skatespots.pro, p = user, expiry) — so Pro is not solely
dependent on the Lightning provider publishing zap receipts. New owner-only
GrantProCard (Settings -> Advanced) publishes it to getWriteRelays(); the hook
reads via querySkateContent.
Protocol: the x-sasquatch-pro payload is now {receipts,grants}; BOTH ai-proxy and
the Netlify function accept the new payload and a bare receipt (old APKs). Proxy
was deployed first and verified healthy before this commit.
- DNS: A record ai.skate-spots.org -> 185.18.221.2 (Netlify DNS).
- TLS: Caddy on the CVM host, automatic Let's Encrypt (verified live).
- Client: production builds (web + APK) now default to
https://ai.skate-spots.org/api/ai; dev uses local /api/ai. Override with
VITE_SASQUATCH_AI_BASE to revert to the Netlify Function, which stays
deployed as a fallback.
Same proxy as netlify/functions/ai.mjs, rebuilt as a standalone Bun service on
the CVM host so AI calls stop spending Netlify compute/bandwidth credits and
the sats-paid Routstr key lives on our box.
- ai-proxy/server.ts: Bun.serve on 127.0.0.1:8787 — NIP-98 + Pro gate, model
allowlist, per-IP throttle, /api/ai + /api/ai/status. Own tsconfig
(typecheck:ai in npm test); eslint-ignored like cvm-server.
- ai-proxy/{package.json,.env.example,skatespots-ai.service,Caddyfile,DEPLOY.md}.
- scripts/deploy-ai-proxy.sh (npm run deploy:ai): scp + bun install + unit +
restart, mirroring deploy-cvm.sh.
- Client: one place decides the AI base, src/lib/sasquatchAi.ts (also the NIP-98
u tag). Defaults unchanged (/api/ai on Netlify); set VITE_SASQUATCH_AI_BASE to
move it. Fully reversible.
Deployed and verified on the host: /api/ai/status returns the live Routstr
balance. Public TLS (DNS + Caddy) is the remaining step before switching the app
over, so no client behaviour changes yet.
Map tiles: new lib/tileCache.ts (Dexie SkateSpotsTilesDB, bounded to ~3000
tiles) and lib/cachedTileLayer.ts, a Leaflet TileLayer that reads through it
(cached -> fetch+cache -> plain URL fallback). Wired into both maps (SpotMap,
RouteDialog). Chosen over a service worker so it works in the Capacitor
WebView too, where a SW never sees tile requests.
Photos: useUploadFile drops from three Blossom hosts to two and records
['fallback', '<mirror>/<sha256>']. BlossomUploader signs once and Promise.any
PUTs to every server in parallel, so the old three-host list was triple-
uploading every photo (the 'tries them in order' comment was wrong). Read
side: lib/imageFallback.ts derives same-hash mirrors and ImageWithFallback
retries them before showing the placeholder - one choke point for ~20 photo
call sites, so a dead host no longer blanks a spot.
Relay outages no longer look like 'no spots'. querySkateContent swallowed every
relay error and returned [], so a dead relay would blank the map AND overwrite
good cached data with an empty list (then persist it). Now:
- querySkateContent/queryCanonical take an opt-in 'requireAny' that throws when
NO relay answered. Only the map's own feeds use it, so the blast radius is
small (79 other call sites keep today's behaviour).
- useSkateSpots/useSkateSpot fall back to the local IndexedDB cache on failure,
so the map still works at the park; they only error when nothing is saved.
- The persister refuses to persist an empty array, so an outage can't wipe the
last-known map on the next cold start.
- MapPage shows an honest 'Offline · showing saved spots' banner (reusing the
existing relayProbe/useRelayStatus) and a 'Can't reach the relays' card
instead of falsely claiming 'No spots mapped yet'.
Also seeds skater@skate-spots.org for the second admin (caba497f…) and reserves
the name so nobody else can claim it.
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.
(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.
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.
- NewsletterIssue gains an optional 'image' (per-issue cover); IssueArticle renders
it above the header and buildIssueArticle uses it as the NIP-23 'image' tag
(falling back to the spot-of-the-week image).
- NEWSLETTER_ISSUES now leads with #006 (2026-09-27): the Nostr-Twitter-Clone
rant, the Directions fixes (route never drew + identical per-mode numbers),
follow mode, choose-your-start + offline saved routes, Sasquatch Pro
(sats-paid, streamed, personal tools, one signature per session), the
Marketplace hub + 1-10 deck ratings, Mints (directory + the native JSON-error
fix), Amber scoping, the DM inbox-relay bug, and the polish list.
- Cover: https://skate-spots.org/images/drop-006-cover.png (hosted in
public/images, added in e075ee8).
Verified: the archive renders the cover + #006 copy, and the 'Drop #006 is out'
pill shows on other pages for 7 days. tsc/eslint clean, 101 tests, smoke PASS.
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).
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.
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.
- Nav: MIDDLE_SLOTS 3->4 and add 'mints' to the default middle + desktop tabs,
so the Cashu mints directory is reachable on the phone out of the box (also
still linked from Wallet; any of the 4 slots can be swapped via long-press).
- Directions: RouteDialog now seeds the origin from the last known location and
draws immediately, then refines with a live fix — no more hanging on 'Finding
your location…'. Shortened the web geolocation fallback 15s -> 8s.
- Amber: /api/ai/status is now public (it only reports a small balance), so
showing the AI balance no longer signs NIP-98 / opens the signer. The signer is
reserved for actions that actually need it.
- sasquatchBrain: PROXY_MODELS now match the Routstr node's slugs
(gemini-3-6-flash, z-ai-glm-5-3, gemini-3-7-flash).
- ai.mjs: new owner-only POST /api/ai/status (NIP-98 + owner pubkey) returning the
upstream balance + a 'low' flag (<500 sats); config.path now serves both routes.
- useSasquatchAiStatus + SasquatchAiStatusCard (owner-only, in Settings): shows the
balance and warns when low — the top-up nudge, no webhook/notification key needed.
- 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).
The pay-to address was duplicated (PRO_LIGHTNING_ADDRESS and
PLATFORM_LIGHTNING_ADDRESS) and hardcoded in the tour zap-split; a typo in any
copy would send money nowhere. Now one source of truth in src/lib/platform.ts,
re-exported by the fee/Pro modules.
Also surface the address in the Sasquatch Pro dialog ('Pays to ⚡ <address>') so
users can see where their sats go, in both the auto-pay and manual-QR paths.
- 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.
- 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).
- 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.
Add a purposeful subset of Amethyst-style relay categories (not a full clone):
- App-local 'Local relays' (never published; added to reads+writes)
- App-local 'Blocked relays' (excluded from every read/write)
- src/lib/relays.ts: resolveRelays()/useRelays() + URL normalisation
- NostrProvider req/event routers go through resolveRelays (blocklist applied)
- RelayListManager: Local/Blocked editors; NIP-65 save now preserves the new
lists instead of overwriting relayMetadata
Deliberately omitted (documented in AGENTS.md): KeyPackage (10051, no MLS
consumer) and proxy/broadcast/private-home (no such discovery model here).
Follow-up noted: a few hooks still read config.relayMetadata.relays directly.
Real, oracle-free escrow built on Cashu P2PK with a locktime + refund key:
buyer locks funds to the seller; seller redeems on delivery; the buyer can
reclaim after the deadline. Locked (not plain) tokens are what make the
cashu_token on a public 31872 safe.
- src/lib/cashuEscrow.ts: pubkey normalisation (x-only -> compressed), P2PK
policy builder, offline token inspection (amount/mint/locktime/refund), and
an injectable createEscrowToken mint flow (quote -> pay -> mintProofs{p2pk})
- 15 unit tests incl. the mint orchestration against a fake wallet
- Protocol: advertise a 'cashu_escrow' pubkey in kind 0 (NIP.md); token rides
the existing 31872 cashu_token tag
- ESCROW.md: design, threat model, status, limitations
- AGENTS.md: project map + docs index updated
Wallet UI (cashu-ts + payment rail + profile publish) is native-only and still
needs on-device verification.
Symptom: in the long-running dev server some lazy routes (/settings, /stash,
/collections, /stats) hit the route error boundary ('This page slammed') while
others worked. Console: 'Failed to fetch dynamically imported module'; network:
504 for node_modules/.vite/deps/*.js. Vite's dep-optimizer cache had gone
missing after the server re-optimized on a lockfile change, leaving stale ?v=
hashes.
- Add lazyWithRetry: on a chunk-load failure, retry once, then do a single
full reload per session (avoids reload loops); clears the flag on success
- Use it for all 53 lazy routes in AppRouter
- Document the dev dep-cache gotcha + fix in AGENTS.md
Data-driven pass (adds rollup-plugin-visualizer behind ANALYZE=1):
- MapPage was statically imported by the router, dragging the map route AND the
IndexedDB offline-cache layer into the entry -> now lazy()
- NewsletterOverlay imported NEWSLETTER_ISSUES from the whole NewsletterPage
module -> types + data moved to lib/newsletterIssues.ts
- SasquatchGuide (assistant overlay) lazy-loaded in Layout
- Cashu stack is now a separate chunk (useCashu), off the critical path
Net: entry down ~61%; first paint no longer parses the map, the newsletter
page, the assistant or the wallet stack.
- geolocation: cap the overall deadline at 15s (was 25s) and shorten the native
plugin attempt to 8s, so a failed fix no longer hangs ~25-40s
- MapPage: only warm the location cache when permission is ALREADY granted, so
opening /map no longer fires the browser's 'Allow location?' prompt
- MapPage: add a desktop Near Me control (it was mobile-only)
- MapPage: on native, a denial is now retryable instead of permanently opening
the browser-only help sheet
The bolt11 amount decoder is correct — the three failing tests used the
wrong invoice fixture. Per BOLT11, `lnbc2100n` = 2100 x 1e-9 BTC x 1e8
sats = 210 sats, not 21. A 21-sat invoice is `lnbc210n`. The passing
`lnbc1000n = 100 sats` case confirms the decoder's math.
Corrected the three cases in bolt11.test.ts and verifyInvoice.test.ts
to use `lnbc210n` (21 sats) so they exercise the intended 21-sat / 1-sat
slack scenarios against the correct implementation.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
- Replace single-assignment `let timer` with a `timers[]` array in
citrine.ts and relayProbe.ts so stricter prefer-const builds pass
(the newer ESLint pulled in by `npm audit fix` flagged the let as
never-reassigned).
- Drop two now-redundant `eslint-disable react-hooks/exhaustive-deps`
directives in NostrProvider and MapPage. Newer react-hooks no longer
reports those lines, so the directives were flagged as unused errors
under reportUnusedDisableDirectives; removing them is safe in both
old (warning only) and new (silent) ESLint.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Two type errors (surfaced by `npm run test` / tsc --noEmit on a full
toolchain; the in-browser bundler does not run tsc, so they were latent):
- useCVMClient.ts: remove a malformed cast
`spots as typeof spots & { tags; created_at }[]` feeding
deduplicateByIdentity(). The intersection dropped pubkey/kind and broke
the reassignment to `spots`. deduplicateAddressable() already returns
the correct shape, so the cast was both wrong and unnecessary.
- queryPersister.ts: import PersistedClient / Persister from
@tanstack/query-persist-client-core (their canonical source) instead of
@tanstack/react-query-persist-client, whose `export *` re-export tsc
won't resolve for named type imports. Added the core package (5.102.8,
matching the pinned wrapper) as an explicit dependency.
Both are pre-existing issues on the branch, unrelated to the audit
remediation commits.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Cover the three highest-value pure functions in the security/payments
path — previously untested:
- bolt11.getSatoshisFromBolt11: m/u/n/p multiplier decoding, amountless
invoices, 21-sat zap case, case-insensitivity, testnet/regtest
prefixes, and malformed-input throwing.
- verifyInvoice.assertInvoiceAmount: accepts matching amounts + 1-sat
rounding slack; rejects the core attack (over-charging invoice),
amountless-when-expected, malformed, and empty input.
- sanitizeUrl + companions: https passthrough, http→https upgrade,
javascript:/data:/file: rejection, relative-path rejection, plus
sanitizeCssString / urlDomain / sanitizeTel / sanitizeEmail.
Note: run `npm run test` on a full toolchain to execute these — the
in-browser builder bundles but does not run vitest.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Closes two items from the platform/completeness audit appendices.
Amber signature verification (audit "open verification item", LOW):
reconstructSignedEvent() previously trusted whatever `sig` the external
Amber signer app returned — it recomputed the event id but never
verified the Schnorr signature. A compromised or buggy signer could
therefore return a forged/garbage signature that the app would treat as
valid and publish. Now verifyEvent() is run on the reconstructed event
before it is returned; failures return null, which every caller already
handles as an "incomplete signature" error. This single choke point
covers all Amber paths: the native AmberIntent signer, AmberCallbackPage,
and AmberCallbackHandler.
F9 (LOW): pin MCP server versions in .mcp.json. Both js-dev-mcp and
nostrbook/mcp were fetched at @latest, silently pulling the newest
release into the agent tool environment every session. Pinned to the
current exact versions (0.6.3 / 0.5.3) so upgrades are deliberate.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Empty states (P4 follow-up):
- CrewsPage and EventsPage now render the shared <EmptyState> instead of
their own local copies, keeping spacing/actions/footer consistent.
Become-role routes (P1):
- Add src/lib/becomeRoles.tsx: single registry mapping each role slug to
either a CraftProfileForm config (artist/graffiti/musician/judge/tattoo/
trainer) or a lazy bespoke page (coach/filmer/editor/photographer/
builder/skater). The 6 craft roles were already thin config wrappers, so
their configs move into the registry and the wrapper files are deleted.
- Replace 12 hardcoded /become-* routes + 12 imports with one
/become/:role route (BecomeRolePage) that renders from the registry.
- Legacy /become-<slug> URLs now redirect to /become/<slug> so shared/
bookmarked links keep working.
- Update all internal links (LabPage, Index, SkatersPage, SpotDetailPage,
NIP19Page, SkaterDirectoryCard) to the canonical /become/<slug> paths.
Note: the 5 large bespoke forms were intentionally NOT merged into one
config — they carry distinct machinery (SpotPicker, wallet/Pro, geohashing)
and forcing them together would increase complexity, not reduce it.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Skaters need to know if a spot is roofed/indoor so weather info isn't
misleading — a covered or indoor spot is still skateable in the rain. Added a
4-way shelter level: outdoor (default) / partial / covered / indoor.
Data model (types.ts + skateTags.ts):
- SkateSpot.shelter parsed from a `['shelter', value]` tag (validated to known
values; absent/unknown → treated as outdoor).
- shelterOptions / DEFAULT_SHELTER / SHELTERED_VALUES + ShelterType exported.
- SHELTER_INFO display map (emoji/label/short/color) for badges.
Publishing (CreateSpotPage + EditSpotPage):
- New "Shelter" selector reusing the exact selectable-Card pattern used by
Access Type / Spot Type (cn() borders, neon ring, corner check badge).
- Emits `['shelter', value]` always, plus a queryable `['t', value]` for
sheltered spots (indoor/covered/partial) so "dry spots" filtering + Sasquatch
search work at the relay level. Outdoor adds no t tag.
- EditSpotPage initialises from the existing spot and preserves the value.
Display:
- Shelter badge on SpotDetailPage, SpotCard, and the MapPage popup — only shown
for sheltered spots (outdoor stays unbadged to avoid clutter).
Docs:
- NIP.md: shelter added to the optional-tags table, a dedicated Shelter section
explaining values + the queryable-t-tag behaviour, and an indoor example.
Build clean, no console errors.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Three low-risk speedups focused on the slowest path — questions that hit live
relay data:
1. Relay timeout 10s → 4.5s (6s for the fan-out community stats). The useful
relays answer in ~2s; the old 10s just let one slow/dead relay stall the
whole reply.
2. Results-first: for finder tools (spots/events/coaches/gear/crews/shops/
tours/stats/weather) format the CVM result locally and SKIP the second
"prettify" AI call. Removes a full model round-trip (~2–4s) — users want the
list, not extra prose. New toolCallToIntent() maps a tool call to the existing
formatLocalResults() path. Personal/Pro tools (get_my_*) still use AI synthesis.
3. Faster AI fallback: cap OpenRouter attempts to the top 3 free models before
the keyless backup, and shorten 429 backoff (≤800ms vs ≤2500ms). Avoids the
worst-case 15–20s grind through 8 models on a busy free tier.
Conversational/knowledge answers (single AI call) keep Sasquatch's personality
and are untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
Countdown badges (upcoming tours):
- New getCountdown() in TourCard: "In 3 days" / "Tomorrow" / "In 2 weeks" /
"Happening now" (pulsing) based on start/end dates, category-tinted
- Shown on both compact and full TourCard, and in the TourDetailPage schedule card
NIP-05 provider fix (removes obsolete nip05.social):
- New single source of truth src/lib/nip05Providers.ts with working, curated
providers: nostrcheck.me + nostr.directory (free), Nostr Plebs + nostr.band (paid)
- New reusable Nip05Required component (full-page "gate" + inline "banner"
variants) listing those providers and deep-linking to /settings?section=nip05
- Wired into CreateTourPage (gate), CreateListingPage (banner) and all four
Become* pages (Coach/Filmer/Photographer/Builder) for a consistent prompt
- SettingsPage NIP-05 screen now reads from the shared provider list
- Updated internal planning docs to match
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>