4862 lines
No EOL
327 KiB
Markdown
4862 lines
No EOL
327 KiB
Markdown
# 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
|
||
- `CreateEventPage` can link a **shop** as the venue too (not just a spot):
|
||
`['a','30017:<pk>:<d>']` + `['venue-name', <name>]` (2026-10-04).
|
||
- `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`).
|
||
- **Coach profile enrichment (2026-10-04):** the coach (kind `35001`) gains
|
||
portfolio/experience fields beyond the shared base — repeated named tags
|
||
(multi-letter, client-filtered like `experience`/`rate`): `format`
|
||
(one-on-one/group/camp/video-analysis/contest-prep/park-lesson/adaptive/
|
||
girls-session/after-school), `level`, `age`, `cert` (self-attested
|
||
first-aid/cpr/safeguarding/background-check/insured/federation/qualification/
|
||
judge + free-form `cert_note` — **never shown as verified**), `language`
|
||
(free-form), `availability`, `booking` (URL),
|
||
`photo` (repeatable gallery, ≤12) and `video` (YouTube/Instagram reel).
|
||
Option sets + i18n keys live in **`src/lib/coachOptions.ts`**; the form renders
|
||
a reusable `OptionChips` row; the gallery reuses **`PhotoDropzone`** +
|
||
**`ImageLightbox`**; the reel reuses **`parseCrewVideo`**
|
||
(`src/lib/videoEmbed.ts`). `parseSkateCoach` exposes `formats`/`levels`/
|
||
`ageGroups`/`credentials`/`languages`/`availability`/`booking`/`photos`/`video`
|
||
on `SkateCoach`. Documented in NIP.md → "Coach (kind 35001)".
|
||
**`/lab` coaching filters (2026-10-04):** the filter drawer gains a
|
||
per-coaching facet section (formats/levels/ages/credentials/availability/
|
||
languages) built from **`COACH_FACETS`** — only values actually present in the
|
||
loaded profiles are offered; selections show as removable chips in the
|
||
toolbar and count toward `activeFilterCount`. Client-side only (these tags are
|
||
not relay-indexed). Testimonials are already covered by the existing Lab
|
||
scene-vouch system (`VouchCard`, kind 1985) — no new kind needed.
|
||
**Structured rate + form polish (2026-10-04):** `rate_amount`/`rate_currency`
|
||
(sats/USD/EUR/GBP) × `rate_unit` (hour/session/half-day/day/camp) replace the
|
||
free-text-only rate; the legacy `rate` still renders and acts as an optional
|
||
note. The form is grouped with a `FormSection` divider, the identity block is
|
||
a neutral avatar card (it was a red error-looking box), and chips use
|
||
**`min-h-0`** — the global mobile `button { min-height:44px }` rule turned
|
||
every chip into a 44px slab otherwise.
|
||
**Payments (2026-10-04):** coaches **declare** accepted methods
|
||
(`payment_method`: cash/lightning/cashu/bank/other) + an optional
|
||
`payment_note`, a `pay_what_you_can` flag, and an optional `booking_fee`
|
||
(+`booking_fee_currency`). A booking fee is **paid directly to the coach's
|
||
kind-0 `lud16`** via the existing `PayWithSats` (NWC→WebLN→Cashu) — mounted
|
||
only while open — and is **not escrowed/refundable through us** (copy says so).
|
||
In-app payment is offered only for `sats` when the coach has a Lightning
|
||
address. Fiat/card and a refundable deposit are deliberately **out of scope**
|
||
(no custody/KYC; escrow stays native-only/unverified). No tag named `payment`
|
||
— that's the marketplace offer pointer.
|
||
- **Shared Lab enrichment (2026-10-04 — all crafts):** the coach fields above
|
||
were generalized so every Lab kind (35001–35014) gets the same
|
||
craft-agnostic treatment without copy-paste. **`src/lib/labExtras.ts`**
|
||
(`parseLabExtras` + option sets), **`components/LabExtrasFields.tsx`** (the
|
||
form block) and **`components/LabExtrasSection.tsx`** (the detail block).
|
||
Covered: languages, availability + booking link, structured rate,
|
||
payment methods/note/PWYC/booking fee, portfolio gallery + reel.
|
||
`CraftProfileForm` (the 8 data-driven crafts) and the 4 bespoke pages
|
||
(filmer/editor/photographer/builder) render `LabExtrasFields` and append its
|
||
managed tags on save; the shared `CoachDetailPage` renders
|
||
`LabExtrasSection` for every craft (coach-specific formats/levels/ages/
|
||
credentials stay in the coach block). Per-craft *context* stays in
|
||
`skillSections` + `extraFields`, and per-craft `rateUnits` (billing units) are
|
||
set in `becomeRoles.tsx`. `/lab` filters read `parseLabExtras` for all crafts.
|
||
⚠️ A new craft-specific freeform `extraField` is **invisible** unless the
|
||
detail page has a matching section — declare it in **`src/lib/craftFields.ts`**
|
||
(`CRAFT_DETAIL_FIELDS`) as well as the form. A small per-craft set is in place
|
||
(deliverables/turnaround, deposit/minimum, travel, lead_time, …); toggle tags
|
||
store `"true"` and render as a localized "Yes".
|
||
- **Lab offer/publish UX (2026-10-04):** the create→post loop got its missing
|
||
pieces. **`components/LabProfilePreview.tsx`** is a live "how it will look"
|
||
preview fed a draft from the form (title/bio/image/location/experience/rate/
|
||
skills/contact + the shared enrichment tags); wired into `CraftProfileForm`
|
||
and all 4 bespoke pages + coach via a **Preview** button next to Submit.
|
||
`parseLabExtras` now accepts any `{ tags }` (not just a full event) so drafts
|
||
preview. On a **first** publish the form redirects with `?published=1`, and
|
||
the profile shows a one-time "You're live — share it" banner. The owner
|
||
"Manage" card has an **Edit profile** link (`/become/<craft>`), every profile
|
||
has a **Share** button (`ShareProfileDialog` with a `path` override), and
|
||
`/lab` shows a **"Your Lab profiles"** strip. `main.tsx`'s clean-link shim now
|
||
also rewrites `/coach/<pubkey>/<d>`. Still open: draft/autosave and a
|
||
multi-step layout.
|
||
- **Lab offer form polish (2026-10-04, part 2):** four more gaps closed.
|
||
**Drafts:** `hooks/useLabDraft` autosaves the form snapshot to localStorage
|
||
(content-debounced) and only offers it as resumable when it *differs* from the
|
||
current state (so opening an existing profile never shows a spurious banner
|
||
and a draft can't clobber a fresh load); `LabDraftBanner` gives Resume /
|
||
Discard; clears on publish. **Progress:** `LabProfileProgress` is a
|
||
completeness bar (photo/bio/skills/rate/location/contact) — a progress
|
||
indicator, not a wizard. **Validation:** `CraftProfileForm` + the coach page
|
||
split contact validation into per-field inline errors (+ red border) and
|
||
**scroll to the first invalid field / the skills grid** on submit; required
|
||
skill sections show an inline message after a submit attempt. **URLs:** Lab
|
||
profiles now live at **`/lab/<craft>/<pubkey>/<id>`** (`lib/labRoutes.ts`
|
||
builds it; `/coach/...` still resolves). The bespoke pages have inline contact
|
||
errors but not the scroll-to behavior (yet).
|
||
- **Lab offer wizard (2026-10-04, part 3):** the long offer forms are now
|
||
multi-step. **`components/FormWizard.tsx`** (`FormWizard` + `WizardStep`) is a
|
||
controlled step shell — non-active steps are hidden with `display:none`, not
|
||
unmounted, so stateful children (`LabExtrasFields`) keep their values; the
|
||
form owns the step so it can jump to the offending step on a validation
|
||
failure. Applied to `CraftProfileForm` (8 crafts, 5 steps: Identity / What
|
||
you offer / Rates / Contact / Media) and the coach page (5 steps); the
|
||
footer renders Back/Next/Submit + a Preview button. The **4 bespoke pages
|
||
keep the one-scroll + progress bar** (their sections have no reliable
|
||
boundaries to split) and now **scroll to their contact error** on a failed
|
||
submit. A bespoke-page wizard is the remaining optional item.
|
||
- **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
|
||
- **Crew handles (2026-10-03):** a crew (kind `35006`) may carry **up to 5
|
||
`nip05` tags**, each verified against the **founder** key (`SkateCrew.nip05s`;
|
||
`nip05` = first, back-compat). `CrewForm` adds/removes handles with a live
|
||
verified/mismatch status; `CrewNip05Badge` renders them all. Unlike a kind-0
|
||
profile (a single `nip05` string), a crew is our own tag convention, so it
|
||
isn't limited to one. Normalise → dedupe → regex-validate → cap 5 in
|
||
`parseSkateCrew`.
|
||
- **Shop handles (2026-10-03):** same idea on shops (kind `30017`) —
|
||
repeatable `nip05` tags (up to 5) verified against the **owner** key
|
||
(`SkateShop.nip05s`), added/removed in `CreateShopPage`/`EditShopPage`
|
||
(`'nip05'` is in `EditShopPage`'s `MANAGED_TAGS` so republish doesn't
|
||
duplicate them); shown via `Nip05Badges` on the shop page.
|
||
- **Profile extra handles (2026-10-03):** a profile can advertise **up to 5**
|
||
extra personal NIP-05 handles, since kind-0 has only one standard `nip05`
|
||
field and other clients may drop non-standard keys. The extras live in a
|
||
**separate NIP-78 record** (`kind 30078`, `d=skatespots:profile-handles`,
|
||
authored by the user; see `lib/profileHandles.ts` + `hooks/useProfileHandles`).
|
||
Primary stays in kind-0 `nip05`; `EditProfileForm` edits extras;
|
||
`NIP19Page` shows all via `Nip05Badges`. Shared editor:
|
||
`components/Nip05HandlesEditor.tsx` (used by profile/crew/shop).
|
||
- **Shared NIP-05 UI (2026-10-03):** `components/Nip05Badges.tsx` is the
|
||
generic verify-and-render chip row (any owner pubkey); `useCrewNip05` is the
|
||
generic verifier (name kept for back-compat).
|
||
- **Rider Cards (2026-10-02) — now REAL + verifiable.** `32271` **Rider Card**
|
||
(addressable, `d=rider-card`) — the skater signs their own card customization
|
||
(theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags
|
||
explicit). `35679` Park Access Card (venue-bound via an optional
|
||
`["a","30818:<parkPubkey>:<d>"]` tag). `1293` witnessed skill badge. Routes:
|
||
`/card` (editor) · **`/#/card/<npub>`** (public, read-only) ·
|
||
**`/#/verify/<naddr>`** (signature check + venue binding). Helpers in
|
||
`src/lib/riderCard.ts` (`parseRiderCard`/`riderCardTags`/`cardVerifyUrl`),
|
||
hooks `useRiderCard`/`useRiderBadges`, visuals in `src/components/RiderCard.tsx`
|
||
(ONE React renderer; the canvas export in `RiderCardPage` is the print one).
|
||
⚠️ **A valid signature proves the SIGNER — not skill, and not access rights**
|
||
beyond what the venue issued; the card says so. A pass with no `a` tag verifies
|
||
as a signature but is shown **not venue-bound** (any key can mint one). The QR
|
||
now encodes the verify URL (was a profile path that HashRouter ignored).
|
||
**Identity facets (2026-10-02):** the card also shows **crew crests**, a **local**
|
||
badge (skater profile `location`/`home_spot`), and **Lab role** chips — all
|
||
**derived LIVE** (kinds 35006 / 35000 / 35001–35014), never claimed on the card,
|
||
toggled by `show` flags (`v:2`; old cards default them ON). Rendered in the React
|
||
card AND the canvas export (roles as chips, local/crews as lines).
|
||
**Crew Card (2026-10-02):** the `/card` editor has a third type — **Crew** —
|
||
which renders a selected crew as a card. **No new event:** it is derived from the
|
||
crew's own signed kind `35006`, so its QR verifies that event (the founder's key
|
||
is the signature). `/verify/:naddr` renders kind `35006` too. Card fields:
|
||
name/logo/home turf/founded/members/spots/vibes + **Open call** when
|
||
`lookingFor`.
|
||
**Event pass/wristband (2026-10-02):** a fourth `/card` type — **Event** —
|
||
renders an event (`kind 31923`) you organise **or RSVP'd going/interested** to
|
||
(`useMyRsvpCoords`, kind 31925). Also derived (the organiser's key signs it),
|
||
so the QR verifies that event; `/verify/:naddr` renders kind `31923` too.
|
||
Shows title/date/venue/cover + **who it admits** + going count.
|
||
**Claims tab (2026-10-02):** `/review` has a **Claims** tab (pending
|
||
private-spot ownership claims; was a banner above the tabs).
|
||
**Card Studio (2026-10-02):** `/card`'s "My Card" tab is now a two-pane studio
|
||
— **sticky live preview** (lg) + inspector; actions collapsed to **Share** +
|
||
a `⋯` menu (PNG/print/copy); a **dirty/saved** state with a manual **Save
|
||
changes** (no autosave — an interactive signer must not prompt per keystroke);
|
||
card **type = compact chips**; the two toggle grids merged into **one "Show on
|
||
card" list** with live values. **Self-claimed skills removed** (were marginal/
|
||
unverifiable) — the card shows **witnessed badges** (kind 1293) only, and
|
||
**style/level are DERIVED** from the Skater profile (kind 35000). The canvas
|
||
export + `CardData` dropped the self-claimed skills row and gained `level`.
|
||
**Customization depth (2026-10-02):** card config gained `accent` (custom hex
|
||
overriding the theme via `--ss-accent` + canvas palette), `logo` (personal
|
||
crest, uploaded), and `back` (printable reverse side — React `RiderCardBack`
|
||
+ canvas `drawCardBack`; Print renders both sides). Park passes **enforce
|
||
`valid-thru`** (`passExpiry()` → EXPIRED badge on card + verify; the input is a
|
||
date picker). Card schema → `v:3`. Public `/#/card/:npub` also flips + shows
|
||
the custom accent/logo.
|
||
**Hero redesign (2026-10-02):** the card is now a **full-bleed photo/cover**
|
||
with a bottom scrim, the name + identity overlaid bottom-left and the **QR
|
||
floating bottom-right** — **no vertical rules / columns** (the old portrait
|
||
panel + dashed stub read as two ugly lines). No photo → a **monogram
|
||
watermark**. Card pages resolve the profile via **`useCardProfile`** (tolerant
|
||
JSON over `querySkateContent`, so it can't drop a real profile the way
|
||
`useAuthor` could); `DEFAULT_RIDER_CARD.style` is now `''` (no fake "Street").
|
||
**Issuance logic (2026-10-02):** card types are **gated by what you can
|
||
actually issue/derive** — Park only if you own a venue (a spot you authored),
|
||
Crew only if you're in a crew, Event only if you organised/RSVP'd. A Park pass
|
||
**requires** a venue binding (`a` tag) and is only issuable by that venue's
|
||
owner key; `/verify` shows the **holder** + venue match. The studio shows a
|
||
**"Finish your card"** checklist (photo/name/styles/crew).
|
||
**Event tickets (2026-10-02):** an attendee's card is labelled **MY TICKET**,
|
||
an organiser's **EVENT · HOST** (never a self-made "issued" pass); a ticket's
|
||
QR carries **`?h=<holder-npub>`** so `/verify` checks the holder's **RSVP**
|
||
(kind 31925) and shows Ticket holder + RSVP status.
|
||
**Ownership model + badges (2026-10-02):** owner = the event's **author key**
|
||
(spot 30818 / shop 30017 / event 31923 / crew 35006); crew **delegated admins**
|
||
= `admin`-marker `p` tags (`useCrewRole(crew).isElevated`); platform moderators
|
||
= hardcoded `ADMIN_PUBKEYS`. `ControllerBadge` (`components/ControllerBadge.tsx`)
|
||
surfaces the viewer's role — spot=Owner, shop=**Verified owner** (NIP-05 domain
|
||
match), crew=Owner/Admin, event=Organiser — on detail pages **and** on the card
|
||
studio's type chips (`role.*` i18n).
|
||
**NFC (2026-10-02):** `src/lib/nfc.ts` writes the card/pass **verify URL** —
|
||
**Web NFC** (`NDEFReader`) on Chrome/Android, or the **local Android plugin**
|
||
`packages/skatespots-nfc` (`SkateSpotsNfc`, registered **by name** via
|
||
`@capacitor/core`) in the APK, because Web NFC isn't exposed in the Capacitor
|
||
WebView. `NfcWriteButton` renders **nothing** where unsupported (iOS/desktop),
|
||
wired to the issuer lists + the holder's "My passes". ⚠️ **UNVERIFIED** (needs
|
||
`cap sync` + a device); iOS can't write tags. A tag carries only the pointer —
|
||
trust is still the relay signature.
|
||
✅ **It compiles:** `npm run android:build` wires `skatespots-nfc` in and merges
|
||
the NFC permission (the plugin needs `androidx.appcompat` on its classpath —
|
||
Capacitor's `activity` is an `AppCompatActivity`). Tag-writing still needs a
|
||
**device test** (the plugin is otherwise unverified).
|
||
**Audit fixes (2026-10-02):** removed the now-dead **stats** (the redesign
|
||
dropped the stats row, so the `show` flags are only crews/local/roles and
|
||
`spotsAdded/zapsReceived/eventsCreated/memberYear` are gone from the card
|
||
types); **holographic is persisted** (`holo` tag); the **public card derives
|
||
style/level** from the Skater profile (was `config.style` = ''); the **share
|
||
link** is `/#/card/<npub>` (was a path HashRouter ignored); **issued passes
|
||
dedupe by `d`** (a revoke no longer shows two rows); PNG download is labelled
|
||
"Save front (PNG)" when a back exists. **Cosmetics (2026-10-02):** the preview
|
||
is **zoomable**; the page title is translated; legacy `style`/`skills` are gone
|
||
from the config/CardData; card links are **clean** (`/verify/<naddr>`,
|
||
`/card/<npub>`) via a **`main.tsx` startup shim** that rewrites those paths to
|
||
the hash (old `/#/…` links still work).
|
||
**Venue-page pass management (2026-10-02):** `VenueAccessCard`
|
||
(`components/VenueAccessCard.tsx`, owner-only) is rendered on the **shop**
|
||
page and on **gated spots** (`accessType` private/locals) — it shows the
|
||
venue's active/revoked pass counts and links into the studio with the venue
|
||
preselected (`/card?venue=<kind>:<pubkey>:<d>`; `RiderCardPage` reads the
|
||
param and opens the park editor on that venue). Renders nothing for
|
||
non-owners, so no relay query fires.
|
||
- **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`) are a **counter-offer negotiation thread
|
||
(2026-09-29):** every message is its own event with a **unique `d`** (append-only)
|
||
carrying `role` (`offer` = buyer / `response` = seller), `e` = the message it
|
||
answers, and `status`; either side can **accept / decline / withdraw / counter**
|
||
and `ListingOffersPanel` shows the volley + whose turn it is. Legacy fixed-slot
|
||
`d`s are still parsed (role inferred when the `role` tag is absent) — see NIP.md
|
||
→ "Marketplace Offers". Trade/offer steps also feed **`useTradeNotifications`**
|
||
→ the bell's **All** tab (`TradeNotificationCard`, counted in the badge against
|
||
`seenAt`) and OS notifications (new **`trades`** category in `notificationPrefs`,
|
||
wired in `OsNotificationBridge`). The bell also has a dedicated **Trades tab**
|
||
(5th tab, `Handshake`) listing them, with its own count. Types cover **every
|
||
step**: `offer`, `counter`, `accepted`, `declined`, `withdrawn`,
|
||
`trade-request` (they asked you to confirm) **and `trade-confirm`** (they
|
||
*confirmed* — `usePendingTradeConfirmations`, now carrying `createdAt`/`eventId`
|
||
so it can be notified; confirming back is what makes the trade mutual and builds
|
||
reputation). **Barter is supported
|
||
by design**: trade attestations carry `method` (`cash|lightning|trade|other` —
|
||
`MarkSoldDialog` picks it), so a swap feeds reputation exactly like a sale.
|
||
**Barter is first-class on the offer side too (2026-09-29):** an offer/counter
|
||
may carry an `item` tag (`["item","my 8.25 deck"]`) instead of or alongside
|
||
`amount` — `OfferDialog` and the counter UI expose it, and the bell/OS text
|
||
reads it ("offered 2,000 SATS + an 8.25 deck"). Listings already support
|
||
`price_type=trade` ("Looking to swap"); `LISTING_TYPES` itself is still
|
||
`selling|wtb`.
|
||
- **In-app payment on an accepted offer (2026-10-01):** the seller may attach a
|
||
**`["payment", "<value>", "<kind>"]`** pointer to the **`accepted`** `30403`
|
||
message (`kind` = `lninvoice|lnaddress|creq|clink`; optional
|
||
`["payment_hash","<hex>"]`). `ListingOffersPanel.respond('accepted')` attaches
|
||
the seller's own profile `lud16` when present; the pointer lives on the
|
||
accepted event so it rides the **same decrypt-free notification path** as the
|
||
offer (a NIP-17 DM was rejected — it can't reach the Trades tab without
|
||
decrypting, which `OsNotificationBridge` forbids). Buyer sees a **Pay with
|
||
sats** button → `PayWithSats` dialog → `usePayPaymentPointer` pays via
|
||
**NWC → WebLN → Cashu melt** (mirrors `useZaps`), falling back to the seller's
|
||
`lud16` if the accept carried no pointer. **Nothing is held or signed by us** —
|
||
recording the trade stays the existing `31871` flow (`proof` tag documented in
|
||
NIP.md). `creq`/`clink` are recognized but **not payable yet**. This is the
|
||
**Numo/BTCPay-derived** "pay the seller in-app" rail; the request format is
|
||
ours (offer tags), not a new kind.
|
||
- **Buy with sats — fixed price, no negotiation (2026-10-01):** a
|
||
`price_type=fixed` listing shows a direct **Buy with sats** button in
|
||
`ListingTradeActions` (shared by card + detail). The price is resolved to
|
||
sats — **SATS** directly, **USD** via `useUsdToSats` (live rate); other
|
||
currencies fall back to the offer/contact flow — and paid through the same
|
||
`PayWithSats` sheet (NWC → WebLN → Cashu melt, falling back to the seller's
|
||
`lud16`). ⚠️ The sheet is **mounted only while open** so its wallet hooks
|
||
don't run on every grid card.
|
||
- **Shipment / fulfilment (2026-10-01):** listings carry an optional
|
||
`["fulfilment","pickup"|"ships"|"both"]` (selling only) — a create-page
|
||
selector (default **pickup**), a card badge, and a **Pickup/Ships** filter on
|
||
`GearSwapPage`. ⚠️ **The app never collects an address**: a `ships` deal
|
||
exchanges it privately in the trade chat (NIP-17), and `ListingTradeActions`
|
||
nudges **escrow** for shipped deals. No KYC rail, no carrier API, no labels.
|
||
- **Shipping flow + distance + guided handoff (2026-10-01):**
|
||
`["shipping","buyer"|"seller"|"split"]` (who pays postage; selector on
|
||
`CreateListingPage` when `fulfilment != pickup`). `GearSwapItemCard` /
|
||
`MarketplaceItemCard` show **distance from the viewer's last known location**
|
||
(cache only via `getLastLocation` — **never triggers a GPS prompt**) and
|
||
`GearSwapPage` gains a **Nearest (local)** sort (`itemDistanceKm` in
|
||
`lib/sortOptions.ts`). **Guided handoff** — `HandoffHelper` (rendered in
|
||
`ListingTradeActions`, buyer-only) opens the trade **DM pre-filled**
|
||
(`/messages/<npub>?message=…`): "Suggest a time & place", "Share delivery
|
||
details", "Use a locker / parcel point", plus anti-scam copy (prefer escrow,
|
||
never pay outside the rails, meet in public). Details stay in the NIP-17 DM —
|
||
the app never sees them. `ships` deals now get a safety nudge on **all**
|
||
platforms, not only when escrow is available.
|
||
- **Meet-at-a-spot + shipment status (2026-10-01):** `HandoffHelper` also
|
||
suggests the **3 nearest public skate spots** to the listing (public data
|
||
only — **never the viewer's live location**) as one-tap DM meetup templates.
|
||
New **addressable kind 34967** (`useShipmentStatus` + `ShippingStatusCard`):
|
||
seller marks **shipped**, buyer **received**, either **issue**. `p`-tagged to
|
||
the counterparty so the push service notifies (`MARKET_KINDS` in
|
||
`push-service`), and `d = ship:<listing-d>:<author>` so a status replaces only
|
||
its author's. ⚠️ It's a **claim, not proof** — `received` does **NOT** release
|
||
escrow, no address is stored, and parcel photos showing an address are
|
||
discouraged.
|
||
- **Low-diversity ring warning (2026-10-01):** beyond the single-partner
|
||
warning, `TradeReputationCard` flags `verified >= 5 && verifiedCounterparties
|
||
<= 2` — "most confirmations come from very few accounts".
|
||
- **Anti-sybil depth (2026-10-01):** `src/hooks/useTradeIntegrity.ts` adds two
|
||
levers to `TradeReputationCard`. **Activity weighting** — `useBatchActivity`
|
||
counts each counterparty's published spots+listings and multiplies their
|
||
contribution by `min(1, activity/3)`, so a ring of empty accounts contributes
|
||
~0 even when cross-confirmed. **Ring analysis** — `useTradeRings` does a
|
||
**2-hop** query of the partners' own attestations and measures **closedness**
|
||
(share of possible partner↔partner trades); `≥3` partners with `≥0.5`
|
||
closedness flags a closed cluster.
|
||
- **NIP-13 proof of work (2026-10-01):** `src/lib/pow.ts` (`minePow` /
|
||
`verifyPow` / `countLeadingZeroBits`). ⚠️ **PoW pays no one** — it is a free
|
||
**CPU** tax, not a fee; don't describe it as a payment. `useNostrPublish`
|
||
accepts `pow?: number` and **mines the nonce on the UNSIGNED event before
|
||
signing** (the id is signature-independent, so the signer is still prompted
|
||
once). `CreateListingPage` sends **16 bits** on every listing (~80 ms) and
|
||
**20 bits** when a **newcomer picks work instead of the 1,000-sat listing
|
||
fee** (`SpamFeePayment.onPowInstead` → the free alternative). `parseMarketplaceItem`
|
||
exposes `powDifficulty` and `MarketplaceItemCard` shows a **⚡ PoW badge** at
|
||
≥16. It raises cost, it doesn't prove humanity; keep targets low (mining runs
|
||
on the main thread with periodic yields).
|
||
- **Payment receipts — money vs goods (2026-10-01):** `src/lib/receipts.ts` +
|
||
`getPaymentHashFromBolt11` (in `lib/bolt11.ts`, **verified against the BOLT11
|
||
spec test vector**). `usePayPaymentPointer` now captures the **preimage** from
|
||
NWC/WebLN/melt and returns a `PaymentReceipt`; `PayWithSats.onPaid(receipt)`
|
||
carries it, held device-locally (`saveReceipt`/`getReceipt`/`clearReceipt`) so
|
||
the later CONFIRM step can attach it — `publishTrade({ paymentProof })` writes
|
||
`["proof","lnpreimage",preimage,payment_hash]` (and `depositProof` →
|
||
`["proof","deposit",…]`). `useTradeReputation` **verifies** it
|
||
(`sha256(preimage) === payment_hash`) and counts it in `paymentVerified`.
|
||
⚠️ A receipt proves the **PAYMENT, not delivery** — and applies to **money
|
||
legs only**: a pure hardware swap has no invoice → no preimage → it stays
|
||
*attested*; the `deposit` proof is the lever that gives barter a real cost.
|
||
**Never** promote a barter or a plain `lninvoice` claim to verified. The
|
||
**barter deposit** is now a UI affordance: `ListingTradeActions` shows a
|
||
"Good-faith deposit" input for `price_type=trade` listings; paying it stores a
|
||
**deposit-slot** receipt (`saveReceipt(coord, r, 'deposit')`) that the buyer's
|
||
confirm attaches as `depositProof` (`method=trade`).
|
||
- **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`).
|
||
- **QR scanner: BIP21 + Cashu payment requests (2026-10-01).** `classifyQRCode`
|
||
(`src/hooks/useQRCodeScanner.ts`) now parses `bitcoin:` **BIP21** — extracting
|
||
`lightning=` (→ `lightning_invoice`, payable today via NWC/Cashu melt) and
|
||
`creq=` — plus standalone NUT-18 **`creq1…`/`creqA…`** as a new `cashu_request`
|
||
type. This is the **Numo POS** interop: a Numo terminal's default "Unified" QR
|
||
is `bitcoin:?creq=…&lightning=…`, so it pays through the **existing** Lightning
|
||
path with no `creq` implementation. `cashu_request` is **recognized but NOT
|
||
payable yet** — `CashuWalletCard` shows an honest toast; paying NUT-18 is a
|
||
later step. ⚠️ A `creq` is **amount-bound/expiring**, so a **Lightning address
|
||
(`lud16`) is the robust default** for any future listing payment pointer
|
||
(BIP21 parsing deliberately avoids `URLSearchParams` so a raw `creqA…` base64
|
||
`+` isn't coerced to a space).
|
||
- **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.
|
||
- **Amber "flashing" on navigation (fixed 2026-10-12).** For an interactive
|
||
signer every `nip44.decrypt` opens Amber, and the read hooks decrypt **2 per
|
||
gift wrap** (seal + rumor) — up to ~400 calls per query, and
|
||
`DirectMessagesPage` mounts **two** such hooks at once
|
||
(`useConversations` + `useGroupConversations`). `amberSafeQueryOptions`
|
||
(`useDirectMessages.ts`) only disabled polling/focus-refetch and set a **5-min**
|
||
staleTime, so navigating back re-decrypted. It now also sets, for interactive
|
||
signers: `staleTime: Infinity`, `gcTime: Infinity`, `refetchOnMount: false`,
|
||
`refetchOnReconnect: false` — decrypt **once per session**, mirroring
|
||
`useStash`/`useSyncedAppData`. Consequence: Amber users don't get live DM
|
||
updates, so **`DirectMessagesPage` gained a manual Refresh button**
|
||
(`socialUi.dms.refresh`; en/es). Same guard added to `usePrivateContacts` and
|
||
`useCordnChat`. Also `useSyncPendingPosts` (`useOfflineCache.ts`) signed queued
|
||
**legacy/unsigned** posts in a 30 s loop with **no interactive gate** — it now
|
||
skips unsigned posts for interactive signers (pre-signed ones still publish).
|
||
⚠️ If you add a new read hook that decrypts, gate it the same way or Amber
|
||
will storm on every navigation. ⚠️ **Never fire concurrent signer intents with
|
||
an interactive signer (fixed 2026-10-13).** `useSendDirectMessage` created the
|
||
per-recipient gift wraps with `Promise.all`, and `sealGroupMessage`
|
||
(`lib/cordn/crypto.ts`) encrypted to the whole roster with `Promise.all`. Amber
|
||
is a single foreground activity: several `nostrsigner:` intents at once means
|
||
only one is answered, and the others sit until the **60 s** `TIMEOUT_MS` in
|
||
`android-templates/AmberIntentPlugin.java` rejects with the misleading *"Amber
|
||
didn't respond. Make sure Amber is installed"*. Both now send **sequentially
|
||
for an interactive signer** (parallel kept for silent ones); the NIP-17 crew
|
||
Chat therefore costs **2 approvals per member** (encrypt + sign). Steering
|
||
Amber users to the **MLS Private chat** (≈1 approval per action) is the real
|
||
cure.
|
||
**Crew-chat audit + fixes (2026-10-13, see `CREW_CHAT_AUDIT.md`):** ① a sent
|
||
crew message **didn't appear** — `useSendDirectMessage` never invalidated
|
||
`['group-messages', gid, …]` (the crew thread key); it now **optimistically
|
||
inserts** the sender's own plaintext (no refetch ⇒ no Amber pops). ② **All four
|
||
DM readers shared no work** — each decrypted every wrap (2 ops) under its own
|
||
query key, so every view re-decrypted the whole inbox; now a module-level
|
||
**`decryptGiftWrap` cache** (keyed `viewerPk:wrapId`, coalesced, bounded 4000,
|
||
**successes only** so a transient failure retries) is used by
|
||
`useConversations`/`useConversation`/`useGroupConversations`/`useGroupConversation`.
|
||
③ threads dedup by rumor id; ④ `CrewChat` guards double-send with a ref;
|
||
⑤ the Cordn path optimistically inserts too and invalidates only for silent
|
||
signers. **Standardised the Chat tab on NIP-17 (2026-10-13):** a crew with a
|
||
`coordinator` tag no longer silently reroutes the Chat tab — coordinator
|
||
routing lives **only** in the Private chat (MLS). `CrewChat` no longer takes a
|
||
`crew` prop or the Cordn branch; the tab's unread dot (Cordn-only metadata peek)
|
||
was dropped. `useCordnChat.ts`, `useCrewChatUnread.ts` and
|
||
`lib/cordn/{client,crypto}.ts` are **kept but unwired** (marked in their
|
||
headers) — re-wiring is a deliberate decision, not a one-liner, because it
|
||
split chat history. Group reads widened to **500** wraps (1:1 stays 200).
|
||
**Amber audit (2026-10-13): result COMPRESSION.** The web/PWA path always sent
|
||
`compressionType=none`; the **native** intents sent neither `compressionType`
|
||
nor `returnType`, and the JS gzip decoder was **never called**. NIP-55 lets
|
||
Amber gzip a large result (`Signer1…` base64), so a long `nip44_decrypt` (a
|
||
long DM) could arrive compressed and be parsed as garbage — a "sometimes"
|
||
failure. Fixed both ends: `AmberIntentPlugin` now sends
|
||
`compressionType=none` on every intent (template → `android/` at build), and
|
||
`capacitorAmberSigner` gunzips any `Signer1…` result anyway. Also: the timeout
|
||
message no longer wrongly blames "Amber is not installed". **Verified sound:**
|
||
MainActivity registers the `ActivityResultLauncher` before `super.onCreate`
|
||
and routes results to `AmberIntentPlugin.handleResult` (id-matched,
|
||
single-pending fallback); `reconstructSignedEvent` verifies the signature;
|
||
rejections normalize; `isInteractiveSigner` gates background decrypts; the only
|
||
multi-signer loops are sequential now. ⚠️ **Web/PWA Amber is inherently
|
||
degraded**: sign_event relaunches via a page reload and encrypt/decrypt results
|
||
can't resume the original promise after the redirect, so **web Amber crypto
|
||
doesn't complete** (use an extension/bunker on web). Native is unaffected.
|
||
**Persisted DM decrypt cache (2026-10-13):** `src/lib/dmDecryptCache.ts` (Dexie
|
||
`SkateSpotsDmCacheDB`, keyed `viewerPk:wrapId`, bounded 4000/account, pruned
|
||
oldest-first on hydrate) is used by `decryptGiftWrap` — so a message is
|
||
decrypted **once, ever**, killing the first-load-per-launch Amber storm.
|
||
⚠️ It stores **plaintext DMs at rest** in IndexedDB (device-local, never
|
||
published) — the trade-off for not re-prompting; it is cleared on **logout**
|
||
(`AccountSwitcher`) so another account on the device can't read it.
|
||
`clearDmDecryptCache()` is exported for a future "clear cache" action.
|
||
- **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 <node>/lightning/invoice {amount_sats,model}` → pay bolt11 →
|
||
`GET <node>/lightning/invoice/<id>/status` → `api_key` (`sk-…`). Refund:
|
||
`POST <node>/v1/balance/refund` → a Cashu token (recoverable/portable).
|
||
Then set `SASQUATCH_AI_BASE=<node>/v1`, `SASQUATCH_AI_KEY=sk-…`,
|
||
`SASQUATCH_MODELS=<slugs>` — **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 & Support hub (2026-10-04):** all paid extras now have one home —
|
||
**Settings → Pro & Support** (`?section=pro`, `components/ProSupportScreen.tsx`,
|
||
menu row + section in `SettingsPage`). It shows the honest "prepaid, not a
|
||
subscription" framing, **Sasquatch Pro** status (days left) + `PRO_TIERS` with
|
||
per-day sats + a "Go Pro / Extend" button (opens `SasquatchProDialog`), the
|
||
**Featured placement** types (`PLATFORM_FEES` × `DURATION_TIERS`, per-day +
|
||
best-value), **your currently-featured Lab listings** (`featuredUntil`), and
|
||
the **free paths** (PoW listing, free AI FAQ). Distinct naming is enforced in
|
||
copy: **Sasquatch Pro** (AI) vs **Featured placement** (visibility) — don't
|
||
call both "Pro". Not done: an actual discounted bundle SKU (would be a single
|
||
combined zap, no new rails). `/settings?section=pro` is in the smoke list.
|
||
- **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.
|
||
- **Pro UX pass (same date):** `usePersonalContext` now decrypts the Stash
|
||
**on demand** — `unlockStash()` is called by `getMyStash` when the AI
|
||
actually reads it (`stashRef` avoids the stale-closure trap) — so "what's in
|
||
my stash?" is correct, while the globally-mounted guide still never decrypts
|
||
on mount. New self-contained action draft **`wtb_gear`** ("wanted" ads,
|
||
NIP-99 `listing_type=wtb`). The panel header shows **`⚡ Pro · Nd`** days
|
||
left; the tier picker shows **live USD** via `useExchangeRate` (the
|
||
hardcoded ~$60k/BTC `usd` field was removed from `PRO_TIERS`). Copy now says
|
||
*skate status* (not *spot status*) and states trip planning is free.
|
||
`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).
|
||
**Form-page visibility (2026-10-29):** the assistant is deliberately hidden on
|
||
`/become/*`, `/create*`, `/edit*`, `/supporters` and `/onboarding` — the
|
||
floating bubble covered form inputs/chips (especially on mobile). It remains
|
||
available everywhere else.
|
||
**Bubble visibility (2026-10-04):** the floating Sasquatch FAB no longer
|
||
pulses forever — the `animate-ping` halo shows **only when an unread tip
|
||
exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes**
|
||
(`isFullBleed` passed from `Layout`). The bubble keeps its **full size/glow**
|
||
on every route (do NOT shrink it into a dull compact circle — that looked
|
||
bad); the map only loses the pulse. **Hide** is a small ✕ shown on
|
||
**desktop hover**, plus an **EyeOff "Hide assistant"** button in the panel
|
||
header, plus `useSasquatchHidden` (`sasquatch-hidden`, same-tab synced via a
|
||
`sasquatch:hiddenchange` event) and a **Settings → Appearance** "Show
|
||
Sasquatch assistant" toggle. (Earlier iteration put an always-visible ✕ over
|
||
the avatar on mobile — removed; it was ugly.)
|
||
- **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 **community relay** (self-hosted
|
||
`wss://relay.skate-spots.org` since 2026-10-12, with the legacy hosted relay as
|
||
a fallback). 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 `<button>` rows
|
||
(like route steps) must add **`min-h-0`** or every row becomes a 44px slab —
|
||
a class beats the element selector, so `min-h-0` is enough.
|
||
- **Follow mode / start point / offline (2026-09-27):** the dialog also does
|
||
**follow mode** (explicit **Start** → `watchPosition` in `geolocation.ts`,
|
||
live dot, "NEXT · step N" banner with distance-to-turn + distance-to-go,
|
||
current step highlighted), a **start point** choice (My location / Choose on
|
||
map — tapping the Leaflet map sets it), and an **offline route cache**
|
||
(`src/lib/routeCache.ts`, downsamples the line to ≤250 points, keeps the last
|
||
8 destination+mode entries; the failed fetch falls back to it and shows
|
||
"Offline · saved route {age}"). Rules: **never auto-start the watcher**
|
||
(battery — user action only) and always `stop()` it on close/unmount; a
|
||
transient watch error (timeout/temporarily unavailable) must **not** tear down
|
||
the watcher — only `denied` does (headless CDP testing made this obvious:
|
||
moving the mocked position emits a transient error that recovered).
|
||
**Phone navigation (2026-09-29):** while following we also keep the screen
|
||
awake (`navigator.wakeLock`, re-acquired on `visibilitychange`), speak turns via
|
||
**device TTS** (`speechSynthesis`, prefer a `localService` voice in the user's
|
||
language — free/offline/no server) at ~150 m and ~40 m bands (announced once
|
||
each, tracked in `announcedRef`), buzz haptics (`navigator.vibrate`), detect
|
||
**arrival** (~30 m → "You've arrived", auto-stop), and show a **bigger map**
|
||
while following with the planning step-list hidden. Voice has a mute toggle
|
||
persisted in `skatespots:route-voice`; TTS is unlocked by the Start tap.
|
||
**Still deliberately absent: auto-reroute if you leave the route.** True
|
||
screen-off/background navigation needs a native foreground service — the app
|
||
must stay foregrounded to guide.
|
||
- **Dialog layout (2026-09-29):** app-like flex column —
|
||
`flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden`. **DOM order now
|
||
matches the visual order** (and `order-*` is set redundantly, so a dev-mode
|
||
CSS-timing hiccup can't scramble it): header, then the **map directly under
|
||
it** (it's the point of the screen), then the **mode/vehicle row**, then the
|
||
**"From" row**, then the scrolling details, with the **Start/Stop bar
|
||
pinned** at the bottom. The map is `shrink-0` at
|
||
`h-[34vh] min-h-[220px] sm:h-[400px]`, or `h-[54vh] min-h-[320px]`/`sm:h-[60vh]`
|
||
while following. The **summary is a compact one-line strip**
|
||
(distance · time · arrive) — `StatTile` was **removed**; the three ~90px
|
||
tiles were eating the map's space. **Owner feedback (2026-10-01): the map
|
||
dominated the phone screen** — it was `50vh` (and `38vh` before that); `34vh`
|
||
gives the itinerary and controls room while staying pannable, and follow mode
|
||
restores a bigger map. The dialog's tagline ("In-app route · OpenStreetMap
|
||
routing — nothing leaves to Google or Apple Maps") was **removed from the
|
||
visible chrome** (kept `sr-only` for a11y); that promise now lives on
|
||
**/privacy → "What leaves your device" → Directions**. ⚠️ Don't go back to a single
|
||
`overflow-hidden` content box: that made everything below the fold
|
||
**unreachable** once the map grew. The map carries a metric
|
||
`L.control.scale`. **Transit** mode uses **Transitous** (`api.transitous.org`,
|
||
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
|
||
modes. **No external map-app handoff anywhere** (product decision) and no
|
||
scooter/moped profile. **Speed (2026-09-27):** `RouteDialog` seeds origin from
|
||
`getLastLocation()` and skips the live GPS call entirely when that fix is
|
||
**< 2 min old**; the blocking "Finding your location…" overlay shows only when
|
||
there is no seed (with a seed it refines silently via a corner chip). The map
|
||
"Near Me" button also flips the sort to `proximity`.
|
||
- **Transit detail (2026-10-01):** Transitous returns **more itineraries than
|
||
requested** (`numItineraries: 4` → we saw 9), so `fetchTransitRoute` now maps
|
||
them ALL into `RouteResult.transitOptions` (`transit` = index 0). `RouteDialog`
|
||
shows alternatives (tap to switch — a separate effect redraws the map, **no
|
||
re-query**) and a **leg-by-leg itinerary**: coloured line badge, headsign
|
||
(`→ S Strausberg Nord`), board/alight stop + clock times, platform `track`
|
||
when present, `N stops` (`intermediateStops.length + 1`), operator
|
||
(`agencyName`), and a **live** badge only when `realTime` is true. Mode →
|
||
icon/colour is MOTIS's own `mode` (`BUS` 🚌, `TRAM` 🚊, `METRO`/`SUBWAY` 🚇,
|
||
`RAIL*`/`REGIONAL_RAIL`… 🚆, `FERRY` ⛴️). ⚠️ MOTIS labels a German S-Bahn
|
||
`METRO` (verified live) — that's the feed's own classification; don't
|
||
"correct" it heuristically. ⚠️ Transitous **403s a generic User-Agent**
|
||
("Generic user-agent headers are not allowed") — the browser sends its own so
|
||
the app is fine, but a Node/curl probe must set a `User-Agent`.
|
||
**Time picker + mode filter + stop boards (2026-10-01):** `fetchTransitRoute`
|
||
takes `{ time, arriveBy, transitModes }` (MOTIS `time`/`arriveBy`/`transitModes`);
|
||
the dialog exposes **Leave now · Depart at · Arrive by** (a `datetime-local`
|
||
input) and an **Only** row of mode toggles (nothing selected = all modes).
|
||
Mode groups live in **`src/lib/transitUi.ts`** (`TRANSIT_MODE_GROUPS`) with the
|
||
shared mode→emoji/colour/label helpers — same module the board uses, so the
|
||
vocabulary can't drift. Tapping a **stop name** in the itinerary opens
|
||
**`StopDepartures.tsx`** (`fetchStopDepartures` → MOTIS `/api/v1/stoptimes`,
|
||
`n=10`), showing clock, mode, line, headsign, a feed-provided platform hint
|
||
(`place.description`, e.g. "S-Bahnsteig Gleis 3"), and **live/cancelled**
|
||
badges. It is rendered *outside* the route dialog (a nested focus trap would
|
||
fight). ⚠️ **Fares do not exist in this data — do NOT invent prices:**
|
||
verified live that the plan response has no fare field (`debugOutput.fares: 0`,
|
||
`agencyFareUrl: ""`). The only honest fare UI is a `tickets ↗` link to
|
||
`agencyFareUrl` **when the feed supplies one** (usually empty). Still absent:
|
||
nothing planned.
|
||
- ⚠️ **`RouteDialog` had a total-failure bug (fixed 2026-09-27), don't regress:**
|
||
(1) its Leaflet map is inside Radix Dialog content, which mounts AFTER the
|
||
component's first effect pass, so a `mapDivRef.current` guard was null,
|
||
the init effect bailed, and **the map was never created** (no route line
|
||
ever appeared). The node is now held in **state** (`const [mapDiv,setMapDiv]`)
|
||
with a callback ref, and `mapDiv` is in the effect deps so it runs when the
|
||
node actually mounts.
|
||
(2) Even once created, the global `.leaflet-container { height: 100% }`
|
||
override in `index.css` **beats `h-64`** on the map element; against a
|
||
height-less parent it resolved to 0 (invisible map). Height now lives on the
|
||
wrapper (`<div className="relative h-64">`) and the map is `h-full`.
|
||
(3) On the map, Directions used to render the dialog INSIDE the spot `Sheet`
|
||
(two stacked Radix modals; the sheet covered the route). The map now owns
|
||
the dialog at page level (`routeTarget` state) and closes the sheet first.
|
||
- OSRM is called **directly** (`router.project-osrm.org` sends
|
||
`Access-Control-Allow-Origin: *`) with the `proxy.shakespeare.diy` CORS proxy
|
||
only as a fallback — faster, and origin/destination never leave OSRM unless the
|
||
direct call is blocked. Failures are distinguished: `Routing service busy`
|
||
(429/TooManyRequests), `No route found` (NoRoute / invalid coords), else
|
||
unreachable. Verify route rendering with `scripts/`-style CDP repros, not just
|
||
the smoke test (which only checks mount).
|
||
- **`Layout` is a router layout** — `AppRouter` wraps every route (except
|
||
`/amber-callback` and the `*` 404) in **one** `<Route element={<Layout/>}>`
|
||
with an `<Outlet/>`, so the chrome (header, nav, notifications, Sasquatch)
|
||
mounts **once** and survives navigation. Pages render **fragments** (`<>…</>`),
|
||
never `<Layout>`. The map is full-bleed via a route check (`isFullBleed` in
|
||
`Layout`), not a per-page `noPadding` prop. **Don't reintroduce per-page
|
||
`<Layout>` wrappers** — remounting the chrome re-runs its hooks (and with Amber
|
||
that means signer popups). Encrypted-on-mount hooks are also gated for
|
||
interactive signers (`staleTime/gcTime: Infinity`, `refetchOnMount: false`) —
|
||
see `useSyncedAppData` / `useStash`. **Read-state is local-only for interactive
|
||
signers**: `useReadState` skips the encrypted publish when
|
||
`isInteractiveSigner` is true, so merely opening the notification bell (which
|
||
marks read) no longer pops Amber twice (encrypt + sign). Interactive signers
|
||
only prompt for real signed actions.
|
||
- ⚠️ **`staleTime: Infinity` does NOT stop the FIRST decrypt (fixed
|
||
2026-09-27).** That was the "Amber pops on every screen" report: the
|
||
encrypted `useSyncedAppData` blobs (read-state, preferences) ran their
|
||
queryFn once on mount for each racing consumer, and relay responses landing
|
||
during navigation made it look per-page. `useSyncedAppData` now takes
|
||
**`interactiveNetwork` (default false)**: for an interactive signer the blob
|
||
is **local-only** — the query is not even enabled and `patch()` is a no-op —
|
||
so no decrypt on mount and no publish on a nav-tab change. localStorage is
|
||
the session source of truth. Only set `interactiveNetwork: true` if a feature
|
||
genuinely cannot work from localStorage. **Diagnostic recipe:** mock an
|
||
`x-amber` login by writing `localStorage['nostr:login']` before load, then
|
||
wrap `localStorage.setItem` to log writes of `amber_pending_operation`
|
||
(captures every prompt + a stack). Beware stale harness profiles — use a
|
||
fresh `--user-data-dir` per run or the ops array is from a previous run.
|
||
- ⚠️ **Don't make read-state mutators (or any hook they flow from) unstable
|
||
(fixed 2026-09-27).** `/newsletter` was stuck in an **infinite render loop**
|
||
(~100% CPU, React "Maximum update depth exceeded") because: `useSyncedAppData`
|
||
returned a **fresh object literal every render** → `useReadState.apply`
|
||
(dep `synced`) got a new identity → `markNewsletterRead`/`markAllRead` changed
|
||
every render → page effects depending on them re-ran after every render →
|
||
the mark set a **new state object** → re-render → loop. Same latent loop in
|
||
`NotificationBell` **while open**. Fixes (keep them): `useLocalStorage`'s
|
||
setter is **referentially stable** (ref + functional update), `useSyncedAppData`
|
||
returns a **memoized** object, `useReadState.apply` reads `synced`/`interactive`
|
||
from **refs** so it's stable, and `markAllRead`/`markNewsletterRead` are
|
||
**idempotent** (return the SAME object when the marker already covers `now`, so
|
||
React bails). Page effects that mark read on open use a **mount-once ref guard**
|
||
(`NewsletterPage`). Diagnose with CDP `Performance.getMetrics` `TaskDuration`
|
||
over ~4 s idle (a looping route burns ~4 s; a healthy one ~30–100 ms).
|
||
- **Newsletter publish/read relay rule:** issues (kind 30023,
|
||
`t=skatespotsnews`) and the subscription marker (kind 30078,
|
||
`d=skatespots:newsletter:subscription`) are **read via `useSkateRelays`
|
||
(`querySkateContent`)** — i.e. the community relay + the reader's relays, and
|
||
for logged-out visitors **only the community relay**. So they must be
|
||
**published to `getWriteRelays()`** (community relay + the author's relays),
|
||
NOT the default pool (`useNostrPublish`). The composer / backfill / subscribe
|
||
hook do this and fail loudly if every relay rejects. `useSpotChat` is the
|
||
reference pattern. (Before the fix, an admin whose own NIP-65 lacked
|
||
`goskatespots` would publish issues nobody could find.)
|
||
- **Weekly newsletter automation (2026-10-28).** `scripts/newsletter-weekly.ts`
|
||
generates the next issue (published max + 1), builds all fields, makes a
|
||
fresh 1200×630 cover from the Drop #006 foundation, uploads it to Blossom,
|
||
and publishes **only a draft** as NIP-78 `kind 30078`,
|
||
`d=skatespots:newsletter:draft`, `t=skatespotsnewsdraft`. The admin
|
||
`/newsletter` page shows a **Weekly draft ready** banner; the composer loads
|
||
it (`useNewsletterDraft`) and has a safe Markdown **Preview**. Set
|
||
`NEWSLETTER_AUTOPUBLISH=1` in the service to also publish the NIP-23 issue +
|
||
kind-1 teaser. The timer (`scripts/systemd/skatespots-newsletter.timer`,
|
||
Monday 08:00, install with `scripts/install-newsletter-timer.sh`) defaults to
|
||
`NEWSLETTER_AUTOPUBLISH=0` — review the voice/cover before enabling. The
|
||
wrapper also sends the admins a content-free NIP-17 DM (push-enabled) with
|
||
the issue number and /newsletter link when the draft is ready.
|
||
editorial bank lives in `src/lib/newsletterContent.ts` (rotating South Park
|
||
jokes; 4 shipped highlights; current tech wires); issue 7 uses the curated
|
||
highlights, later issues parse the top of `release-notes.txt`. The static
|
||
archive now hides an issue number that is also live, so backfilled issues
|
||
don't duplicate. The draft event is public (it contains no private data);
|
||
never put `SIGN_WITH` in it or in tracked files.
|
||
- **Global command palette (2026-10-28):** `components/GlobalSearch.tsx` +
|
||
`lib/globalSearch.ts`; opens on `Cmd/Ctrl+K` or the header search button.
|
||
`CommandDialog` (cmdk) searches static pages/nav plus spots/shops/crews/events/
|
||
gear/skaters. All data hooks are **enabled only while open** (the map's
|
||
`useSkateSpots(2000)` key is reused); recent searches live in
|
||
`skatespots:global-search-recent`. It suppresses the desktop welcome modal on
|
||
open. Map Events/Crews layer choices use `useLocalStorage`; `ScrollToTop` now
|
||
restores scroll per history entry on POP and scrolls to top on PUSH.
|
||
- **Nav parity (fixed 2026-09-27):** the desktop header is `max-w-[1700px]`
|
||
(wider than the page `container`'s 1400px) and nav items are `px-3` +
|
||
`whitespace-nowrap`; the nav keeps `min-w-0 overflow-x-auto scrollbar-hide`, so
|
||
**if it ever overflows it clips the TRAILING item — i.e. the "More" menu**
|
||
(this is exactly how "More" vanished when MARKETPLACE replaced MINTS). Keep the
|
||
header wide / padding tight; don't let the nav grow without room. The mobile
|
||
burger menu (`MOBILE_MENU_LISTED_HREFS`) now **appends catalog items that
|
||
aren't hard-coded**, so Marketplace/Mints (and any future section) are always
|
||
reachable on mobile — don't re-hardcode only a subset.
|
||
- **Transport / Driver Lab craft (2026-10-29):** kind `35016`
|
||
(`skatetransport`) is a data-driven `BECOME_ROLES.transport` craft over
|
||
`CraftProfileForm`. Vehicles (`car/taxi/van/moto/helicopter/plane/boat/other`),
|
||
services (`people/goods/gear/airport/venue/tours/logistics/other`),
|
||
capabilities, capacity and coverage are `t` tags; extras are `capacity`,
|
||
`cargo`, `coverage`, `availability`, `base`, `accessibility`. `LabPage` has a
|
||
Transport tab + filters, `CoachDetailPage` renders the shared trust/vouch/
|
||
review blocks and a prominent **self-attested** notice. No licence, insurance,
|
||
roadworthiness or operator permit is verified — never label it verified.
|
||
`useGrowthStats.totalTransport` / `/stats` and Supporters impact include it.
|
||
- **DIY resources (`/diy`, 2026-10-05):** a hub for making your own
|
||
boards/ramps/concrete. Content is a seed list in **`src/lib/diyGuides.ts`**
|
||
(`DIY_CATEGORIES` boards/ramps/concrete/repairs/tools + `DIY_CARDS` +
|
||
`DIY_ACTIONS`); `src/pages/DiyPage.tsx` renders it. **Policy: link to our own
|
||
app sections (`to`) where we have content; otherwise show the topic as an
|
||
unclickable `comingSoon` card** — no external links. Registered as a nav-catalog
|
||
item (id `diy`, Hammer) so it shows in the desktop "More" menu **and** the
|
||
mobile burger, and is linked from the **site footer** + the **map links strip**.
|
||
Cross-links to `/lab?craft=building` (Builder), `/crews`, `/create-spot`,
|
||
`/create-listing`, `/lab?craft=coaching`, `/marketplace`. **Deck cost
|
||
calculator (2026-10-05):** the first in-app tool lives at `/diy/deck-cost`
|
||
(`components/DeckCostCalculator.tsx`) — a self-contained (localStorage) model of
|
||
labour, equipment amortisation, operations, materials, process time, packaging
|
||
and margin → selling price; the `deckCost` card links to it. **Naming is
|
||
deliberate (2026-10-05):** the labour multiplier is **"Labour burden (×)"** (not
|
||
"overhead" — rent/bills are the separate **Operation expenses** section; the
|
||
hint warns against counting them twice), the derived `wage × burden` figure is
|
||
the **"Loaded rate"**, and the multiplier is **"Markup (×)"** with the implied
|
||
**gross margin %** shown (it is *not* a margin). The three `# boards` columns
|
||
are context-specific: **Lifetime boards** (equipment), **Boards / order**
|
||
(materials), **Boards / run** (processes). CSV keys stay machine-stable
|
||
(`overhead_factor`, `profit_margin`, …) for round-tripping.
|
||
**UX + batch/trade (2026-10-05):** the long sheet is now an **accordion**
|
||
(`CalcSection` — each header carries its live per-board subtotal; Labor open by
|
||
default, Margins always visible) with **quick-start templates**
|
||
(`SHEET_TEMPLATES`: *Hobby starter* / *Small shop*) and an amber
|
||
**sanity-warning** block (markup < 1×, wholesale ≥ retail, rows with 0 boards,
|
||
fixed costs with 0 boards/month, no materials, equipment+overhead > 60% of
|
||
cost, equipment spread over 1 board). The result adds **quantity** (unit price
|
||
vs order total), a **wholesale markup** → wholesale/unit price, **profit/deck**
|
||
and a **breakeven** (`fixed ÷ profit`, plus a `targetMonthlyProfit` goal). New
|
||
state `quantity` / `wholesaleMarkup` / `targetMonthlyProfit` round-trips through
|
||
CSV (`margins` rows); `numInput` clamps negatives to 0. The client quote shows
|
||
`× N` and the order total (the deposit is taken on the total).
|
||
**Advanced toggle (2026-10-05):** a **Simple ↔ Advanced** switch (persisted
|
||
`skatespots:deck-cost:advanced`) — deliberately **not** called "Pro" (that name
|
||
is Sasquatch Pro). Advanced reveals four things: **per-operation labour rate**
|
||
(`ProcessRow.rate`, 0 = shop rate) and **per-operation scrap**
|
||
(`ProcessRow.scrapPct`) that **compounds** with the general scrap into an
|
||
*effective waste %*; **batch setup** (`batchSetupHours` + `batchSetupCost`, a
|
||
one-off per run amortised over `quantity`, so the unit price depends on qty);
|
||
a **tax / VAT** line (`taxPct` + `taxInclusive`, shown on the client quote as
|
||
Subtotal/Tax/Total or "includes X% tax"); and a **cost-vs-quantity curve**
|
||
(1/5/10/25/50/100 decks). `costWithPackUnit`/`costNoPackUnit` include setup, and
|
||
the prices use them. All four round-trip through CSV, and the state is merged
|
||
over `DEFAULT_CALC` on read (old sheets/presets can't produce NaN — `clampPct`
|
||
is NaN-safe too). Currency switching also converts `targetMonthlyProfit`,
|
||
`batchSetupCost` and process `rate`.
|
||
**Polish (2026-10-05, part 2):** the client-quote fields are a collapsed
|
||
`<details>` ("Client quote details") so the always-visible Margins card stays
|
||
short; `CalcSection` sets `aria-expanded`; every table row gains a **duplicate**
|
||
(Copy) action beside delete; the sticky summary chip shows the **order total**
|
||
when `quantity > 1` (and uses the setup-inclusive cost); and the marketplace
|
||
hand-off passes a **buyer-inclusive** unit price (tax added on top when tax
|
||
isn't marked inclusive).
|
||
**Mobile layout (2026-10-05, part 3):** the sheet tables (5–8 columns) were
|
||
unusable on a phone — values truncated (`900`→`90`) and columns fell off the
|
||
edge. On `max-width:639px` the 4 data tables become **stacked cards** via
|
||
`src/index.css` `.ss-sheet-table` (`thead` hidden; each `td` a label/value row;
|
||
`data-label` supplies the label through `td::before`; cells `flex-wrap` so a
|
||
long label + input never overflows). The **Cost-vs-quantity** block is now a
|
||
vertical list, not a table, so nothing scrolls sideways. Verify with a
|
||
`--window-size=390,…` headless screenshot; add `defaultOpen` to a `CalcSection`
|
||
temporarily to see a table expanded. **Currency
|
||
(2026-10-05):** a **searchable currency selector** (Popover + search) covering
|
||
**sats + BTC** (both Bitcoin units) and every **fiat** Coinbase reports (crypto
|
||
is filtered out via `FIAT_CODES`), and switching **converts every money field**
|
||
using live BTC rates (`useBtcRates` → `getBtcRates`, one Coinbase
|
||
`exchange-rates?currency=BTC` call — all currencies, `sats` = 1e8; unitless
|
||
fields are untouched). Falls back to relabel-only when rates are unavailable.
|
||
**Polish (2026-10-05):** `Setup (h)` now feeds equipment amortisation
|
||
(`(cost + setupHrs × labor/hr) / boards`); sats round to whole numbers; each
|
||
section shows a per-board subtotal; a **sticky summary bar** keeps cost/price
|
||
visible; **Save/Load named presets + JSON Export/Import** (localStorage +
|
||
download/file), and Reset now confirms. The results include a
|
||
**"List a deck at this price →"** link to `/create-listing?price¤cy`
|
||
(CreateListingPage reads both params) and **"Get it made →"** to
|
||
`/lab?craft=building`. **Export (2026-10-05):** the primary export is a **PDF**
|
||
(lazy-loaded `jspdf`) — a formatted summary of every section + the selling
|
||
price; the reason it's a real PDF and not print-to-PDF is that
|
||
`window.print()` doesn't work in the Capacitor WebView. A secondary **JSON**
|
||
export remains (for Import/backup); Import reads JSON. The default **Materials**
|
||
list is a full deck-build checklist (veneer/edge tape/logs, glue/epoxy/filler,
|
||
mold release/vacuum film, sandpaper/saw+drill bits/masking tape, stain/primer/
|
||
paint/clearcoat/grip). **Export/Import (2026-10-05):** **Export ▾** —
|
||
**PDF (full sheet)**, **PDF (client quote — uses a "Quote details" block:
|
||
business name, a data-URL logo, email/phone/website/address, notes/terms, a
|
||
**quote # counter** (`Q-0007`, with a +1 button) and **validity days**; shows
|
||
logo + contact header, line item, total, `Quote valid until <date>` and notes,
|
||
not the internal breakdown)**, **PNG** summary card (canvas — now also draws
|
||
the **logo + business name**), **XLSX** multi-sheet workbook (lazy `xlsx`),
|
||
**CSV** (`section,item,field,value`), **Markdown**, **JSON** (backup), **Copy
|
||
summary** (clipboard). ⚠️ The **quote identity lives in its own store**
|
||
(`skatespots:deck-cost:quote`, `QuoteProfile`), **not** in `CalcState`, so
|
||
loading/saving presets keeps the same business name/logo/contact (the sheet is
|
||
numbers only). The sheet's key is `skatespots:deck-cost:v2`. **Nostr identity on
|
||
the quote (2026-10-05):** `QuoteProfile` also carries **NIP-05, npub and a
|
||
Lightning address (lud16)**; a **"Fill from profile"** button autofills them (plus
|
||
name/website) from the logged-in **kind-0** (`useCurrentUser` + `useAuthor` +
|
||
`nip19.npubEncode`). The client PDF lists `NIP-05 …` / `nostr npub…` in the
|
||
contact block and `Pay via Lightning: <lud16>` under the total; the PNG shows the
|
||
NIP-05 (or npub) under the business name. A PDF is a **snapshot** — the identity
|
||
is verifiable on Nostr, not on the paper.
|
||
**Payment terms on the quote (2026-10-05):** `QuoteProfile` also carries
|
||
**payment methods** (multi-select chips: Cash/Lightning/Cashu/Bank/Card/Other),
|
||
a **deposit %** (default 50 — the PDF splits *Deposit due now* / *Balance on
|
||
completion*), free-text **payment terms** and **payment details** (bank/PayPal,
|
||
separate from the schedule), and an **optional Incoterm** (EXW/FCA/DAP/DDP —
|
||
light, not a contract; the app still never handles shipping). Read with
|
||
`{ ...DEFAULT_QUOTE, ...rawQuote }` so a quote saved before a new field existed
|
||
can't crash. **Import**
|
||
auto-detects `.json`/`.csv`: a CSV whose
|
||
header is `name,order_cost,…` is treated as a **supplier price list** (replaces
|
||
the Materials table), otherwise it round-trips the whole sheet. Hand-rolled CSV
|
||
parse/escape so commas in names are safe. Next: write our own
|
||
step-by-step guides (NIP-23 `30023` with `t=diy`) to flip the coming-soon cards
|
||
live.
|
||
- **Deck designer — FROZEN / HIDDEN (2026-10-05):** ⚠️ **Not ready for regular
|
||
users.** It is **not listed in the DIY hub** (`diyGuides.ts` has a note where
|
||
the card was) and `/diy/deck-designer` is **admin-gated** — `DeckDesignerPage`
|
||
returns `<Navigate to="/diy">` unless `isAdminPubkey(user.pubkey)`, and shows an
|
||
"Internal preview" badge. To un-freeze: remove the guard in
|
||
`DeckDesignerPage.tsx` **and** re-add the `deckDesigner` card in
|
||
`diyGuides.ts`. The i18n keys (`diyUi.cards.deckDesigner.*`, `diyUi.designer.*`)
|
||
are kept. The code below is otherwise intact.
|
||
Parametric 2D outline + 1:1 template — `src/lib/deckDesign.ts` (pure maths) +
|
||
`components/DeckDesigner.tsx` + page. Inputs: width, wheelbase, nose, tail (length is **derived** as
|
||
nose+wheelbase+tail), waist, end-roundness, concave, thickness, tail/nose kick,
|
||
and the **truck-hole pattern** — **New school 53.975 × 41.275 mm (2.125″×1.625″)**
|
||
or **Old school 63.5 × 41.275 mm**; **3/16″ (r 2.38 mm) holes**. Exports:
|
||
**SVG at true mm** (width/height in mm → prints 1:1), **tiled 1:1 PDF** (A4/Letter,
|
||
split across sheets with corner registration crosses — `doc.lines` deltas, no
|
||
path-API dependency, `ensureRoom` guards an overflow), and **DXF** (R12,
|
||
`$INSUNITS=4`, outline polyline + hole circles + centreline). ⚠️ **Deliberately
|
||
2D** — this is the template a builder traces and cuts, **not** a SK8CAD clone.
|
||
**SK8CAD is proprietary** (its Terms forbid copying/redistribution); only its
|
||
*engine*, **JSCAD, is MIT**. We did **not** copy or self-host it, and we don't
|
||
link out (DIY policy = no external links). If full 3D/mold CAD is ever wanted,
|
||
the honest route is a JSCAD-based generator we own (CSG → STL, **not** STEP) —
|
||
see the decision in the session notes.
|
||
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be
|
||
paid in **sats only (Lightning or Cashu)** — no cards/accounts — sold
|
||
**locally, on request**: there is **no cart, no checkout, no shipping**. Buying
|
||
is a conversation: "Request an item" deep-links to a **DM with the owner**
|
||
(`/messages/<owner npub>`, owner = `PLATFORM_OWNER_PUBKEYS[0]`), the details
|
||
are agreed in chat, and sats change hands at handover. Don't add shipping
|
||
copy/rates or a cart without a decision to change that model. Registered in
|
||
`NAV_CATALOG` as id `app-shop` (label "App Shop", `ShoppingBag`), which is what
|
||
makes it appear in **both** the desktop "More" menu and the mobile burger menu
|
||
(the mobile menu appends catalog items not in `MOBILE_MENU_LISTED_HREFS`). It is
|
||
**intentionally empty**: no stock, no prices, no pre-orders and nothing for
|
||
sale. That is carried by the "COMING SOON" badge, the "IN THE WORKS / not for
|
||
sale yet" label on the teaser grid and the "sold locally, on request" hero
|
||
copy (the page deliberately has **no** stock list, no subscribe band and no
|
||
'nothing is for sale' note — removed on request). **Do not add prices/fake urgency/buy buttons until
|
||
there is real stock**; when items exist, model them as listings and take
|
||
Lightning/Cashu payment. The page reads the live stock strip as 0/None/Nothing
|
||
— keep it honest if that changes. It also serves two **free printables** (rendered INSIDE the 'Sticker packs'
|
||
entry of the 'In the works' grid — deliberately not a separate section, per the
|
||
owner: the free sticker + poster belong under their merch category)
|
||
(`public/images/print/skatespots-sticker.svg` 55×86 mm, `…-poster.svg` A3),
|
||
regenerated with `node scripts/make-print-art.mjs` (output is committed). Those
|
||
SVGs are self-contained: brand fonts + logo embedded as data URIs and a real
|
||
vector QR (verified decoding to https://skate-spots.org — if you regenerate,
|
||
re-verify the QR scans with the logo in the middle; error-correction level H).
|
||
- **Spot data quality (2026-09-27):** three cheap defences against junk pins
|
||
(test entries, airports, malls), because "the map looks endorsed" is the real
|
||
risk. (1) `CreateSpotPage` **requires ≥1 photo** (guard in `handleSubmit` +
|
||
the submit button is disabled) — a typo rarely has a photo. (2) The spot page
|
||
shows a **Community verified / Unverified** badge computed from signals only
|
||
*other* skaters can produce: community photos + `useSpotStatus` `reporterCount`
|
||
(kind 2259). **Community confidence aggregation (2026-10-03):**
|
||
`src/hooks/useSpotCommunityScore.ts` does ONE batched query for a spot's
|
||
engagement (photos 1063, status 2259, comments/tag-suggestions 1111, reactions
|
||
7, zaps 9735, collections 30003), counts **distinct accounts** (never the
|
||
mapper), tier-weights costly signals (zap 3, photo/status 2, rest 1) with a
|
||
**per-account cap of 3**, and multiplies by each actor's **web-of-trust + real
|
||
activity** (`useBatchTrustScores`/`useBatchActivity`) so a ring of empty
|
||
accounts can't inflate it. Returns `level: unverified | seen | community` +
|
||
breakdown; `SpotDetailPage` shows a "why" line (`2 photos · 1 zap …`). The
|
||
weather score was renamed **"Conditions · weather only"**
|
||
(in `SpotDetailPage` and the `SpotMap` popup) so it can't read as spot quality.
|
||
(3) `useNotASpotReport` = one-tap **NIP-56 (1984)** report tagged
|
||
`not-a-skate-spot` (NIP-56 `other` reason + our `L`/`l`/`t` taxonomy), used
|
||
from the spot's "More" menu and the shared `ReportDialog`.
|
||
- **Admin hide + review queue (2026-09-27):** `useAdminModeration` always READ
|
||
NIP-32 kind-1985 `hidden` labels, but **nothing published one** — the only
|
||
working hide was device-local (`useDeleteEntity` → `addLocalHidden`).
|
||
`useAdminHide` is the missing write side: kind 1985 + `['L',
|
||
'org.skatespots.moderation']` + `['l','hidden',NS]` + `['a',coord]`/`['e',id]`,
|
||
plus an optimistic local hide. `/review` (`AdminReviewPage`, **admin-only,
|
||
404 otherwise**) lists spots that trip cheap signals (suspicious title, name
|
||
≤3 chars, no photo, no description, no geohash) sorted by flag count, with
|
||
one-tap **Hide** or **Not a skate spot**; entry point is the admin-only
|
||
`AdminReviewCard` in Settings → Advanced. Hiding is **soft and reversible** —
|
||
the event stays on relays (honest for a censorship-resistant network); nothing
|
||
is deleted. `/review` is in the smoke-test route list.
|
||
- **Symmetric verdicts + richer signals (2026-09-27):** `MODERATION_LABEL_VERIFIED`
|
||
is the positive counterpart to `hidden`. `useAdminModeration` fetches both
|
||
labels in ONE query and exposes `verifiedCoords`/`verifiedIds`/`isVerified()`
|
||
(plus a local mirror via `addLocalConfirmed`/`getLocalConfirmed`), and
|
||
`useAdminVerify()` (same file as `useAdminHide`) publishes it. Using it, the
|
||
review queue can **Confirm** a spot — which clears it from the queue and shows
|
||
a "Verified by SkateSpots" badge on the spot page — instead of only ever
|
||
hiding things. Queue flags now also include **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 flag logic is shared in **`src/lib/spotQuality.ts`** (used by both the page
|
||
and `AdminReviewCard`, so they can't drift) and is deliberately heuristic —
|
||
'no community signals' only fires alongside another flag, so a brand-new real
|
||
spot isn't punished for being new. Community signals now include **other
|
||
skaters' photos**: `useBatchSpotPhotos` (`useSpotContributions.ts`) does ONE
|
||
`#a` query for every spot and ignores the mapper's own photos.
|
||
- **Verdicts are reversible — latest wins (2026-09-28):** `MODERATION_LABEL_CLEARED`
|
||
('cleared') is the neutral undo verdict. `useAdminModeration` no longer unions
|
||
labels; it keeps the **newest verdict per target** (by `created_at`, with
|
||
device-local verdicts overlaid by timestamp), so publishing `cleared`
|
||
supersedes an earlier `hidden`/`verified`. `useAdminClear()` publishes it.
|
||
The local mirror is now a **verdict map** (`skatespots_local_admin_verdicts_v1`,
|
||
key = coord or `e:<id>`, value `{verdict, at}`), migrating the legacy
|
||
hidden/confirmed arrays on read. `useSkateSpots(limit, { includeHidden: true })`
|
||
is **admin-tooling only** — without it a hidden spot leaves the list and can
|
||
never be un-hidden. The review queue keeps actioned rows (badge + **Undo**)
|
||
so both verbiage and verdicts are reversible in the UI.
|
||
- **Reports reach the admin, duplicates are caught, contributors are told (2026-09-28):**
|
||
three gaps closed in one pass.
|
||
1. **Reports were a void.** `ReportDialog` publishes NIP-56 (1984) for spots/
|
||
events/tours/users/listings/comments, but the ONLY reader in the app was a
|
||
count on marketplace cards — an admin never saw a report. Now
|
||
**`useSpotReports`** batch-reads `kinds:[1984], '#e': [all spot ids]` in ONE
|
||
query (via `querySkateContent`), and the review queue ranks reported spots
|
||
ABOVE the heuristics (red `REPORTED: …` chip). `useNotASpotReport` now
|
||
publishes to **`getWriteRelays()`** (community relay + the reporter's) rather
|
||
than the default pool, because the admin reads from the community relay — the
|
||
same relay-parity rule as the newsletter and DMs. Verified end-to-end by
|
||
publishing a report from Node and seeing it surface in the queue.
|
||
2. **Duplicate guard** in `CreateSpotPage`: on submit, any existing spot within
|
||
**60 m** (geohash-decoded, ≤3 candidates) shows an inline warning listing them
|
||
with distances and links, plus "It's a different spot — publish anyway"
|
||
(`dupConfirmedRef` bypass). Designed for the clone-spam failure mode.
|
||
3. **Contributor feedback:** `useSkateSpot` no longer returns null for a hidden
|
||
spot when the viewer is its **author** — otherwise a moderated spot silently
|
||
404s for the person who made it. `SpotDetailPage` shows an author-only banner
|
||
("Hidden by a moderator" / "Verified by SkateSpots"). The verdicts are public
|
||
labels, so there is nothing secret being revealed.
|
||
- **Review queue covers every content kind (2026-09-28):** `/review` now has tabs
|
||
for **spots / shops / events / tours / gear**. The hide/verify labels were
|
||
always kind-agnostic (`a`/`e` tags), so the work was: (1) add
|
||
`{ includeHidden }` (admin-tooling only) to `useSkateShops`, `useSkateEvents`,
|
||
`useTours` and `useMarketplaceItems` — same reason as spots; (2) generalise the
|
||
flags with **`contentFlags()`** in `src/lib/spotQuality.ts` (spot-specific
|
||
signals — trust/status/photos — stay in `spotFlags`); (3) one small component
|
||
per kind so only the OPEN tab queries (five hooks in one component would fire
|
||
five relay queries on every visit). Reports (`useSpotReports`) and trust
|
||
(`useBatchTrustScores`) are kind-agnostic; status/photo signals are spot-only.
|
||
Status/go-lab/crew queues are still out — profiles are less spam-prone and
|
||
hiding a crew hides a community.
|
||
- **Profile badges (2026-09-28):** `lib/badges.ts` + `components/ProfileBadges`.
|
||
Two sources: **(1) the built-in SkateSpots badge** — *derived* from a `nip05`
|
||
ending `@skate-spots.org` (app logo → `https://skate-spots.org/`), so every
|
||
issued handle gets it with no storage and it works for existing users; **(2)
|
||
user badges** from a kind-0 **`badges`** array (`{label, url?}`), rendered as
|
||
clickable chips (external links). The legacy newline **`sponsors`** field is
|
||
carried forward into badges (`parseBadges` reads it; `EditProfileForm` now
|
||
writes `badges` and deletes `sponsors`). Shown on the profile page
|
||
(`NIP19Page`): the app badge in the header, user badges in a "Badges" card.
|
||
⚠️ `NostrMetadata` has no `badges` field, so it's read via a cast
|
||
(`metadata as … & Record<string, unknown>`), like `sponsors`/`cordn` before it.
|
||
- **Sponsor-ready profile (2026-10-29):** `NIP19Page` now has Follow/Unfollow,
|
||
Followers/Following, **Events hosted** (`useUserEvents`), a six-cell stats
|
||
strip and a neutral **For sponsors & collabs** card (followers/events/spots/
|
||
zaps, prefilled collab DM, Rider Card link). The card only appears when the
|
||
profile has actual skate activity. NIP-05 has a loading/verifying state.
|
||
`useFollowing` invalidates follower counts on follow/unfollow. Sponsorship
|
||
is an **explicit opt-in** (`sponsorships_open` + optional
|
||
`sponsorships_note` in kind-0), never inferred from activity; the neutral card
|
||
is shown only when the profile has skate activity or opted in. `/supporters`
|
||
has a Bitcoin-grantmaker inquiry form that composes a private DM to the owner
|
||
(no storage) and a humorous KYC disclosure.
|
||
- **Report → action + a publish floor (2026-09-28):** the one-tap "not a skate
|
||
spot" report used to only land in the admin queue (invisible effect). It now
|
||
**shows on the spot page to everyone** ("Reported as not a skate spot by N —
|
||
under review"), and **`useAutoHiddenSpots`** hides a spot from the map when
|
||
**≥3 DISTINCT** accounts reported it **and** it has **no community signals**
|
||
(no community photo, no status report). The "no signals" clause is the
|
||
anti-brigade guard; it is reversible (a photo/status, or an admin
|
||
`cleared`/`verified` verdict, brings it back). `useSpotReports` now exposes
|
||
`reporters` (distinct accounts). **Publish floor:** a spot now needs a **photo
|
||
OR a non-empty description** (title + type alone no longer publishes) — photo
|
||
stays optional, per the earlier decision. And the flattering **"Conditions …
|
||
NN/100"** block is **hidden until the spot is confirmed** (community or staff);
|
||
on an unconfirmed pin it read as endorsement of the *spot*.
|
||
- **Private-spot ownership claims (2026-10-02):** a private spot (`access=private`)
|
||
can't be domain-verified like a shop, so ownership is **NIP-05 + moderator
|
||
confirm**, never an instant takeover. Claimant (must have a NIP-05) publishes a
|
||
kind-1985 label in the **separate** `org.skatespots.spotclaim` namespace;
|
||
an admin publishes `claim-approved` (moderation namespace, `a`=coord + `p`=claimant);
|
||
the claimant then re-publishes the spot with `claim-reason=private-approved` to
|
||
become the owner. `useSpotClaims.ts` (`useSpotClaims`/`useApprovedSpotClaims`/
|
||
`useAllSpotClaims`/`useSubmitSpotClaim`) + `useAdminApproveClaim`; UI in
|
||
`SpotOwnership.tsx` (private branch) and a pending-claims list atop `/review`.
|
||
⚠️ Proves key control + NIP-05, **not property ownership**. Non-private spots
|
||
keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims".
|
||
- **Photo + description are required again (2026-10-07, reverses 2026-09-28).**
|
||
Owner decision: the create-spot form now requires **≥1 photo AND a non-empty
|
||
description** (on top of name/type/location) — the empty husk / one-word test
|
||
pin can't publish. ⚠️ This is a **junk filter, not sybil defence**: a bot can
|
||
upload a random image; the real sybil levers remain PoW, WoT weighting and the
|
||
≥3-distinct-reporter auto-hide. The 2026-09-28 "photo recommended, not required"
|
||
decision is **superseded** — do not revert. No-photo publishing is gone.
|
||
- **Add-spot flow overhaul (2026-10-07).** Three parts, one commit:
|
||
(1) **Map tap no longer jumps** you into the form. `MapPage.handleMapClick`
|
||
drops a `pendingAddPin` (rendered by `SpotMap`'s `pendingPin` prop) and shows an
|
||
"Add a spot here?" card; only its **Add spot** button navigates to
|
||
`/create-spot?lat=&lng=`. (2) **The form has a real pin map** —
|
||
`src/components/SpotLocationPicker.tsx` (tap to drop, drag to fine-tune, cached
|
||
tiles). `createSpot.pickOnMap` was an **orphaned key** — the map picker had been
|
||
intended all along; raw lat/lng now live behind an "Enter coordinates manually"
|
||
toggle. (3) **Progressive disclosure + inline errors + draft autosave:** required
|
||
fields (map, name, description, type, photo) are up front; access/contact/shelter/
|
||
tags/address sit under a **More details** collapsible (access defaults public,
|
||
shelter defaults outdoor); field errors render inline (no toast per field); and
|
||
the form autosaves via **`useFormDraft`** (generalized from `useLabDraft`, same
|
||
resume-banner pattern). ⚠️ Leaflet's base CSS **and marker icons are self-hosted**
|
||
now (2026-10-12) — the CSS is imported in `src/main.tsx` (`leaflet/dist/leaflet.css`,
|
||
`MarkerCluster*.css`) and `SpotMap.tsx` imports the marker PNGs from
|
||
`leaflet/dist/images/`, so there's no unpkg/CDN request and the CSP dropped
|
||
`unpkg.com` (in all three copies). A new Leaflet map needs no CSS import.
|
||
Follow-ups in the same pass: the 60 m duplicate check now runs **at pin-time**
|
||
(debounced, warning under the map) using the **same `useSkateSpots(2000)` cache
|
||
key as the map** (was a second `useSkateSpots(500)` query); and publish **caps
|
||
the reverse-geocode at `GEOCODE_BUDGET_MS` (2.5 s)** so a slow provider can't
|
||
stall signing (a miss falls back to the stats' read-time region derivation).
|
||
⚠️ A geohash-prefix nearby query is still impossible — relays only exact-match
|
||
`#g` — so the 2000-spot cache remains the mechanism.
|
||
**Required set (2026-10-07):** pin + name + description + **address** +
|
||
**access type** + photo. Address and access type were **moved OUT of the
|
||
"More details (optional)" collapsible** into the required flow (the collapsible
|
||
now holds only Shelter + Tags) — required fields must not be hidden under
|
||
"optional". Access keeps its **Public default** (so it is always satisfied; the
|
||
`*` marks membership in the required set), and Address has real inline
|
||
validation (`createSpot.addressRequired`).
|
||
- **Map pin rendering (perf, 2026-09-29):** the spot/tour/event/crew marker
|
||
effect in `SpotMap.tsx` used to `clearLayers()` + rebuild **every** marker
|
||
whenever the parent handed it fresh arrays, and added each marker with a
|
||
per-marker `clusterGroup.addLayer()` (each call re-clusters the whole set) —
|
||
several synchronous full rebuilds per load on a few-hundred-pin map. Now:
|
||
(1) a **marker signature** (group geohash + spot/listing event ids + weather
|
||
colour/icon + layer toggles) short-circuits the effect when nothing that
|
||
affects the rendered pins changed — ids are enough because spot/listing/tour/
|
||
event/crew are all **addressable** (a new id on every edit), so unrelated array
|
||
churn (trust scores, comment/zap totals, re-sorts) no longer rebuilds the map;
|
||
(2) markers are collected and added with the plugin's bulk **`addLayers()` in
|
||
`requestAnimationFrame` batches of 150**, cancelled on effect re-run. One
|
||
cluster refresh per batch, pins paint progressively, main thread stays free.
|
||
**If you change what a marker renders, add it to the signature** or the update
|
||
will be skipped. `showSpots/showShops/showGear` are now effect deps.
|
||
- **On-device OS notifications (Phase 1, 2026-09-29):**
|
||
`src/components/OsNotificationBridge.tsx` (mounted once in `Layout`, inert until
|
||
opted in) mirrors the in-app activity — social (`useActivityNotifications`),
|
||
crews (`useCrewNotifications`), newsletter (`useNewsletterIssues`) and **DM
|
||
gift-wrap ENVELOPES (`useDmEnvelopes`, no decrypt)** — to the OS shade.
|
||
Delivery is `src/lib/osNotify.ts`: web `Notification` API, native
|
||
**`@capacitor/local-notifications`** (lazily imported; the dep was added so
|
||
`cap sync` registers it — an installed APK must be rebuilt to pick it up).
|
||
Prefs are **device-local** (`useNotificationPrefs`, key
|
||
`skatespots:notifications:prefs`, opt-in, default **OFF**); UI is
|
||
**Settings → Notifications** (`NotificationSettingsCard`). Hard rules, don't
|
||
regress: **(1) never decrypt to notify** — DM alerts are envelope-only, so
|
||
Amber is never prompted just to say "you have a message"; **(2) per-source
|
||
silent PRIMING + a seen-id set** (`skatespots:notifications:seen`, `×600`) so
|
||
opening the app never replays history as a flood; **(3) suppressed while the
|
||
document is visible** (the bell already covers that). Delivery is **local
|
||
only** — a fully closed app/browser is unreachable without a server push.
|
||
**Phase 1b — scheduled reminders (2026-09-29):** `lib/reminders.ts` +
|
||
`useLocalReminders` hand the OS an alarm 1 h before each upcoming event the
|
||
user RSVP'd going/interested. Native-only; the hook is mounted from the bridge
|
||
only when `prefs.reminders` AND `isNativePlatform()`. Because the **OS holds
|
||
the alarm it fires with the app fully closed** — the only closed-app
|
||
notification achievable without a server. Scheduled ids live in
|
||
`skatespots:notifications:reminders`; the hook cancels them all on unmount
|
||
(opt-out / logout). New pref key `reminders` (default on). Pro-expiry reminder
|
||
deliberately skipped — it would pull in the heavy `useSasquatchPro` query.
|
||
**❌ Phase 2 (background runner) is DEAD — don't attempt it
|
||
(verified 2026-09-29):** Capacitor's Background Runner supports `fetch` but
|
||
**NOT `WebSocket`** (v8 docs), and Nostr relays are WebSocket-only, so a
|
||
runner physically cannot query relays while backgrounded. On-device background
|
||
relay polling is not possible. Closed-app DMs/invites therefore need either
|
||
**(a)** a push gateway (FCM; a server learns *metadata* "this pubkey has
|
||
something" — content/sender stay hidden by NIP-17, timestamps are randomised)
|
||
or **(b)** a foreground service keeping a native WS alive (no server, but a
|
||
permanent notification + battery + custom native code). **Owner decision
|
||
(2026-09-29): HOLD at Phase 1 + 1b** — the zero-server design stands and
|
||
closed-app DMs/crew are a documented limitation, revisited only if it becomes
|
||
load-bearing. `useResolvedDmRelays` is exported for the envelope hook.
|
||
- **Push service built — the HOLD is lifted in principle (2026-10-01).**
|
||
Proven end-to-end in a local harness (marked wrap → relay → **keyless**
|
||
watcher → Web Push → OS toast with the tab closed → tap opens the app),
|
||
then built as **`push-service/`** — our own Bun service (NOT Linky's; no
|
||
Effect/linkstr dep) with `web-push`. ⚠️ **Never use Linky's
|
||
`["linky","push"]` marker** — ours is `["skatespots","push"]` on the
|
||
**recipient copy only** (plaintext on the wrap; unpushed wraps are ignored).
|
||
Endpoints: `GET /health` · `GET /vapid-public-key` · `POST /auth/challenge`
|
||
→ `POST /subscribe`/`/unsubscribe` (NIP-98 kind-27235 proof with a
|
||
**one-time** `challenge` tag). State = JSON file; VAPID keys from env.
|
||
Wired like the other standalone servers: `typecheck:push` +
|
||
`check:push` (both in `npm test`), `deploy:push`
|
||
(`scripts/deploy-push.sh`, generates VAPID keys **on the host**), Caddyfile
|
||
+ systemd unit in `push-service/`. ⚠️ **Relay overlap is the trap:** the
|
||
marked wrap must land on a relay the service watches — designate the
|
||
community relay as the **notify relay** and publish push-marked recipient
|
||
copies there. ⚠️ Privacy: the server sees **metadata only** (pubkey ↔ push
|
||
endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **Client
|
||
half done:** `public/push-sw.js` (service worker), `src/lib/pushService.ts` +
|
||
`src/hooks/usePushNotifications.ts` (register + subscribe with a NIP-98
|
||
challenge proof), a **Background notifications** toggle in
|
||
`NotificationSettingsCard`, and `useSendDirectMessage` now marks the
|
||
**recipient** wrap `["skatespots","push"]` **and adds the community relay as
|
||
the notify relay**. The service **also pushes marketplace events** addressed
|
||
to subscribers — offers (`30403`, the whole negotiation), requests (`31872`),
|
||
confirms (`31871`) — via `#p` filters built from the live subscription set
|
||
(so it only sees events for subscribers; the payload is a **generic body**,
|
||
never the amount/item). ✅ **DEPLOYED (2026-10-01):** `npm run deploy:push` →
|
||
the CVM host, systemd unit `skatespots-push`
|
||
(`/home/ubuntu/skatespots-push`; VAPID keys generated **on the host** into
|
||
`.env`). Public TLS: `push.skate-spots.org` (Netlify DNS **A → 185.18.221.2**,
|
||
and a site block **appended** to the host's `/etc/caddy/Caddyfile` — NEVER
|
||
overwrite it, it also serves `ai.skate-spots.org`). Verified
|
||
`https://push.skate-spots.org/health` → 200. ⚠️ 000 from this box = a **stale
|
||
local resolver cache** (NXDOMAIN); confirm with
|
||
`curl --resolve push.skate-spots.org:443:185.18.221.2 …`. **Remaining:**
|
||
native closed-app push. ⚠️ **Google/FCM is REJECTED — owner decision
|
||
(2026-10-01).** The FCM/`@capacitor/push-notifications` work was reverted
|
||
(commit `68c12a2`); do NOT re-add Firebase/google-services. Web push
|
||
(VAPID) is live and unaffected. For the APK the only no-Google options are:
|
||
**(a) UnifiedPush** (FOSS distributor the user installs — e.g. ntfy /
|
||
NextPush) or **(b) a native background poll** of our own service. **Chosen:
|
||
(b), the Cordn pattern** — Cordn (`Cordn-msg/cordn-web`, package
|
||
`cordn-background`) does **native background polling** via a custom Capacitor
|
||
plugin (Kotlin + Rust/UniFFI) with a `standard` (WorkManager ~15 min) and a
|
||
`fast` (foreground service ~5 min) mode, polling its **ContextVM** coordinator
|
||
and posting local notifications — **no Google**. We need **less**: our
|
||
notifications are already content-free, so no Rust/decrypt is needed.
|
||
**Server side DONE + verified (2026-10-01):** `push-service` has
|
||
`/native/poll/register` (NIP-98 → poll token; the pubkey is added to the relay
|
||
watch), `GET /pending?token&since` → `{ seq, at, changed }` (a content-free
|
||
cursor), and `/native/poll/unregister`; `pushTo()` calls `recordActivity()`.
|
||
**Plugin WRITTEN (2026-10-01):** `packages/skatespots-background/` — a local
|
||
Capacitor Android plugin (Kotlin: `SkateSpotsBackgroundPlugin`, `PollWorker`
|
||
[WorkManager], `PollService` [foreground], `Poller`, `Notifier`, `PollPrefs`)
|
||
plus `src/hooks/useBackgroundDelivery.ts` and a **Background delivery
|
||
(Android)** control in `NotificationSettingsCard` (Off / Standard / Fast).
|
||
The app registers it **by NAME** via `@capacitor/core` — it does **not**
|
||
import the package (no web-bundle dep); only the Android shell does, via the
|
||
`file:` dep + `cap sync`. ⚠️ **UNVERIFIED — needs `cap sync` + a device.**
|
||
`android/` is regenerated, so the `file:` dep must stay in `package.json` for
|
||
`cap sync` to add the module; `build.gradle`/manifest may need tuning per the
|
||
app's Capacitor version.
|
||
**Category-aware (2026-10-01):** `/subscribe` and `/native/poll/register`
|
||
carry `categories`; the server stores them per subscription/token and only
|
||
pushes matching ones (DM gift-wrap → `dms`; offer/counter/accept/attestation/
|
||
shipment → `trades` — `social`/`crew`/`newsletter` are **in-app only**, never
|
||
pushed). `/pending` uses a **per-category activity** breakdown, so it only
|
||
moves for the token's categories. `NotificationSettingsCard` **re-sends** the
|
||
choices on toggle change (`push.resubscribe()` / `background.resubscribe()`).
|
||
⚠️ An **absent** `categories` = legacy (allow all); an **empty array** = none.
|
||
⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers
|
||
defer it); `fast` needs a sticky notification. Never the only path. (Approach
|
||
+ tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.)
|
||
**Categories PUSHED (2026-10-01, supersedes the line above):** `dms`,
|
||
`trades`, `crew` (invite 36229 / join-request 31969), and `social` (zap 9735 /
|
||
reply-mention 1 / comment 1111) via `CATEGORY_BY_KIND`. **Reactions/reposts
|
||
are NOT pushed** (too noisy) and **newsletter has no push channel** — those
|
||
stay in-app. The **master switch off tears down push + the native poller**
|
||
(`toggleEnabled(false)` calls `push.disable()` + `background.disable()`).
|
||
- **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS
|
||
delivery coordinator** exposed as a **ContextVM server** over Nostr relays
|
||
(E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is
|
||
a **Cordn client only** — we do **not** embed a coordinator in the APK and we
|
||
don't run the default one (liability/centralisation — deliberate). Formats are
|
||
taken from Cordn itself, not invented: a coordinator is an **`nprofile`**
|
||
(pubkey + relay hints); its canonical add-link is
|
||
**`https://cordn.net/chat/coordinators?c=<nprofile>`** (Cordn
|
||
`packages/server/src/main.ts`); default relay **`wss://relay.contextvm.org`**;
|
||
self-host is the `docker run … ghcr.io/cordn-msg/cordn:latest` one-liner.
|
||
Files: `src/lib/coordinator.ts` (parse/encode/add-url/name),
|
||
`src/hooks/useCoordinator.ts` (device-local store key `skatespots:coordinator`
|
||
+ `useCoordinatorHealth`), `src/components/CoordinatorSettingsCard.tsx`,
|
||
**Settings → Coordinator** (`?section=coordinator`). Health is a **presence
|
||
probe, not Cordn RPC**: query the coordinator's relays for `authors:[pubkey]`
|
||
(its ContextVM announcement/heartbeat) — so an **unannounced/private
|
||
coordinator honestly shows "Not seen"**. "Run your own" is a **hand-off**
|
||
(link to Cordn + copy Docker + scan its QR), never an embedded runtime — the
|
||
phone-hosting story belongs to Cordn's own app. ⚠️ `CORDN_APP_URL` currently
|
||
points at `cordn.net`; the exact Android-app deep link is unconfirmed — fill
|
||
it in when known.
|
||
**Profile discovery (built 2026-09-29):** the carrier is the existing kind-0
|
||
**`cordn`** field (app convention, documented in NIP.md — not a new kind).
|
||
`EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
|
||
http URL), **normalises a bare pointer to Cordn's canonical add-link** before
|
||
saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`
|
||
renders it as a **Coordinator** row with a one-tap **Add** (sets the viewer's
|
||
`useCoordinator`, with toast) plus an "Open in Cordn" link — falling back to
|
||
the old plain-link row when the value doesn't parse.
|
||
⚠️ **STATUS (2026-10-13): the sealed-relay chat path described in this bullet
|
||
is now UNWIRED** — the crew **Chat** tab is always **NIP-17**, and coordinator
|
||
routing lives only in the crew's **Private chat** (MLS). `useCordnChat` +
|
||
`lib/cordn/{client,crypto}` + `useCrewChatUnread` are kept but unused (headers
|
||
marked); see `CREW_CHAT_AUDIT.md`. Do not re-wire the old path without reading
|
||
that. The rest of the bullet is the historical design.
|
||
**⚠️ Crew-level Cordn chat ALREADY EXISTS — do NOT rebuild it
|
||
(2026-09-29):** `src/lib/cordn/{client,crypto,endpoint,types}.ts` +
|
||
`useCordnChat.ts` + `CrewChat.tsx` + `crew/CrewCoordinatorCard.tsx`, driven by
|
||
a crew's **`coordinator` tag** `['coordinator', <url>, <coordinatorPubkey?>,
|
||
<operatorPubkey?>]` (`SkateCrew.coordinator`, parsed in `types.ts`,
|
||
`resolveCordnEndpoint()` → `CordnEndpoint`), documented in NIP.md ("Private
|
||
Crew Chat (Cordn / MLS)"). Every crew already has built-in NIP-17 E2E chat; the
|
||
tag *upgrades* it to sealed NIP-44 over ContextVM. **The only gap bridged this
|
||
session:** the crew form accepted a URL only, so a coordinator run from
|
||
Cordn's phone app (an nprofile, not a URL) was un-enterable — `CrewForm` now
|
||
uses the shared `parseCrewCoordinatorInput` / `crewCoordinatorInputValue`
|
||
(`lib/coordinator.ts`) and has a **"Use my coordinator"** button that prefills
|
||
from Settings → Coordinator. ⚠️ **Transport reality (do not overclaim):** a
|
||
crew's **`wss://` coordinator URL is used AS A RELAY** (`resolveCordnEndpoint`
|
||
→ `relayUrl`); an `https://` one falls back to the **public**
|
||
`wss://relay.contextvm.org`. The app publishes sealed kind-4497 events and
|
||
queries them back — it does **not** call Cordn's coordinator RPC
|
||
(`FetchGroupMessages`/`SubscribeGroupMessages`), so a real Cordn coordinator is
|
||
not yet engaged, and an `https://` coordinator puts ciphertext + metadata
|
||
(crew coord, timing) on a public relay. `CrewCoordinatorCard` + `CrewForm` now
|
||
say this honestly (2026-09-29). **Still future:** real MLS
|
||
(`ts-mls`) instead of group-key crypto, plus Cordn RPC interop. **Lesson: grep
|
||
`src` for the feature before building it — this was nearly duplicated.**
|
||
- **Cordn native — real MLS (Level 2 foundation, 2026-10-12):**
|
||
`src/lib/cordn/native/` is a **browser-safe Cordn client doing real RFC 9420
|
||
MLS** (not the sealed-relay stand-in above), mirroring Cordn's wire contract —
|
||
reimplemented, we do not copy `@cordn/cli`. Files: `mls.ts` (identity, key
|
||
packages, group create/add/join, application messages, the ChaCha20-Poly1305
|
||
coordinator wrapper, causal `prev` tags), `coordinator.ts` (ContextVM client
|
||
over `@contextvm/sdk`, validated with `@cordn/core` schemas), `session.ts`
|
||
(`CordnSession`: `generateKeyPackage`/`publishKeyPackage`/`createGroup`/
|
||
`addMember`→Welcome/`joinGroup`/`send`/`fetch`; persistence behind a
|
||
`CordnStore`, `MemoryCordnStore` today — IndexedDB adapter still TODO) and
|
||
`index.ts`. Deps added: `@cordn/core`, `@contextvm/mcp-sdk`, and
|
||
`@noble/ciphers` pinned **2.2.0** (the tree was `invalid`: `ts-mls` wants 2.2.0
|
||
while a stale root copy was 0.5.3). ⚠️ **Verified end-to-end against our own
|
||
coordinator** — `CORDN_LIVE=1 npx vitest run
|
||
src/lib/cordn/native/native.live.test.ts` does a **two-member round trip** (A
|
||
creates group + adds B → Welcome → B joins → A↔B messages encrypt/decrypt with
|
||
proven senders). ⚠️ That live test runs in the **node** vitest environment
|
||
(`// @vitest-environment node`) — jsdom's realm makes a `Uint8Array` fail
|
||
`instanceof` inside the SDK's nested nostr-tools/@noble/hashes; `src/test/setup.ts`
|
||
is now guarded with `typeof window !== 'undefined'` so node-env files collect.
|
||
**Not wired to any UI yet** — the app still uses the sealed-relay crew chat.
|
||
Next: IndexedDB store + a crew chat panel + the coordinator welcome/join-request
|
||
flow. Nothing imports the native module, so the web bundle is unchanged.
|
||
**Milestone 2 landed (2026-10-12):** the session now runs on the **viewer's own
|
||
signer** (NIP-07/Amber/nsec — no key held; `CordnSession.open({ pubkey, signer })`),
|
||
so the Cordn identity IS the user's pubkey. Added `idbStore.ts`
|
||
(`createCordnStore(pubkey)` — Dexie DB `SkateSpotsCordnDB`, one snapshot per
|
||
account, survives reload) and the **membership delivery** methods:
|
||
`requestJoin` / `fetchJoinRequests` / `fetchWelcomes` / `acceptWelcome` /
|
||
`addMemberByIdentifier` (+ auto `welcome_store` in `addMember`). **Verified live**
|
||
with a 3rd scenario (`join-request → approve → welcome`); ⚠️ `kp_take` accepts
|
||
the requester's **pubkey** (and a `kp_ref` also worked). UI:
|
||
`hooks/useCordnNative.ts` (opens/owns the session; **lazy-imports** the native
|
||
module so `@contextvm/sdk`+ts-mls land in their own ~678 kB async chunk, NOT the
|
||
1.9 MB entry) + `components/crew/CordnNativePanel.tsx`, rendered as a **third
|
||
"Private chat" tab** on the crew page (founder/admin create the group + approve
|
||
requests; members ask to join + check for an invite). Strings in
|
||
`i18n/legacy/cordnUi.ts`. ⚠️ The **React panel** isn't click-tested (no automated
|
||
login+crew flow); the native stack's browser runtime IS — see milestone 2b. For **interactive signers (Amber)**
|
||
polling is off (each ContextVM request would pop) — manual Refresh/Send, same
|
||
rule as the DM hooks.
|
||
**Milestone 2b (2026-10-12):** ① **browser runtime proven** —
|
||
`node scripts/cordn-browser-probe.mjs` serves `dist/`, drives headless Chromium
|
||
over CDP (same harness as `smoke-test.mjs`), imports the built native chunk, and
|
||
runs a real session against the coordinator (publish key package → create group
|
||
→ send), then reopens from the **Dexie/IndexedDB** store (1 group, 1 message
|
||
restored). It is NOT in `npm test` (needs network + Chromium). ⚠️ The barrel
|
||
chunk exports its namespace under a mangled name, so the probe picks
|
||
`ns.CordnSession ? ns : ns.a`. ② **Group ref sharing** — the panel shows the
|
||
`cordn1…` ref (`encodeGroupRef({ gid: crew coordinate, coordinatorPubkey,
|
||
relays })`, re-exported from the native barrel; computed in the hook) with a
|
||
**Copy** button, so the group can be opened in Staircase/cordn-web. ③ **Images**
|
||
— the composer has an attach button (`useUploadFile` → Blossom), and the URL
|
||
rides **inside** the encrypted MLS envelope (`['image', url]` tag),
|
||
`CordnChatMessage.imageUrl`, rendered with `ImageWithFallback`. Verified in the
|
||
live test (image URL decrypts to the other member). ⚠️ **Unread badge for the
|
||
tab is deliberately NOT built** — it needs a background signed request (pops on
|
||
Amber) or a poll; revisit only if the feature gets real use. ④ **Tab promotion +
|
||
nudge** — the crew page defaults to the Private-chat tab when this device
|
||
already holds the group (`useCordnGroupExists` reads the IndexedDB snapshot:
|
||
local only, no signer), and a founder/admin banner ("Set up") points at the tab
|
||
when it doesn't. A manual tab click always wins (`tabTouchedRef`). ⑤ **Messages
|
||
hub entry (2026-10-12):** `DirectMessagesPage` has a **"Crews"** section listing
|
||
the viewer's crews (`useUserCrews`) with an Open/Set-up badge (from
|
||
`useCordnGroupGids` — one local IndexedDB read) linking into
|
||
`/crew/<pk>/<id>?tab=mls`. The crew page's tabs are now **URL-addressable**
|
||
(`?tab=events|chat|mls`; an explicit param wins over the auto-default). This is
|
||
the cheap "pointer" integration — the two transports are NOT merged (DMs stay
|
||
NIP-17; the coordinator is only the crew MLS group).
|
||
**UX polish (2026-10-27):** after "Ask to join" the panel auto-checks the
|
||
coordinator every 10s→30s (silent signers only; Amber stays manual), and stops
|
||
when the welcome lands. The empty state has 3 plain steps + "only crew members
|
||
can read this"; Cordn/group ref/key package moved under **Advanced**. The
|
||
status chip is actionable (Retry), failures show a plain message + details.
|
||
A device with no key package gets **"Set up this device"** (`requestJoin`
|
||
publishes a fresh package). The member list shows avatars + founder/admin/
|
||
member roles; join requests load on open and show avatars. Messages show
|
||
Sending…/Sent/Failed with **Retry** (optimistic pending bubble). Invite opens
|
||
a dialog with QR + copy/share and a `?tab=mls` link; on native it uses
|
||
`https://skate-spots.org` (not `https://localhost`). The founder banner is
|
||
dismissible per crew (`skatespots:cordn-banner-dismissed:<pk>/<id>`).
|
||
`useCordnNative` adds `retry`, `keyPackageReady`, boolean
|
||
`send`/`createChat`/`requestJoin`/`approve`, and **filters welcomes by gid** so
|
||
one crew can't adopt another crew's chat. Coordinator announce relays are now
|
||
`["wss://relay.skate-spots.org"]` only (removed purplepag.es/nostr.band/nos.lol
|
||
timeouts; logs show `local[10002=ok]`). Advanced also exposes the Cordn share
|
||
link `https://cordn.net/chat/<cordn1…>` (open/copy). Per Cordn's group-ref
|
||
spec the `cordn1…` ref is a **share** primitive: a client can publish a key
|
||
package and submit a join request, then the founder approves. The reliable
|
||
"join us" link for normal users is still
|
||
`https://skate-spots.org/#/crew/<pk>/<id>?tab=mls`; use the Cordn link for
|
||
Staircase/Cordn clients. ⚠️ cordn.net's web client still stalls after NIP-42
|
||
(upstream issue), so it is not the primary onboarding path yet.
|
||
- **SkateSpots Community room (2026-10-27):** `/community` is a standalone
|
||
`CordnRoom`, not a crew. Its gid is the stable literal `skatespots:community`
|
||
(no owner key in the gid), so the bot key can rotate without changing links.
|
||
Owner/`crew.pubkey` = the bot `f978…f0`, admins = `ADMIN_PUBKEYS` + bot. The
|
||
page passes `canCreate={false}`: everyone (admins included) uses the normal
|
||
"Ask to join" flow; the bot auto-approves. After joining, admins still get the
|
||
requests panel as a fallback. Links: `/community`, **`/c`** and **`/cordn`** all redirect into
|
||
`/#/community` (the old Cordn-web shortcuts were retired 2026-10-29 because
|
||
Cordn web stalls after NIP-42). The raw `cordn1…` reference is still
|
||
copyable from Advanced for Staircase/Cordn clients; the external open link
|
||
was removed.
|
||
Entry points: nav catalog/burger + About feedback card.
|
||
- **Join UX (2026-10-29):** community-specific approval copy names the bot or
|
||
admins, an admin pending-request banner links to the room's request panel,
|
||
and waiting users get **Request again** plus the second-device hint (use
|
||
**Link device** for a direct handoff, or ask to join again with the new
|
||
device; the earlier “already in the group” error was a stale request for the
|
||
same key package, not a same-account limitation).
|
||
If the bot is missing from the group, the panel warns that an admin must
|
||
review requests.
|
||
- **Bot:** `scripts/community-bot.ts`, built to CJS with
|
||
`scripts/build-community-bot.sh` (Node 20 needs the `ws` global shim), run by
|
||
`scripts/systemd/skatespots-community.service` on the relay box:
|
||
`/opt/skatespots-community/community-bot.cjs`, env
|
||
`/etc/skatespots/community.env` (600), state
|
||
`/var/lib/skatespots-community/state.json` (600). It created the room once
|
||
with `COMMUNITY_CREATE=1`; **leave `COMMUNITY_CREATE=0` afterward** — a lost
|
||
state plus create=1 would fork the room. It polls every 15s, auto-approves
|
||
up to 10 requests/cycle, skips its own request, and backs off failed
|
||
approvals for 5 minutes. **2026-10-29 fixes:** it prefers the request's
|
||
`kp_ref` (fallback pubkey), retires handled/stale requests through
|
||
`join_request_take_many`'s `consumed` list (the old bot left approved
|
||
requests in the queue and retried "already in the group" forever), logs
|
||
pending counts, and stops auto-approval at **250 members** /
|
||
**5 devices per pubkey**, and **30 approvals/hour**
|
||
(`COMMUNITY_MAX_MEMBERS`, `COMMUNITY_MAX_DEVICES_PER_PUBKEY`,
|
||
`COMMUNITY_MAX_APPROVALS_PER_HOUR`). The bot must be rebuilt with
|
||
`scripts/build-community-bot.sh` and copied to
|
||
`/opt/skatespots-community/community-bot.cjs` when this file changes —
|
||
**`scripts/deploy-community-bot.sh`** does the build, remote backup,
|
||
install, restart and local state-backup refresh in one step (host alias
|
||
`skatespots-cvm`; `--no-build` reuses `build/community-bot.cjs`).
|
||
- **Secrets/backup:** bot key in `~/.config/skatespots/community.env` (600)
|
||
and the VPS env; state copy in
|
||
`~/.config/skatespots/community-state.json` (600). Never commit/print either.
|
||
**Daily backup (2026-10-29):** `scripts/backup-community-state.sh` pulls the
|
||
live state, keeps 30 daily snapshots in `~/.backups/community`, and pushes
|
||
the directory to restic (tag `community-state`) when
|
||
`~/.config/skatespots/restic.env` exists. Installed by
|
||
`scripts/install-community-backup-timer.sh` as user timer
|
||
`skatespots-community-backup.timer` (04:17, Persistent). `ops-health.sh`
|
||
reports both the bot service and the timer.
|
||
- **Copy:** `communityUi.*` (en/es + all 24; 100%); bot rows are labeled
|
||
"Community bot" by pubkey, no profile event required.
|
||
- **Cordn native devices/members/benchmark (2026-10-28):** a new device can
|
||
publish a key package and show a `cordn-device:` QR; an existing member scans
|
||
it (`QRScannerModal`), calls `addMemberByIdentifier(gid, kpRef)`, and the new
|
||
device's `checkInvite` accepts the Welcome — no join request/founder round
|
||
trip. Member leaves can be removed by `leafIndex` (`removeMemberFromGroup` →
|
||
`createCommit` with `defaultProposalTypes.remove`). **Inline promote/demote
|
||
(2026-10-28):** the founder gets a shield button on each non-founder member row;
|
||
it republishes the **existing crew event tags** with the target's `p` tag
|
||
switched between plain and `admin` (only the founder can do this — an admin's
|
||
republish would fork the crew). Messages have copy/reply, **reactions
|
||
(👍/❤️/🔥/😂)**, **delete-for-everyone**, and **read receipts**. These are
|
||
encrypted MLS **control messages** (empty content + `reaction`/`unreact`/
|
||
`delete`/`read` tags) sent via `sendControl`; `useCordnNative` filters them out
|
||
of the visible list and derives reaction/read maps. Delete is a local
|
||
tombstone, not a relay hard-delete; automatic read receipts are silent-signer
|
||
only (Amber would prompt). Live benchmark (`scripts/cordn-benchmark.ts`, 30
|
||
msgs): ~489ms/send, ~20ms/fetch, snapshot ~13.8KB (~460 B/message).
|
||
- **Cordn room UX + resilience (2026-10-29):** `/messages` has a **Private
|
||
rooms** section with Community + every crew MLS room, Open/Set up state, and
|
||
local unread dots (`hooks/useCordnRoomRead.ts` + `lib/cordnRoomRead.ts`; only
|
||
rooms actually opened record `latestAt/seenAt`, so a dot is never invented).
|
||
The panel has per-room mute (`useDmMute` key `cordn:<gid>`), in-room search,
|
||
Chat-vs-Private guidance, coordinator **last checked**, and confirmations for
|
||
approve/remove. Failed sends persist in a per-account/room device-local outbox
|
||
(`lib/cordnOutbox.ts`) and auto-retry up to 3 times when online, then stay for
|
||
explicit Retry/Discard. Background unread for rooms never opened still needs a
|
||
global poller; there is deliberately no background decrypt.
|
||
- **Cordn join-request push (2026-10-28):** `scripts/cordn-join-watcher.ts`
|
||
runs every minute (user timer) and polls the coordinator for known crew gids.
|
||
New requests become (a) a **NIP-17 DM** to the founder/admins (push service
|
||
wakes the phone) and (b) an **encrypted NIP-78** notice (`t=cordnjoinrequest`,
|
||
authored by the dedicated watcher key `82ce71cf…`). `useCordnJoinNotifications`
|
||
decrypts it; the bell's Crews/All tabs show a `CordnJoinRequestCard` with
|
||
Review/Dismiss, and count it by `seenAt`. Key `~/.config/skatespots/cordn-watcher.env`
|
||
(600); state `~/.local/state/skatespots-cordn-watcher/state.json`. Crews with a
|
||
**custom coordinator** are skipped (only the SkateSpots embedded coordinator is
|
||
queried); approved requests leave the coordinator but the bell card remains
|
||
until dismissed.
|
||
- **Map site-links strip (2026-10-05; mobile bar 2026-10-29):** the desktop footer
|
||
is hidden on the map (full-bleed), so `components/MapFooterStrip.tsx` surfaces
|
||
the same links (DIY/Newsletter/License/Privacy + "not sponsored by OpenSats")
|
||
as an overlay inside the map container. Desktop is a slim full-width bar;
|
||
mobile is now a **slim always-visible strip above the bottom nav** (the old
|
||
floating “Links” chip in the centre was removed — it collided visually with
|
||
the Events/Crews nav items and its popover looked broken).
|
||
**Always visible (2026-10-28):** the old dismissible `useMapFooterHidden`
|
||
behavior/setting was removed — it was easy to hide the only footer on the map
|
||
forever and then report it missing. The map's OpenStreetMap attribution moved
|
||
to `md:bottom-7` so the bar doesn't cover it.
|
||
- **Map layout — controls float (2026-09-29):** on mobile the `MapPage` toolbar
|
||
is **`absolute` over the map** (`pointer-events-none` wrapper + gradient +
|
||
`pointer-events-auto` inner) so the map fills the whole container; `md:static`
|
||
restores the in-flow bar on desktop. The container height subtracts
|
||
**header + bottom nav only** (the old desktop `--footer-desktop` subtraction is
|
||
gone) and the **desktop footer is hidden on full-bleed routes** (`Layout`
|
||
footer: `isFullBleed ? 'hidden' : 'hidden md:block'`). Result at 390×844:
|
||
map starts under the header and is **~86% of the viewport** (was ~78%). Don't
|
||
re-add a stacked toolbar or the footer subtraction. The "New here?" welcome
|
||
banner (logged-out only) still occupies ~69px *in flow* on first visit — it's
|
||
dismissible and the dismissal is persisted (`skatespots_welcome_dismissed`).
|
||
⚠️ **The banner MUST be the first child of the map container, ABOVE the
|
||
toolbar** (fixed 2026-10-01). The toolbar is `absolute top-0` inside the same
|
||
container, so as a later sibling the banner sat at the container's top and the
|
||
floating toolbar **painted over it** (owner screenshot: search bar covering the
|
||
banner). Now the banner is first and the toolbar **drops into flow**
|
||
(`showWelcomeBanner ? 'relative border-b bg-black/95' : 'absolute …gradient'`)
|
||
so it can't cover it; `md:static` is unchanged on desktop.
|
||
⚠️ The floating toolbar sits over Leaflet's default **top-left zoom control**,
|
||
so `MapPage` passes `className="h-full ss-map-floating-toolbar"` and
|
||
`index.css` offsets `.leaflet-top.leaflet-left` by `4.25rem` **only under
|
||
`max-width:767px`** (desktop toolbar is in flow, no offset). Verified at
|
||
390×844: zoom top 135px vs search bottom 108px.
|
||
- **Signup & onboarding UX pass (2026-10-01):** hardening the two highest-stakes
|
||
first impressions.
|
||
- **Signup is one fork.** `LoginArea` also rendered a `SignupMethodDialog`
|
||
("Recovery Phrase vs Secret Key") + `MnemonicSignupDialog`, but
|
||
**`setSignupMethodDialogOpen(true)` existed nowhere** — the whole method
|
||
branch was DEAD code (unreachable). Removed the wiring (files kept on disk);
|
||
the misleading `nsecSignupDialogOpen` state was renamed `signupDialogOpen`.
|
||
`SignupDialog` (`welcome → profile → done`) is the only signup path.
|
||
- **`SignupDialog` gained a step indicator** (`STEP_ORDER`/`STEP_LABELS`:
|
||
Choose → Profile → Save key) and **makes backup a confirmation, not a skip**:
|
||
a checkbox ("I've saved my recovery code…") enables "Let's skate"; the
|
||
Download button also sets it. The old co-equal "Skip for now, skate" is now a
|
||
quiet text link ("I'll save it later from Settings"). Welcome copy no longer
|
||
claims "nothing to remember" (it contradicted step 3), the profile step warns
|
||
a signer may ask once, and non-Android copy points at signer apps instead of
|
||
implying a recommendation.
|
||
- **Onboarding adds "Where do you skate?"** (`OnboardingPage`): for a logged-in
|
||
skater with no cached fix, "Use my location" calls `getCurrentPosition()`
|
||
(which also `saveLastLocation()`s), so `MapPage`'s mount-time
|
||
`getLastLocation()` opens centered on their area. The logged-out wallet
|
||
section's dead disabled "Log in first" button became a plain line.
|
||
- **Desktop welcome sign-in modal (2026-10-26):** `Layout` opens the themed
|
||
`LoginDialog` **once** for a logged-out **desktop** visitor (non-native,
|
||
`min-width: 1024px`, after ~900 ms) as a welcome/sign-in offer; dismissal is
|
||
remembered in `sessionStorage['skatespots:desktop-welcome-snoozed']` (per
|
||
session, not forever — a single ✕ must not suppress it permanently), and it
|
||
also offers the
|
||
normal `SignupDialog`. It reuses the existing methods (browser extension,
|
||
bunker/signer, Amber on Android, nsec, and the **NostrConnect QR**).
|
||
**3-card restyle (2026-10-26):** `LoginDialog` now leads with a **three-card
|
||
chooser** — Browser extension / Signer (Bunker on desktop, Amber on Android) /
|
||
**Sign in with your phone** (NostrConnect) — plus a quiet **Secret Key** link;
|
||
the cards are a **controlled tab chooser** (`loginTab` state) over the existing
|
||
panels, and the NostrConnect QR moved from the bunker tab into its own
|
||
`connect` panel. **Compact pass (2026-10-26):** the dialog is now
|
||
scroll-safe (`flex flex-col` + `max-h-[calc(100dvh-2rem)]`, content
|
||
`min-h-0 flex-1 overflow-y-auto` — it used to clip the bottom links), the
|
||
New-here block is a compact row (one dominant action per state), bunker
|
||
instructions are a collapsed `<details>` ("Where do I get a bunker?"), and the
|
||
secret-key option is a quiet bottom link. Labels are plain ("Browser
|
||
extension · Signer app · Sign in with your phone") with **NIP-07/46/55** as
|
||
tiny hints (`settingsUi.loginDialog.{extensionCard,signerCard,secretKeyLink,
|
||
whereBunker,welcomeSubtitleBrowse}`; backfilled to all 20 locales). Spacing is
|
||
an **8px grid**: `px-5` (20px) gutters, `space-y-4` (16px) between sections,
|
||
`gap-2` (8px) between cards, `p-2.5` (10px) card padding, `pt-5/pb-3` header —
|
||
a 448px max width keeps 3 cards ≥~120px each while staying a single focused
|
||
column. Verified with headless 1280×900 and 390×844 screenshots.
|
||
⚠️ `SignupDialog` is a **default-only** export, so
|
||
lazy-load it as
|
||
`lazy(() => import('…/SignupDialog'))`.
|
||
- **Pass the app on — Android only (2026-10-26):** the About page renders
|
||
`ShareAppCard` when `getPlatform() === 'android'` (APK/Zapstore; hidden on
|
||
web/iOS): a **QR of the Zapstore listing** (`APP_INSTALL_URL` in
|
||
`lib/platform.ts`, single-sourced), a **share-sheet** button (Nearby
|
||
Share/SMS/etc.), and `NfcWriteButton` to write the install URL to an NFC tag.
|
||
⚠️ Deliberately **no peer-to-peer APK transfer** — that needs native Wi‑Fi
|
||
Direct/BLE + a `PackageInstaller` flow and real-device QA; Zapstore handles
|
||
installs/updates. (Not visually verified on-device yet.)
|
||
- **First-run checklist + backup-reminder gating (fixed 2026-10-12).** Two
|
||
onboarding prompts nagged returning users:
|
||
1. `FirstRunChecklist` (map) showed for **any logged-in user** until they
|
||
tapped ✕ — returning skaters saw it forever. It now requires a **recent
|
||
`signup_completed` stamp** (set by the signup flow — imported/older
|
||
accounts never match) **and** `first-run-seen` (set once, on first show;
|
||
captured at mount so it still shows that session but never again).
|
||
2. The `SignupDialog` marked backup done with
|
||
`setBackupConfirmed(true)` keyed on `useCurrentUser().pubkey` — but `user`
|
||
is **still settling mid-signup**, so it wrote `backup-confirmed:anon` and
|
||
the post-signup `BackupReminder` (real pubkey) never learned it → it nagged
|
||
**every session**. Now `markBackupConfirmed(pubkey)` (in `lib/backup.ts`,
|
||
mirrors the hook's JSON encoding) is called with the pubkey **derived from
|
||
the freshly minted nsec** (`getPublicKey(sk)` captured in
|
||
`createAccountSilently`). ⚠️ Any new "backup done" write must use the real
|
||
pubkey, not `user?.pubkey` during signup. (The NIP-49 export password field
|
||
also switched `autoComplete` `new-password` → `off` to cut the browser's
|
||
save-password prompt.)
|
||
- **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the
|
||
existing "No spots match" card is for *filters* (`filteredSpots.length === 0 &&
|
||
allLocations.length > 0`) and a new "No spots mapped yet — be the first" card
|
||
covers a genuinely empty map (`allLocations.length === 0`) with Add-spot +
|
||
Near-me CTAs. ⚠️ Don't key it off `mapSpots`: that is derived FROM
|
||
`filteredSpots`, so it is 0 whenever filters hide everything.
|
||
- **Report inbox + bulk undo + crews/Lab moderation (2026-09-28):** finishing the
|
||
moderation gaps. `/review` now defaults to a **Reports** tab: `useRecentReports`
|
||
fetches recent NIP-56 (1984) reports and **resolves the referenced event by id**
|
||
(one extra `ids:` query) so ANY report is actionable — including content that
|
||
isn't flagged and comments/profiles that never appear in the heuristic queue.
|
||
`reportTargetMeta(kind)` maps a kind to its label + route for the "Look at it"
|
||
link. The queue also has **crews** and **lab** tabs (added `includeHidden` to
|
||
`useCrews`/`useLabProfiles`); because hiding those removes a community or a
|
||
person's listing rather than one pin, their Hide button goes through an
|
||
`AlertDialog` confirmation (`confirmHide`/`confirmHideNote` on `QueueList`).
|
||
When a tab holds more than one verdict, a toolbar offers **Undo all N** (also
|
||
AlertDialog-gated, and it warns that each undo is a separate label publish —
|
||
one Amber approval each). Verified in the browser: 8 tabs render, the inbox
|
||
shows and resolves a real report, and the Spots tab shows the bulk-undo toolbar.
|
||
- **App listing submitted (2026-09-28):** SkateSpots was added to the community
|
||
**nostrapps.com** directory by PR. Recipe (ngit has an active remote-signer
|
||
session, so this works non-interactively — no browser needed):
|
||
`git clone https://relay.ngit.dev/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrapps-com.git`
|
||
→ edit `apps.toml` (one `[app-id]` table; keys seen in the file: name,
|
||
categories, description, gallery?, features, npub, platforms, source?, thumb,
|
||
url) → `git remote add nostrapps nostr://<owner-npub>/nostrapps-com` →
|
||
`git push -o 'title=…' -o 'description=…' -u nostrapps <branch>:pr/<branch>`.
|
||
Opening a PR = pushing a `pr/`-prefixed branch; `ngit pr list|view|apply`.
|
||
**Our PR: `bbb27a91…` "Add SkateSpots", open.** The gallery images live at
|
||
`public/images/screenshots/{map,spot,community}.png` (1280×800, quantised —
|
||
captured with headless Chromium; regenerate with a CDP harness if the UI
|
||
changes materially).
|
||
- **Login & signup — NIP-49 + NIP-46 nostrconnect (2026-10-01):** two Nostr-native
|
||
auth upgrades (owner picked these two; read-only/npub sessions were explicitly
|
||
declined).
|
||
- **NIP-49 `ncryptsec` — `src/lib/ncryptsec.ts`.** A password-encrypted key,
|
||
used BOTH as a login method (LoginDialog → Secret Key tab → "Encrypted
|
||
(NIP-49)": paste `ncryptsec1…` + password → `decryptNcryptsec` →
|
||
`login.nsec`) AND as the signup backup (done step → "Save an encrypted
|
||
backup" → password → downloads a password-protected file — a safer artefact
|
||
than a raw nsec). Thin wrapper over `nostr-tools/nip49` (`import * as nip49
|
||
from 'nostr-tools/nip49'` — **not** exported from the package root; log_n =
|
||
16). Verified in Node: round-trips, wrong password rejected by the AEAD,
|
||
non-`ncryptsec1` input rejected, empty password rejected.
|
||
- **NIP-46 `nostrconnect://` QR — `src/lib/nostrConnect.ts`.** The missing
|
||
"log in on desktop with the key already on your phone" flow. ⚠️ **Nostrify
|
||
does NOT implement this direction** — it only has `NLogin.fromBunker` /
|
||
`NConnectSigner`, where the client already knows the signer pubkey and sends
|
||
`connect`. nostrconnect is the reverse: the SIGNER sends the first `connect`
|
||
request to the client's ephemeral pubkey and the client must reply with the
|
||
shared secret. `startNostrConnect(nostr, …)` mints an ephemeral keypair +
|
||
secret, builds `nostrconnect://<clientPub>?relay=…&secret=…&name=…&url=…&image=…`,
|
||
subscribes to kind `24133` `#p:[clientPub]`, and on the signer's `connect`
|
||
replies `{id,result:secret}` — signed with the **ephemeral** key
|
||
(`finalizeEvent` + `nostr.relay(url).event(signed)`; the pool's signer is the
|
||
logged-out user, so it must NOT be used). It then hands the derived
|
||
`bunker://` URI to the existing `login.bunker()` path so the session reuses
|
||
well-tested code. ⚠️ **Transport encryption: try NIP-44, fall back to
|
||
NIP-04**, mirroring whichever the signer used (NIP-46 moved NIP-04 → NIP-44;
|
||
support varies). Default relays `wss://relay.nsec.app`,
|
||
`wss://relay.damus.io`, `wss://nos.lol`. UI: LoginDialog → Bunker tab leads
|
||
with the QR; the `bunker://` paste stays as the fallback. ⚠️ **NOT verified
|
||
against a real signer — needs an on-device check with Amber** (scan → pair →
|
||
signed in); only the URI shape was verified in Node.
|
||
- **Mobile grid overflow gotcha (2026-10-04):** a Tailwind grid **must** declare a
|
||
base column — `grid grid-cols-1 md:grid-cols-2`, **not** `grid md:grid-cols-2`.
|
||
Without the base, mobile renders one implicit `auto` track that can exceed the
|
||
container, so a long name (e.g. a `/lab` `ProfileCard`, or `/stats` Top Mappers)
|
||
pushes the whole card off the right edge and clips the values. Fixed repo-wide;
|
||
also add `min-w-0` to truncating flex/grid children. `/stats` also gained a
|
||
**Top Shops** leaderboard (`topShops` in `useGrowthStats`; `PERSIST_BUSTER` → v6)
|
||
alongside Top Mappers (spots).
|
||
- **Stretched-link card gotcha (2026-10-12):** `SpotCard` renders its whole-card
|
||
navigation as an absolutely-positioned `<Link className="absolute inset-0">`
|
||
behind the content. It **must** be `z-10` (not `z-0`), and the card must not carry
|
||
`backdrop-blur` — a `z-0` link under the content, with `backdrop-blur` making the
|
||
card its own paint layer, meant **no click ever reached the link and the card
|
||
looked dead**. Hover moved to the wrapper (`group` on the wrapper + `group-hover:`
|
||
on the card/image), and the card's action row is `z-20` so its buttons stay above
|
||
the link. Keep this shape when editing card components.
|
||
- **Dropdown menu scroll gotcha (2026-10-08):** the shared `DropdownMenuContent`
|
||
(`components/ui/dropdown-menu.tsx`) shipped `overflow-hidden` with **no
|
||
max-height**, so any menu taller than the viewport was clipped and could not
|
||
scroll to its last items (the language picker, now 22 locales, was cut off at
|
||
the bottom). It now carries
|
||
`max-h-[var(--radix-dropdown-menu-content-available-height)]` +
|
||
`overflow-y-auto overflow-x-hidden` (canonical shadcn). Radix exposes that var
|
||
but nothing applies it. Keep it if you add more long menus. The language picker
|
||
(`LanguageToggle`) also gained a **search input** in both the header dropdown
|
||
(sticky above the scrolling list; input stops `keyDown`/`pointerDown`
|
||
propagation so Radix typeahead does not hijack typing) and the inline/mobile
|
||
variant; keys `nav.searchLanguages`/`nav.noLanguages` live in en/es only (other
|
||
locales fall back). The Settings `LanguageSelector` grid is searchless by design.
|
||
- **Menu audit (2026-10-27):** rendered EN/DE menus at 390×844 and 1280×900.
|
||
Alignment was already start-aligned with no clipped labels. Fixed the real
|
||
problems: the mobile burger's inline 28-language list made the sheet
|
||
**3,168px** tall and buried the picker — it is now a **Language row + dedicated
|
||
sub-view** (Back/search/list), cutting the main sheet to ~1,384px. Language
|
||
dropdown rows are `min-h-11`; the mobile user menu is 44px; desktop More is
|
||
40px. Language search only auto-focuses for `pointer: fine` (no phone keyboard
|
||
covering the list). Crew tabs use `1.1fr / 1fr / 1.3fr` + 13px labels so
|
||
“Private chat”/“Privater Chat” no longer truncate. Re-check with
|
||
`node scripts/smoke-test.mjs` + a menu screenshot if you change the header.
|
||
- **Community stats (`/stats`, 2026-10-01):** `useGrowthStats` + `CommunityStatsPage`.
|
||
- ⚠️ **A relay outage used to render a board of ZEROS as truth** — every
|
||
sub-query ended in `.catch(() => [])`. The queryFn now counts failures (a
|
||
tracked `querySkateContent` wrapper) and returns **`partial: boolean`**; the
|
||
page shows *"Couldn't reach every relay — these numbers may be incomplete"*.
|
||
Don't drop this: zeros must never be presented as real numbers.
|
||
- New data: **marketplace** — live listings (`30402`, `#t:['skateboarding']`,
|
||
`showcase=true` excluded), completed trades (`31871` from
|
||
`useTradeAttestations`, `s=revoked` excluded), `tradedSats` summed from the
|
||
self-reported `amount` tag, `tradesByMethod`; **regions** —
|
||
`topCountries`/`topCities` built from each **spot or shop's** own
|
||
`city`/`country` tags. Anything without one is surfaced as an
|
||
**`uncategorised`** row, and the footnote states the denominator
|
||
(`{uncategorised} of {locationsTotal}`), so the card reconciles with the
|
||
location total instead of silently not adding up. Countries are grouped by a **stable key**:
|
||
the **`country_code`** the create-spot geocoder now also publishes (ISO-3166-1
|
||
alpha-2, e.g. `GB`), else a lowercased name; the label shown is the **most
|
||
common raw name** in the group, so a localised spelling can't split one
|
||
country into two rows. The tail beyond the top 6 is surfaced as an
|
||
**`otherCountries`/`otherCities`** "Other" row, and the heading reads **"Spots
|
||
by Region"** — it counts spots, not people; and a **bounded zap signal** —
|
||
`zapCount`/`zapSats` from kind-9735 receipts `#p` the top ~25 contributors
|
||
(amounts decoded with `getSatoshisFromBolt11`). ⚠️ **Global zap volume is NOT
|
||
measurable on a public relay**, so the code and copy say "not global volume"
|
||
— never relabel it as the community total.
|
||
- UX: the badge shows **"updated Nm ago" + Refresh** (`dataUpdatedAt`/`refetch`);
|
||
the **13 Lab cards collapse on mobile** (a `display:contents` wrapper toggled
|
||
`hidden lg:contents`, so children stay real grid items) with a "Show all 13
|
||
crafts" toggle; container widened `max-w-6xl` → `max-w-7xl`; `StatCard` gained
|
||
a `compact` prop (`+compactNumber`) so big sats don't overflow `text-4xl`.
|
||
- ⚠️ **Adding a field to a PERSISTED query is a crash risk (2026-10-01).**
|
||
`growth-stats` is in the `queryPersister` allow-list, so a cache saved before
|
||
a new field existed lacks it — and `stats.topCountries.length` throws
|
||
(`Cannot read properties of undefined`), slamming `/stats` for anyone with an
|
||
old cache. Fix: **bump `PERSIST_BUSTER`** (`src/lib/queryPersister.ts`, wired
|
||
in `App.tsx`) **AND** read defensively (`stats?.x ?? []`). ⚠️ **The smoke test
|
||
cannot catch this** — it boots a clean profile with no persisted cache, so it
|
||
passed while real users crashed. Any new field on an allow-listed query needs
|
||
both.
|
||
- ✅ **The map is now CANONICAL for spots + shops (owner decision, 2026-10-26).**
|
||
`useSkateSpots` and `useShops` use `queryCanonical` (community relay
|
||
`relay.skate-spots.org` ONLY) with a stable `'canonical'` query-key suffix, so
|
||
the map always matches the community dataset regardless of the viewer's
|
||
personal relays. This **supersedes** the 2026-10-01 note below (which kept the
|
||
map blended so a `useSkateRelay=false` user still saw their own pins). The
|
||
spot **detail** page (`useSkateSpot`) stays blended so shared links to a spot
|
||
published only on a user relay still resolve. Remaining map↔stats deltas:
|
||
a cluster bubble is proximity (may span countries), and the region card only
|
||
counts locations **with a location tag** while the map draws every pin — the
|
||
number comparable to the map is the hero **"Skate Spots"** card.
|
||
- ~~The map and the stats legitimately differ — do NOT "fix" the map to
|
||
`queryCanonical` (2026-10-01).~~ (superseded above)
|
||
- **Moderation now applies to the stats too (2026-10-01):** `useGrowthStats`
|
||
calls `useAdminModeration()` and drops hidden content inside `dedup()`, so a
|
||
spot the community removed no longer inflates `totalSpots`, the regions, or
|
||
the trusted counts. The hidden-set **sizes are part of the query key** so the
|
||
stats recompute once the labels load. Remaining map↔stats deltas (both
|
||
legitimate): **untagged pins** (the map draws them, the region rows only count
|
||
tagged ones) and **auto-hidden** spots (the map drops them at `MapPage:847`;
|
||
the stats can't know about reports without querying them).
|
||
- **Anti-fake-volume on `/stats` (2026-10-01):** the marketplace "Sats Traded"
|
||
used to be a naive sum of the self-reported `amount` tag on `31871`, so two
|
||
colluding accounts could inflate it for free. `useGrowthStats` now computes
|
||
**`tradedSats` robustly** — **mutual trades only** (a listing attested by ≥2
|
||
distinct pubkeys), per-pair cap **3**, amounts **>1 BTC dropped** as junk —
|
||
plus `tradedCount` (mutual trades) + `tradedTraders` (distinct people), and
|
||
keeps `tradedSatsClaimed` (the raw claim) so the card shows both. New query
|
||
fields ⇒ `PERSIST_BUSTER` → **v5**. **There are no trade attestations on the
|
||
relay yet**, so the honest values are 0 today. Related: `useTradeReputation`
|
||
now exposes **`withPaymentProof`** — mutually-confirmed trades that carry a
|
||
`proof` tag — surfaced on `TradeReputationCard` as *"with payment proof
|
||
(self-reported — not verified)"*, **never** as verification. **The one
|
||
verifiable tier (2026-10-01):** `["proof","zap","<receiptId>"]` is checked by
|
||
`src/lib/zapReceipt.ts` (`verifyZapReceipt`: kind-9735 signature + `p` = seller
|
||
+ decodable bolt11 amount + the embedded 9734 request) and counted as
|
||
**`paymentVerified`** — shown as *"payment-verified (zap receipt)"*. Honest
|
||
ceiling: it proves the seller's **LNURL provider** acknowledged a payment, not
|
||
that goods changed hands. (Remaining anti-sybil: ring detection + PoW.)
|
||
- **Reverse geocoding hits Nominatim — disclosed (2026-10-01).**
|
||
`src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `geocodeBigDataCloud` /
|
||
`pushRegionTags`) is the **single source of truth**, used by **spot create, spot
|
||
edit, shop create and shop edit**. `reverseGeocodeRegions()` tries
|
||
`nominatim.openstreetmap.org/reverse?lat=…&lon=…` (`Accept-Language: en`) first,
|
||
then **falls back to BigDataCloud** when Nominatim returns nothing (it 429s —
|
||
see the derived-regions note above), so a new spot is tagged **by default**. It
|
||
**never throws** (a geocoder outage can't block a publish; the spot still gets
|
||
its geohash and the stats' read-time derivation can place it). Both providers
|
||
**see the creator's IP** and are disclosed on `/privacy` → "Spot/shop
|
||
geocoding". Coordinates are the **pin you placed**, never the device's live GPS.
|
||
⚠️ Any new outbound call that receives coordinates must be added to `/privacy`
|
||
in the same change.
|
||
⚠️ **`EditSpotPage` used to DROP region tags (fixed 2026-10-01):** like
|
||
`EditShopPage` it rebuilds `eventTags` from scratch, so any `city`/`country`/
|
||
`country_code` the spot already had were silently lost on save (the new
|
||
addressable event replaced the old one without them). Both edit pages now
|
||
`reverseGeocodeRegions()` on save. **Any page that republishes an addressable
|
||
event must re-derive or preserve its region tags** — check this whenever a new
|
||
editor is added. Related: spots created before the tags existed stay
|
||
"Uncategorised" in `/stats` until they're re-saved (only the author can update
|
||
an addressable event).
|
||
- **Admin backfill for legacy untagged pins (2026-10-01):** the reason
|
||
`/stats` "Spots & Shops by Region" under-counts vs the map is **tag coverage,
|
||
not a stats bug** — the map draws a pin by its coordinates, the region card
|
||
can only place a pin that carries a `city`/`country` tag. Measured on the
|
||
canonical relay: raw **54** spots+shops → **43** after admin-hide → **13
|
||
tagged / 30 "Uncategorised"**, and **21 of those 30 are the two admin
|
||
accounts' own pre-geocoding pins**. So `useRegionBackfill.ts` +
|
||
`RegionBackfillCard` (Settings → Advanced, admin-only) find the **logged-in
|
||
user's OWN** untagged spots/shops, decode each pin's coords (`lat`/`lon` else
|
||
`g`), `reverseGeocodeRegions()` them (spaced **1.1 s** for Nominatim's
|
||
1 req/s policy), and republish the addressable event with the region tags
|
||
added — **every existing tag preserved**, only stale `city`/`country`/
|
||
`country_code` replaced. Publishes to **`getWriteRelays()`** (community relay
|
||
+ author) so canonical `queryCanonical` stats see it, then invalidates
|
||
`skate-spots` + `growth-stats`. **Only the author's key can update an
|
||
addressable event**, so each admin must run it while logged in as themselves;
|
||
the 9 remaining untagged belong to 9 distinct community mappers and can't be
|
||
fixed centrally. ⚠️ Even after backfill, a map **cluster bubble ≠ a country
|
||
count** — bubbles are 60 px proximity (`markerClusterGroup`) and can span
|
||
borders, and the map also plots shops/gear/tours/events/crews the region card
|
||
doesn't count.
|
||
- **Read-time derived regions (2026-10-01):** so the region card matches the map
|
||
with **NO signing**, `useGrowthStats` falls back to a **cached reverse geocode
|
||
of an untagged pin's own coordinates** (`lib/derivedRegions.ts`, localStorage
|
||
`skatespots:derived-regions-v1`, keyed by ~110 m-rounded coords). A
|
||
`useDeriveRegions()` hook mounted on `/stats` scans the community relay for
|
||
untagged spots/shops, fills cache misses, and invalidates `growth-stats` every
|
||
5 resolved (and at the end); the card shows a live "Resolving N…" counter.
|
||
⚠️ **Do NOT use Nominatim for this** — it is per-VIEWER volume (every device,
|
||
every reload), Nominatim's policy forbids it and it **429'd us** (2026-10-01),
|
||
which silently broke every remaining lookup (`reverseGeocodeRegions()` returns
|
||
empty on `!res.ok`, so the counter froze and the card stalled). The fallback
|
||
now uses **BigDataCloud** `reverse-geocode-client` (no key, CORS `*` on both
|
||
`api.bigdatacloud.net` → 307 → `api-bdc.io`, English `countryName`/`countryCode`/
|
||
`city`), spaced ~300 ms. Nominatim stays for **create/edit** only (the author's
|
||
own pin, one call per spot). ⚠️ This geocodes pins when the stats page is
|
||
viewed, so the provider sees the viewer's IP + public pin coords — disclosed on
|
||
`/privacy` ("Spot/shop geocoding", names both providers). It is a **display
|
||
fallback only**; the backfill above writes real `city`/`country` tags. Either
|
||
way the card's `Uncategorised` row now means "couldn't be tagged OR resolved".
|
||
- **NIP-05 names — `you@skate-spots.org` (2026-09-28):** SkateSpots hands out
|
||
free handles so a new skater has a human name from minute one. The registry is
|
||
the Netlify Function **`netlify/functions/nip05.mjs`**
|
||
(`/.well-known/nostr.json` + `/api/nip05/{check,claim,release}`), backed by
|
||
**Netlify Blobs** (`@netlify/blobs`, store `nip05`, one key per name) — no new
|
||
vendor, no new secret. Rules: **non-gating** (a handle is a badge, never a key
|
||
— the app works without one), **proof by signature** (a claim carries a NIP-98
|
||
kind-27235 event in the `x-nip05-auth` header, signed by the pubkey that will
|
||
own the name), and `onlyIfNew` writes so two claims can't clobber each other.
|
||
Platform names are seeded in code (`skate-spots`/`skatespots` → owner pubkey,
|
||
`skater` → the second admin `caba497f…`) and cannot be claimed/released by
|
||
users; a small `RESERVED` list blocks impersonation words. Any admin can wear
|
||
a seeded handle by typing it in Edit Profile → NIP-05 (it verifies against the
|
||
seed; no claim needed). Client: `src/lib/skateNip05.ts` (shared validation,
|
||
`deriveSkateNip05Handle`, `parseSkateNip05`, `skateApiBase()` for the native
|
||
absolute-URL rule), `src/hooks/useSkateNip05.ts` (check/claim/release), and
|
||
`src/components/SkateNip05Input.tsx` (debounced availability + Claim).
|
||
Wired into **signup** (`SignupDialog` profile step adds a "SkateSpots name"
|
||
field, prefilled from the display name until edited; the handle is claimed
|
||
best-effort *before* the kind-0 publish, and **a failure never blocks signup** —
|
||
a profile only advertises `nip05` once the registry confirms it) and into
|
||
**Edit Profile** (`Nip05Field` gains a "claim a free handle" block).
|
||
`Settings → NIP-05` links to it. ⚠️ `config.path` DOES take precedence over the
|
||
`/*` SPA redirect for `/.well-known/*` (verified on a draft deploy). Test
|
||
functions with a free draft (`netlify deploy --dir=dist`), not a prod deploy.
|
||
Verified end-to-end on a draft (2026-09-28): claim → resolves → a second key
|
||
gets 409 → release → gone.
|
||
- **Deleted content flashed back from cache (fixed 2026-10-09):** on refresh, a
|
||
just-deleted spot (e.g. the Africa test pins) appeared for a few seconds and
|
||
then vanished. Two cache layers render before "current truth": the persisted
|
||
React Query cache (`skate-spots`/`skate-shops` are allow-listed in
|
||
`queryPersister.ts`) and the Dexie spot cache (`offlineCache.ts`). A NIP-09
|
||
kind-5 delete is honoured **only by relays** and is never applied locally, and
|
||
some relays ignore it — so cached copies kept resurfacing until the fresh query
|
||
(or the `useAdminModeration` hidden-label query) filtered them out.
|
||
Fix: **`src/lib/localDeletions.ts`** — a device-local tombstone
|
||
(`skatespots_local_deletions_v1`) keyed by BOTH coord (`kind:pubkey:d`) and
|
||
`e:<id>`, written optimistically in `useDeleteEntity` on the owner path (rolled
|
||
back if signing/publish fails). The shared **`filterAdminHidden` /
|
||
`filterAdminHiddenMixed`** now also drop tombstoned items, so every list hook
|
||
(spots/shops/events/tours/crews/marketplace/skaters/coaches/lab) filters them
|
||
from the first render — no per-hook changes. `useSkateSpot` returns null for a
|
||
tombstoned spot. `cacheSpots`/`cacheSpot` refuse to re-cache tombstoned rows and
|
||
evict existing ones. ⚠️ Do NOT prune the Dexie cache by "not in the fresh
|
||
result": callers use limits from 8 to 2000, so a small-limit query would wipe
|
||
valid cached spots — prune by tombstone only. Admin `hide` was already instant
|
||
via `localModeration`'s local verdicts; this closes the NIP-09 self-delete gap.
|
||
- **Map search autocomplete (2026-10-09):** the map search pill now shows a
|
||
suggestions dropdown. **`src/lib/mapSearchSuggestions.ts`**
|
||
(`buildSearchSuggestions`) builds two groups from the items that already match
|
||
the active filters: **spots** (title matches; click = fly to + open the sheet)
|
||
and **places** (distinct city/region/country tokens split out of each item's
|
||
`location`; click = set the query to that token, e.g. "spain" → "Spain").
|
||
**`components/MapSearchSuggestions.tsx`** is presentational only — `MapPage`
|
||
owns the list and `activeIndex`, plus ArrowUp/Down, Enter and Escape. New i18n
|
||
key `mapPage.searchSuggestPlaces` (en/es only, others fall back); the Spots
|
||
header reuses `nav.map`, the empty state reuses `common.noResults`. The filter
|
||
itself still matches `title/location/description`, so a place suggestion works
|
||
by putting that token into the query.
|
||
- **Offline / dead-relay behaviour (2026-09-28):** the app is a utility, so a
|
||
relay outage must degrade, not blank. ⚠️ `querySkateContent` used to swallow
|
||
**every** relay error and return `[]`, so a dead relay looked like "no spots"
|
||
AND overwrote good cached data with an empty list (which the persister then
|
||
saved). Now:
|
||
- `querySkateContent` / `queryCanonical` take an opt-in **`requireAny`** that
|
||
throws when NO relay answered. Only the map's own feeds pass it
|
||
(`useSkateSpots`, `useShops`, `useMarketplaceItems`, `useTours`); the other
|
||
~75 call sites keep the old behaviour, deliberately.
|
||
- `useSkateSpots` / `useSkateSpot` catch that error and fall back to the
|
||
**Dexie spot cache** (`lib/offlineCache`), so the map still works at the
|
||
park; they only re-throw when nothing is cached (UI shows an offline card).
|
||
- `shouldDehydrateQuery` **never persists an empty array** — otherwise one
|
||
blip would wipe the persisted map for the next 24h.
|
||
- `MapPage` shows "Offline · showing saved spots" + retry, and a "Can't reach
|
||
the relays" card instead of the false "No spots mapped yet". ⚠️ **Two distinct
|
||
signals (fixed 2026-09-29):** the **saved-spots banner is driven by
|
||
`deviceOffline` (`!useOnlineStatus()`)** only — it used to also key off the
|
||
single `SKATE_RELAY` probe, but the map reads from *several* relays, so it
|
||
stuck around online while fresh data loaded fine, and its retry never
|
||
re-probed. The banner is also **dismissible** (re-armed when the connection
|
||
returns). The relay probe still gates the **"nothing at all" card**
|
||
(`relayUnreachable = !online || relay.status === 'offline'`), and both retries
|
||
now call `refetchSpots()` **and** `relay.recheck()`.
|
||
- **Offline WRITE queue is now real (2026-09-29).** `lib/offlineCache` already
|
||
had a `pendingPosts` table + `useSyncPendingPosts`/`useQueuePost` + an
|
||
`OfflineIndicator` badge, but **nothing ever called `queuePost`** — offline
|
||
writes just threw. Now: **`useNostrPublish` queues on `isOffline()` (skipping
|
||
the 15s publish timeout) or when every relay fails**, storing the
|
||
**already-SIGNED event** (`queueSignedEvent`) so an interactive signer is
|
||
**never re-prompted at sync time**; `useSyncPendingPosts` publishes it
|
||
directly (`nostr.event`), **not** through `useNostrPublish` (which would
|
||
re-queue and duplicate), re-signing only legacy unsigned entries. Rules:
|
||
**supersede** — a new replaceable (0/3/10000+) or addressable (30000+) event
|
||
drops the older queued one; **TTL** — ephemeral kinds (`2259` spot status,
|
||
`24899` spot chat) are pruned after 15 min (late "I'm here" is worse than
|
||
none); **cap** 100; `getPendingPosts` returns `pending` **and** retryable
|
||
`failed` (a single failure must not strand an action) and the count includes
|
||
both; a **module-level `globalSyncing` lock** keeps ONE publisher even though
|
||
`OfflineIndicator` (which owns the sync) is mounted twice; retries every 30s
|
||
while offline→online. ⚠️ `useNostrPublish` now **resolves** (instead of
|
||
throwing) when it queues — callers show their success toast, corrected by a
|
||
global "Saved offline" toast + the badge.
|
||
- **Stash is readable offline + backed up (2026-09-29).** The private stash
|
||
(NIP-51 kind `30001`, `d=skatespots-stash`, NIP-44 encrypted to self) was
|
||
**deny-listed from React Query persistence with NO local cache**, so an
|
||
offline/relay-outage cold start showed an EMPTY stash even though decryption
|
||
needs no network. Now the **encrypted CIPHERTEXT** is cached in Dexie
|
||
(`offlineCache` v2 `stash` table — `cacheStashEvent`/`getCachedStashEvent`),
|
||
and both the read query and `fetchLatestStash` (the pre-write re-fetch) fall
|
||
back to it — so the stash still loads at the park and an offline edit rebuilds
|
||
from the real list instead of throwing. **Only ciphertext is stored, never the
|
||
decrypted list.** Also added **Export/Import** on `/stash` (`Backup &
|
||
transfer` card, desktop sidebar + mobile): export writes a JSON you control;
|
||
import appends in ONE re-encrypt, skipping title+category+size duplicates
|
||
(`importItems`). **Encrypted backups (2026-10-03):** `src/lib/stashBackup.ts`
|
||
wraps the export in a **version-2 JSON envelope** — `password` mode (scrypt
|
||
NIP-49 params 2^16/r8/p1 → **AES-256-GCM**, header bound as AAD so KDF/
|
||
cipher can't be downgraded) or `nip44` mode (encrypted to your own key, no
|
||
password). Plaintext v1 files still import. `StashBackupExportDialog` +
|
||
`StashBackupUnlockDialog` (`components/StashBackupDialogs.tsx`); the picker
|
||
prompts for a password only for `password` files. ⚠️ Password mode needs no
|
||
signer (works with Amber); nip44 mode does. Verified round-trip / wrong
|
||
password / tamper / KDF-downgrade rejection in Node. **UX pass (same date):** the filter bar gained a **Sort**
|
||
(Newest / Value / Condition / A–Z) and search now includes **`size`**; a
|
||
**"Public stash"** card lists which items are public (with the profile link
|
||
for "see what others see"); and **bulk actions** — a header **Select** toggle
|
||
puts rows/cards into multi-select (checkbox, click-to-toggle, per-item
|
||
actions hidden) with a fixed action bar: **Public / Private / For sale /
|
||
Remove** (`bulkSetVisibility`/`bulkSetForSale`/`bulkRemove`, each ONE
|
||
re-encrypt) + a bulk-delete AlertDialog; showcase sync/unpublish runs per
|
||
affected item. **WTB is unified (2026-10-01):** a **public** want
|
||
(`itemType: 'wtb'`, now defaulting to public in `AddItemDialog`) publishes a
|
||
real **NIP-99 `wtb` listing** (`useStashShowcaseSync.buildListingTags` — the
|
||
`stash-want` tag instead of `stash-showcase`, `listing_type=wtb`, `price` =
|
||
what they'd PAY), so sellers find it in the **Marketplace** (which already
|
||
filters `listing_type=wtb`), while the encrypted Wishlist tab keeps the
|
||
private copy. The old `item.itemType !== 'wtb'` showcase guards were removed
|
||
so the same sync/unpublish path covers wants.
|
||
- **Multi-device merge guard (2026-10-01):** the stash is still ONE encrypted
|
||
blob, but writes now MERGE instead of clobbering. Items gained an
|
||
**`updatedAt`** stamp; deletes record **`{id, at}` tombstones** in a
|
||
top-level `deleted[]`; **`mergeStashes()`** (exported from `useStash.ts`)
|
||
unions items by id (newest stamp wins) and lets a tombstone win at/after the
|
||
last edit. **`fetchLatestStash()`** merges the live remote with the list
|
||
already held (React Query cache → hook closure) and toasts "Merged changes
|
||
from another device" when the other side contributed. ⚠️ **Writes now require
|
||
a CONFIRMED relay read** — `fetchLatestStash()` returns `{stash, confirmed}`
|
||
and `assertConfirmed()` throws when no relay answered, so an offline stash
|
||
write is **REFUSED rather than queued**: `useNostrPublish` would publish the
|
||
pre-signed blob later as-is and could silently overwrite a newer edit made on
|
||
another device. Reads still work offline from the cached ciphertext;
|
||
`StashPage` surfaces the refusal via `onStashError`. Why not per-item events?
|
||
N items = N `nip44.decrypt()` calls per load → N Amber prompts. Schema is in
|
||
`NIP.md` → "Private Stash" → "Multi-device merge".
|
||
- **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie
|
||
`SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)
|
||
+ `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it),
|
||
used by **both** maps (`SpotMap`, `RouteDialog`). Deliberately NOT a service
|
||
worker: a SW only helps the web build, while the Android app runs inside
|
||
Capacitor's WebView where a SW never sees tile requests. Works on both.
|
||
**Web app shell (2026-10-28):** `public/push-sw.js` is also the app-shell
|
||
cache — hashed assets cache-first, navigations network-first with
|
||
`/index.html` fallback, old `skate-spots-shell-*` caches removed. It is
|
||
registered unconditionally on web by `lib/appServiceWorker.ts` (skipped on
|
||
native). The push handlers are unchanged; `/api/*` and cross-origin model
|
||
requests are never cached.
|
||
- **Android navigation + app links (2026-10-28):** `@capacitor/app` is now a
|
||
real dependency. `DeepLinkHandler` dynamically imports it and handles
|
||
`appUrlOpen` (HTTPS paths, `#/` HashRouter paths, and
|
||
`org.skatespots.app://` host routes) plus hardware `backButton` (close the top
|
||
Radix overlay → history back → minimise at the map). `scripts/inject-deep-links.mjs`
|
||
adds the verified `https://skate-spots.org` + custom-scheme intent filters to
|
||
the generated manifest; `public/.well-known/assetlinks.json` carries the
|
||
release cert SHA-256 `B7:52:F2:6A:…:12:0A` for package
|
||
`org.skatespots.app`. If the signing cert changes, update assetlinks and
|
||
redeploy; app-link verification happens at install.
|
||
- **Photo redundancy (2026-09-28):** `useUploadFile` now uses **two** Blossom
|
||
hosts (`blossom.primal.net`, `blossom.band`) instead of three. ⚠️
|
||
`BlossomUploader.upload` signs ONE auth event then does
|
||
`Promise.any(servers.map(fetch))` — it **PUTs to every server in parallel**, so
|
||
the old 3-host list was silently **triple-uploading every photo** (the code
|
||
comment claiming "tries them in order" was wrong). Two hosts = less client
|
||
data, still a single signature (no extra Amber prompt). The returned tags gain
|
||
`['fallback', '<mirror-host>/<sha256>']`, and **`lib/imageFallback.ts` +
|
||
`ImageWithFallback` retry the same hash on the other known Blossom host on
|
||
error** — one choke point covers every photo/logo call site (~20), so a dead
|
||
host costs a moment, not a blank spot. Blossom is content-addressed, so the
|
||
mirror URL is derived from the sha256 in the path (handles `/<sha>` and
|
||
`/<sha>.<ext>`).
|
||
- **AI proxy on our own host (`ai-proxy/`, 2026-09-28):** the same proxy the
|
||
Netlify Function runs, rebuilt as a standalone **Bun** service on the CVM host so
|
||
AI calls stop spending Netlify compute/bandwidth credits and the 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), deployed with **`npm run deploy:ai`**
|
||
(`scripts/deploy-ai-proxy.sh`) to `/home/ubuntu/skatespots-ai`, systemd unit
|
||
`skatespots-ai`, type-checked by `ai-proxy/tsconfig.json` (`typecheck:ai`, wired
|
||
into `npm test`); `ai-proxy/` is eslint-ignored like `cvm-server/`. Env
|
||
(`SASQUATCH_*`) copied from Netlify env to `~/skatespots-ai/.env` (600);
|
||
**verified live** (`POST /api/ai/status` → the real Routstr balance). The client
|
||
picks the base in **one place — `src/lib/sasquatchAi.ts`** (`sasquatchAiBase()`,
|
||
also used for the NIP-98 `u` tag). **Live (2026-09-28):** `ai.skate-spots.org`
|
||
→ the CVM host (an `A` record in **Netlify DNS**; ⚠️ the API silently *dropped
|
||
the first create* — verify the NS actually serves a new record), TLS via
|
||
**Caddy** (auto Let's Encrypt, installed on the host). Production builds now
|
||
default to it (`import.meta.env.PROD` → `https://ai.skate-spots.org/api/ai`);
|
||
dev uses local `/api/ai`. ⚠️ `netlify/functions/ai.mjs` is now a **fallback
|
||
only** — revert with `VITE_SASQUATCH_AI_BASE=https://skate-spots.org/api/ai`.
|
||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||
https://mintradar.org/api/mints/:splat 200`), dev via `server.proxy` in
|
||
`vite.config.ts`. Data is unofficial/rate-limited; adding a mint is native-only.
|
||
**Add-mint dialog (2026-10-04):** `MintPrivacyManager`'s add dialog now also
|
||
lists the **full MintRadar directory** (searchable, `useMintDirectory`) below
|
||
the hand-picked "Recommended" shortlist — previously it only showed the
|
||
recommendations.
|
||
⚠️ **`vite.config.ts`'s `server.proxy` also covers `/api/nip05` →
|
||
`https://skate-spots.org` and `/api/ai` → `https://ai.skate-spots.org`
|
||
(2026-09-28).** Without them the dev server answers those production-only
|
||
endpoints with `index.html`, so the NIP-05 availability check showed
|
||
**"Could not check right now"** on localhost and dev AI silently fell back to
|
||
Pollinations. (Prod is unaffected — the functions are same-origin there.)
|
||
⚠️ **Relative `/api/*` paths don't work in the native app** (fixed 2026-09-27):
|
||
the shell is served from `https://localhost`, so `/api/mints/known` hits the
|
||
LOCAL asset server and returns `index.html` → `Unexpected token '<' … not valid
|
||
JSON`. `useMintDirectory` now uses `https://skate-spots.org/api/mints/known`
|
||
via **CapacitorHttp** on native (core plugin, no CORS) with a plain-fetch
|
||
fallback, and turns an HTML body into a friendly message. Same pattern already
|
||
used by `sasquatchBrain.ts` / `useSasquatchAiStatus` (`Capacitor.isNativePlatform()
|
||
? 'https://skate-spots.org/api/…' : '/api/…'`) — follow it for any new `/api`
|
||
call, and never let a raw JSON parse error reach the UI.
|
||
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
|
||
retries, then one full reload per session, so a chunk-load failure self-heals
|
||
instead of showing the error boundary.
|
||
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
|
||
**156 tests**, build ok. The gate also type-checks the CVM server
|
||
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.56**.
|
||
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
|
||
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
|
||
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
|
||
`lib/trustFilter.ts`) and unused hooks/functions deleted — no blanket
|
||
disables. Keep it at zero — don't reintroduce any.
|
||
- ⚠️ Do **not** mass-fix `no-unused-vars` by name or blanket rename — shadowed
|
||
names and typed shorthand destructuring make it unsafe. Fix position-precise.
|
||
Renaming a component to `_Name` also trips `rules-of-hooks` — delete it or
|
||
suppress instead.
|
||
- **Untracked local junk — do NOT commit** (add to `.gitignore`, not the repo):
|
||
`assets/`, `resources/`, `/bun.lock`, `cvm-server/bun.lock`,
|
||
`cvm-server/package.json` (local dev copy of the tracked `package.cvm.json`),
|
||
`*.bak`.
|
||
(`cvm-server/` *source* IS tracked — only its locks/backups are junk.) Watch
|
||
out: `git add -A` in the repo root sweeps these in.
|
||
- **Never `git add -A` from the repo root** without checking `git status` first.
|
||
|
||
## Angor crowdfunding — the SkateSpots project (2026-10-05)
|
||
|
||
SkateSpots is crowdfunded on **Angor** (Bitcoin + Nostr, no middleman). This is
|
||
*separate* from the app code: a live on-chain project whose metadata is ordinary
|
||
Nostr events signed by a **project identity** (derived from the founder's wallet,
|
||
**not** the personal npub).
|
||
|
||
- **projectIdentifier** (the kind-3030 `d`): `angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z`
|
||
→ Bitcoin address `bc1qtunna00uqyx52rr0nvqa059z2gknhrmnqe7h8h`.
|
||
- **project Nostr key** (signs profile + metadata): `a99eb1d4d546d3a3b40040cc93f77b9a7cd32fab1b693261e03ff1b7ce7400f`
|
||
= `npub14x0tr4rd23kn5w6qqsxvj0mhhxnu6vh6kxmfxfs7q0l3kl88gq8sfgwk54`.
|
||
⚠️ **Different from the personal/platform key** `20e7c953…` (`npub1yrnuj…`, the git origin).
|
||
- **kind 3030** = funding definition (Fund type, Main, penalty 90d, threshold 100k sats,
|
||
3/6/9-monthly stage patterns). Its event id is committed **on-chain via OP_RETURN** →
|
||
**effectively immutable; do not edit**.
|
||
- **Editable metadata** = **kind 0** profile + **kind 30078** (`angor:project`, `angor:faq`,
|
||
`angor:members`, `angor:media`). Live: displayName "Skate Spots", NIP-05
|
||
`owner@skate-spots.org`, lud16 `skatespots@minibits.cash` (single-sourced in
|
||
`src/lib/platform.ts`), picture on `blossom.angor.io`.
|
||
- **In-app display:** `/supporters` has a **Supporters** section (curated) fed by
|
||
**`src/lib/backers.ts`** — `{ npub, amount (sats), note }` or `{ name, logo, url }`.
|
||
Amounts come from the investment txs' Taproot stage outputs. Discovery helper:
|
||
`~/.config/skatespots/angor/angor-backers.mjs` (decrypts the founder's investment DMs →
|
||
`work/backers.json`). ⚠️ Don't list a backer without consent.
|
||
|
||
### Tooling — machine-local, deliberately NOT in the repo
|
||
`~/.config/skatespots/angor/` → `angor-metadata.mjs` + `blossom-upload.mjs` (own
|
||
`nostr-tools`/`ws`). The project nsec lives in `~/.config/skatespots/angor/key` (mode 600,
|
||
outside the repo); the tool **refuses** any key whose pubkey ≠ the project key.
|
||
`check` · `dump`/`show` · `publish [--yes] [--only …]` · `emit` · `claim-nip05 <name>` ·
|
||
`blossom-upload.mjs <file>`. Get the nsec from the app: project → **Private keys**
|
||
(the modal is titled **"Project NOSTR Private Keys"**; feet at `m/44'/1237'/{upi}'/0/0`).
|
||
|
||
### ⚠️ Gotchas that cost a whole session
|
||
- **Publish to ALL SIX relays the app uses**: `relay.angor.io`, `relay2.angor.io`,
|
||
`relay.damus.io`, `nos.lol`, `nostr-01.yakihonne.com`, `nostr-02.yakihonne.com`.
|
||
`RelayService.FetchProfileMetadataAsync` does `result = metadata` on every reply —
|
||
**last response wins, not the newest event** — so one relay still holding an older kind 0
|
||
overrides the update. (The project originally lived only on YakiHonne.)
|
||
- **The app's Edit Profile "Save Profile" republishes whatever the form shows.** While the
|
||
NIP-05/Lightning fields are blank, saving **wipes** them. Don't save until they load.
|
||
- **The app's Relays tab is cosmetic** — `EditProfileViewModel.SaveAsync` never persists
|
||
`Relays` and `LoadAsync` never loads them; adding relays there does nothing.
|
||
- **`nostr-tools` SimplePool + Node's built-in WebSocket is broken here**: it reports a fake
|
||
`✔` with no relay `OK`, and overflows the stack on a failed connect. The tool talks raw
|
||
`ws` and waits for the relay's real `OK` — trust that output, not the old pool behaviour.
|
||
- **NIP-05:** `skate-spots@skate-spots.org` is already bound to the personal key; a project
|
||
profile needs a name that maps to the **project** key. `claim-nip05 owner` →
|
||
`owner@skate-spots.org` (registry = `netlify/functions/nip05.mjs`, proof-by-signature).
|
||
- Blossom (blossom.angor.io) returns a **doubled-scheme URL** (`https://https://…`); build
|
||
`<server>/<sha256>.<ext>` yourself and verify the sha256 on download.
|
||
|
||
`nak` is installed (`~/.local/bin/nak`) — handy for `nak req` / `nak event` against these relays.
|
||
|
||
## Local Dev Environment, Build & Release (project-specific — read first)
|
||
|
||
Machine-specific, verified operational notes. Use these instead of re-deriving workflows from scratch.
|
||
|
||
### Where things are
|
||
- **Dev root / repo**: `/home/sovereignsk8/SkateSpots` — the clone whose `origin` is the `nostr://` remote.
|
||
- **origin**: `nostr://npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx/git.shakespeare.diy/SkateSpots` (browsable at nostrhub.io).
|
||
- **Live site**: https://skate-spots.org — served by **Netlify**, deployed via **shakespeare.diy** (which also hosts the git server).
|
||
- **Backups**: `~/.backups/` (nested clone, old lockfiles, keystore copies).
|
||
|
||
### Toolchain (already installed on this machine)
|
||
Node 22 + npm 10 · Bun 1.3 · Java 21 · Android SDK at `~/Android/Sdk` · `ngit` 3.0.3 (`~/.cargo/bin`) · `zsp` (`~/.local/bin`).
|
||
|
||
### Run & validate
|
||
- `npm run dev` → http://localhost:8080. Note the script runs `npm i` first (see lockfile caveat).
|
||
- **Phone-frame preview (check the UI at device sizes in a desktop browser):**
|
||
http://localhost:8097/phone-frame.html — a static page that embeds the Vite dev
|
||
server (`:8080`) in a phone-sized iframe (device presets, rotate, route box;
|
||
`?app=`/`?route=`/`?device=` params). Files live in
|
||
`~/.config/skatespots/phone-preview/` and it's served by the **systemd user unit
|
||
`skatespots-phone-frame.service`** (`systemctl --user status skatespots-phone-frame`).
|
||
⚠️ **Machine-local, deliberately NOT in the repo** (kept out of the build/deploy and
|
||
the `eslint` HTML gate). It replaced an ad-hoc `python3 -m http.server 8097` copy in
|
||
`/tmp` that got wiped (2026-10-05) — don't recreate it in `/tmp`. Needs the dev
|
||
server (`skatespots-dev.service`, `:8080`) up, else the frame shows "cannot be reached".
|
||
- `npm test` = `npm i` + `tsc` + `eslint` + `vitest` + `vite build`. **Must pass before committing.**
|
||
- **Bundle analysis (2026-10-09):** `ANALYZE=1 npm run build` runs
|
||
`rollup-plugin-visualizer` (already in `vite.config.ts`) and writes machine-readable
|
||
`stats.json` — parse `nodeParts[].metaUid` → `nodeMetas[uid].id` for sizes (delete the
|
||
file after; it is not committed). Findings: the entry chunk is ~1.9 MB minified
|
||
(~584 kB gzip); its single biggest resident is **`src/i18n/en.ts` (~352 kB rendered,
|
||
~11%)**, then react-dom, zod, dexie, vaul. The heavy libs are already correctly
|
||
lazy-chunked (`html5-qrcode`/zxing, `xlsx`, `leaflet`, `html2canvas`, `jspdf`,
|
||
`coco-cashu`), so don't chase those. The 1.9 MB entry is mostly framework + the
|
||
English dictionary + shared UI; shrinking it means trimming those, not re-adding
|
||
`manualChunks` (see the TDZ warning above).
|
||
- **Repo weight (2026-10-28):** removed 1,295 tracked Gradle build files under
|
||
`packages/skatespots-nfc/android/build` (already gitignored; Gradle recreates
|
||
them), deleted unused `public/images/sticker-design.png`, recompressed the
|
||
large logo/cover/guide assets, and dropped the **threaded** ORT WASM variants
|
||
(`ort-wasm-threaded.wasm`, `ort-wasm-simd-threaded.wasm`,
|
||
`ort-wasm-threaded.worker.js`). The translation worker sets
|
||
`numThreads = 1`, so ORT selects `ort-wasm-simd.wasm` or the non-SIMD
|
||
`ort-wasm.wasm`; restore the threaded trio if threads are ever enabled.
|
||
Tracked tree is ~47 MB (from ~66 MB); the **.git history still contains old
|
||
blobs** — only a history rewrite (`git filter-repo`) would shrink clones, and
|
||
that would change every commit hash/tag, so it is deliberately not done.
|
||
- **Load/relay performance (2026-10-28):** three structural costs were removed.
|
||
① `useAuthor` is now **batched** (module queue + one flush per tick): profiles
|
||
are fetched with **one** main/fallback/community query per 100 authors instead
|
||
of 3 queries per card; React Query still owns per-author cache keys. ②
|
||
`MapPage` only queries layers that are actually on — Events/Crews default off
|
||
and no longer fetch at startup; Shops/Gear/Tours pass `enabled` too. ③
|
||
`queryPersister`'s allow-list now matches the real keys (`skate-crews`,
|
||
`skate-tour`/`skate-tours`, `shop-directory`, `newsletter-issues`, …), so
|
||
cold starts rehydrate those datasets. The bell is code-split/deferred (1.7.12), images lazy-load (1.7.13), and its
|
||
crew lookup is member-targeted. Next targets: fold the bell's feeds into one summary query or open-only
|
||
fetch, paginate the map's 2000-spot payload, a no-vendor service worker for
|
||
hashed assets/app shell, and the translation model (617 MB M2M100 is the
|
||
biggest latency cost; use a smaller model or native ONNX).
|
||
|
||
### ⚠️ "This page slammed" on *some* routes in dev (dep optimizer cache)
|
||
Symptom: in the running dev server a few lazy routes (e.g. `/settings`, `/stash`, `/collections`, `/stats`) show the "This page slammed" error boundary while others work. The browser console shows `Failed to fetch dynamically imported module` and the network tab shows **504** for `node_modules/.vite/deps/*.js?v=…`.
|
||
- **Cause**: Vite's dependency optimizer cache (`node_modules/.vite/deps`) went missing/was invalidated while the dev server stayed up (long-running servers re-optimize whenever the lockfile changes — and every `npm i`, which `dev`/`test`/`build` run, touches `package-lock.json`). The server keeps serving stale `?v=` hashes → 504 → `React.lazy` rejects.
|
||
- **Fix**: `npm run dev:fresh` (clears `node_modules/.vite` then starts dev) — or, for the running systemd service, `rm -rf node_modules/.vite && systemctl --user restart skatespots-dev` — then **hard-refresh the browser** (`Ctrl+Shift+R`). `npm i` / `vite build` / `vitest` alone do **not** wipe the cache — it's the long-running server's re-optimize that races.
|
||
- **Prevention**: after any command that runs `npm i` (`npm test`, `npm run build`, `npm run dev`, or a version bump), **restart the dev server** before browsing — the re-optimized cache writes fresh `?v=` hashes, so an open tab stops 504-ing. Don't leave a dev server running across a build.
|
||
- **Resilience — self-heals now (2026-10-05)**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`). On a chunk-load failure it **retries the import 3× with backoff**, then does a **cooldown-throttled full reload** (>10 s since the last one), so *repeated* stale-hash failures each recover **without a manual refresh**. ⚠️ **Do NOT reintroduce a once-per-session reload cap** — the old version reloaded once per session, so the *second* failure dead-ended on "This page slammed" and read as an app bug. Only a genuinely persistent failure should reach the error boundary.
|
||
|
||
### ⚠️ Lockfile caveat (important)
|
||
The committed `package-lock.json` (from commit `f25fc2b`) had entries stripped of `dependencies`/`bin` metadata, so transitive packages were silently missing. Repaired in `eb23ebc` (654 → 902 packages).
|
||
- **Regression symptoms**: `npx cap` → "could not determine executable to run"; `tsc` → "Module '@nostrify/nostrify' has no exported member NostrEvent"; Vite → "Could not resolve @std/encoding/hex".
|
||
- **Do NOT regenerate the lock blindly** — a fresh resolve bumps ~300 packages incl. major `@babel` 7→8 and `esbuild` 0.25→0.28. Prefer `npm ci`. If broken, repair each entry's `dependencies`/`bin` from the installed `node_modules/*/package.json`, then `npm install` to fill only the gaps.
|
||
|
||
### Android release build
|
||
- `npm run android:build` → `android/app/build/outputs/apk/release/app-release.apk`.
|
||
- **Versioning**: `package.json` `version` is the single source of truth; `scripts/sync-android-version.mjs` syncs it to `versionName`/`versionCode` (e.g. `1.4.5` → `10405`) during the build. Bump **only** `package.json`.
|
||
- **Signing**: secrets live in the gitignored `keystore.properties`; `scripts/inject-signing-config.mjs` injects a managed block into `android/app/build.gradle` (runs in `android:init`/`android:sync`/`android:build`). The authoritative keystore is **`/home/sovereignsk8/skatespots-release.jks`** (alias `skatespots`). An older repo-root copy had a wrong password and was retired to `~/.backups/keystore/`.
|
||
- `android/` is **generated and gitignored**; recreate with `npm run android:init`.
|
||
- **Launch splash:** `resources/splash.png` is the orange brand wheel. Capacitor's
|
||
generated drawables used a stock blue placeholder, so `scripts/make-android-splash.mjs`
|
||
(sharp) regenerates all portrait/landscape densities from the tracked source;
|
||
`android:init`/`android:sync`/`android:build` all run it. The web splash
|
||
(`components/SplashScreen.tsx`) uses `public/images/icon-1024.png` with a roll-in,
|
||
rolling spin and CSS sparks; `prefers-reduced-motion` falls back to a static mark.
|
||
- Preflight: `cd android && ./gradlew :app:signingReport` confirms the signing config resolves.
|
||
|
||
### Version bump + release notes
|
||
1. Bump `package.json` `version` (Zapstore rejects an already-published version).
|
||
2. Update the **top** section of `release-notes.txt` — **start it directly with the notes**,
|
||
then `---` before older versions. ⚠️ Do **NOT** begin the file with a `vX.Y.Z` line:
|
||
Zapstore already shows the version, so the heading rendered as `1.5.12 v1.5.12`
|
||
(duplicated) — removed 2026-10-05.
|
||
|
||
### ⚠️ Production-bundle runtime ≠ dev (read before touching vite.config)
|
||
|
||
`vite build` only bundles; it never *executes* the output, and dev mode doesn't
|
||
either. A build can compile cleanly and still crash at runtime — that's how the
|
||
old custom `manualChunks()` shipped a white screen to production:
|
||
`ReferenceError: Cannot access 'X' before initialization` (cross-chunk circular
|
||
import TDZ, first in `vendor-charts`, then `vendor-bitcoin`). It only affected
|
||
the **web** build; the Capacitor build uses `inlineDynamicImports` (single
|
||
bundle) and was unaffected.
|
||
|
||
Rules:
|
||
- **Do not re-introduce custom `manualChunks`** without a boot test. Rollup's
|
||
default splitting is cycle-safe.
|
||
- `scripts/smoke-test.mjs` boots the built `dist/` in headless Chromium and
|
||
fails if `#root` doesn't mount **or** if any route shows the "This page
|
||
slammed" error boundary. It walks each route **in the URL hash** (HashRouter!):
|
||
`/`, `/marketplace`, `/leaderboards`, `/bounties`, `/settings`, `/stash`,
|
||
`/collections`, `/stats`, `/messages/group/abc123`. `scripts/auto-deploy.sh`
|
||
runs it before every deploy and **aborts the deploy** if it fails.
|
||
- Any time the deploy output isn't verified by the smoke test, run
|
||
`node scripts/smoke-test.mjs` manually before shipping.
|
||
|
||
### Deploy
|
||
- **Web (auto)**: a systemd user timer (`skatespots-deploy.timer`) runs `scripts/auto-deploy.sh` every ~3 min. When `origin/main` changes it builds and runs `netlify deploy --prod --dir=dist`. It deploys **only committed code** (skips if tracked files are modified) and never mutates a dirty tree. **Doc/scripts-only commits are skipped** (matches `AGENTS.md`/`*.md`/`scripts/`/`cvm-server/`/`skills/`) — Netlify bills **15 credits per production deploy**, so don't spend them on non-site changes. One-time setup: `netlify login` + `netlify link`. Logs: `journalctl --user -u skatespots-deploy`. Manual run: `scripts/auto-deploy.sh`.
|
||
- ⚠️ **Deploy throttle (2026-09-28):** production deploys are capped to one per **24h** by default (after a week of 68 deploys ≈ **1,020 credits** — a third of the monthly allowance). Batching a day's commits into one 15-credit deploy is the single biggest credit lever. Override with `SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds>` (`0` disables); deploy on demand with **`scripts/auto-deploy.sh --force`**. A throttled tick deliberately does **not** record the deployed SHA, so the pending change still deploys on a later tick; a **failed** deploy doesn't set the clock, so it retries immediately.
|
||
- ⚠️ **Every skip records its reason** to `~/.local/state/skatespots-deploy/last-skip` (`epoch|reason|sha|detail`) — previously a skip was silent. Read it back with `scripts/ops-health.sh` (below).
|
||
- ⚠️ Shakespeare's "Deploy" rebuilds **its own workspace**, which is NOT updated by git pushes — so pushing to the repo does not change what Shakespeare deploys. Prefer the auto-deploy timer (or drag `dist/` to Netlify).
|
||
- **Ops health (`scripts/ops-health.sh`, 2026-10-01):** answers *"why isn't it live?"* Two failures were previously invisible and cost a whole session: the **ngit/Amber signer stalling mid-push** (`git push` hangs at *"signing event (git state) with remote signer"*) and the **deploy timer skipping** while `origin/main` had moved. The script reports repo/push sync, the recorded skip reason, the throttle window, and timer health; **exit 1** when something needs attention.
|
||
- Flags: `--probe-push` runs a bounded 45s **real** `git push` — signing only happens when there *is* something to push, so this is the only true signer probe (it publishes any unpushed commits; a timeout ⇒ signer down ⇒ open Amber in the foreground, or `ngit account login --interactive`). `--notify` = desktop notification (deduped by an actionable-problem signature). `--quiet` = silent when healthy. Test against a fake state dir with `SKATESPOTS_STATE_DIR=…`.
|
||
- ⚠️ `git push --dry-run` does **NOT** exercise signing (prints "Everything up-to-date", never signs) — do not use it as a signer probe.
|
||
- **Runs automatically every 15 min:** `skatespots-ops-health.timer` → `skatespots-ops-health.service` (`--quiet --notify`). Install/refresh with `scripts/install-ops-health-timer.sh`; logs `journalctl --user -u skatespots-ops-health`.
|
||
- **Web (manual)**: `npm run build` produces the web `dist/` (with `_headers`/`_redirects`); deploy by dragging `dist/` to the Netlify site's Deploys tab.
|
||
- **Zapstore (Android)**, from the repo root: `SIGN_WITH='bunker://...' zsp publish zapstore.yaml`
|
||
- `zsp publish --wizard` is **broken for this app** (the wizard can't parse a nostrhub web `repository` — it supports only GitHub/GitLab/Gitea/F-Droid/local). Always pass `zapstore.yaml`.
|
||
- No NIP-07 browser extension is installed, so `SIGN_WITH=browser` fails. Use `bunker://…` (Amber) or `nsec1…`.
|
||
- `zapstore.yaml` sets `release_source` → local APK, `release_notes` → `release-notes.txt`, `icon` → `public/images/icon-1024.png`.
|
||
- Dry run (no upload): `SIGN_WITH='<signer>' zsp publish --offline zapstore.yaml`.
|
||
- **Full publish sequence** (run from the repo root; replace `1.5.13` and the signer):
|
||
```bash
|
||
# 1) bump the version — package.json is the single source of truth
|
||
npm version 1.5.13 --no-git-tag-version
|
||
# 2) edit the TOP of release-notes.txt (start with the notes, NOT "vX.Y.Z")
|
||
# 3) build the signed release APK (syncs the version into the APK)
|
||
npm run android:build
|
||
# 4) dry run — parses + signs, uploads nothing
|
||
SIGN_WITH='bunker://<amber-connection>' zsp publish --offline zapstore.yaml
|
||
# 5) publish for real
|
||
SIGN_WITH='bunker://<amber-connection>' zsp publish zapstore.yaml
|
||
```
|
||
⚠️ `zsp` reads the **version from the built APK** (synced from `package.json` by
|
||
`android:build`) — bump **only** `package.json`. Publish needs the **signer awake**
|
||
(open Amber in the foreground). `SIGN_WITH=browser` fails here (no NIP-07 extension).
|
||
⚠️ **`zsp publish` prompts interactively** (signer/confirm) — with no TTY it dies
|
||
with `prompt failed: … /dev/tty`. Always pass **`--quiet`** (auto-yes, no
|
||
spinners) and, for a dry run, **`--offline`** (signs, uploads nothing; events to
|
||
stdout). ⚠️ The env file **`~/.config/skatespots/zsp.env`** (600, outside the
|
||
repo) holds `SIGN_WITH` — **as of 2026-10-12 it was an empty template**
|
||
(`SIGN_WITH=` with no value), so publishing requires filling it (or exporting
|
||
SIGN_WITH in the shell). ⚠️ **Do not `source` that file blindly**: a
|
||
`bunker://<pk>?relay=…&secret=…` value contains `&`, which backgrounds the
|
||
assignment (`SIGN_WITH` ends up unset). Extract it safely instead:
|
||
`SIGN_WITH="$(sed -n 's/^SIGN_WITH=//p' ~/.config/skatespots/zsp.env)"; export SIGN_WITH`.
|
||
Complete non-interactive publish:
|
||
`SIGN_WITH="$(sed -n 's/^SIGN_WITH=//p' ~/.config/skatespots/zsp.env)"; export SIGN_WITH; zsp publish --quiet zapstore.yaml`
|
||
✅ **Published 1.6.1–1.7.1 on 2026-10-12/27** — use the protected
|
||
`~/.config/skatespots/zsp.env` (mode 600) via **`scripts/release.sh`**;
|
||
the old inline `SIGN_WITH=<nsec1…>` path still works but should not be used.
|
||
Verify the signer is the app identity first (`nak key public` →
|
||
`npub1yrnuj…` / hex `20e7c953…`). Use **`~/go/bin/zsp`** (v0.4.11) — the
|
||
`zsp` on `PATH` (`~/.local/bin/zsp`) is older (v0.4.10). `zsp` warns that a
|
||
nsec in env is insecure (prefer `bunker://`); the relay event to check is
|
||
`nak req -k 30063 -d org.skatespots.app@<version> wss://relay.zapstore.dev`.
|
||
- **1.7.1 (2026-10-27):** Hindi + Catalan/Basque/Galician (24 dictionaries /
|
||
25 codes), regional flag SVGs + English/Portuguese variants, single-world
|
||
map clamp with horizontal wrap to the Pacific, menu hardening; APK sha256
|
||
`846925af…`, versionCode 10701, released with `scripts/release.sh`.
|
||
- **1.7.2 (2026-10-27):** Basque translation repaired (Marian tokenizer IDs +
|
||
EOS + one-time cache eviction), Cordn private-chat polish (auto-check,
|
||
3-step onboarding, status/retry, new-device setup, member roles, send
|
||
states, invite QR/deep link, dismissible banner), coordinator announce
|
||
list trimmed to the community relay. APK sha256 `a25ad33c…`, versionCode
|
||
10702.
|
||
- **1.7.3 (2026-10-27):** native invite QR uses the public
|
||
`https://skate-spots.org` URL instead of Capacitor's `https://localhost`.
|
||
APK sha256 `0448a1c7…`, versionCode 10703.
|
||
- **1.7.4 (2026-10-27):** app-wide **SkateSpots Community** Cordn room
|
||
(`/#/community`) with an always-on auto-approving bot, admin fallback and
|
||
external Cordn link; menu audit fixes (mobile language sub-view, crew-tab
|
||
truncation, touch targets); Cordn share link. APK sha256 `d93b9cc4…`,
|
||
versionCode 10704. Web forced live at the same commit.
|
||
- **1.7.5 (2026-10-27):** auth dialogs are owned by `Layout` and open at
|
||
z-2200 (above the z-2100 burger sheet); opening login/signup from the
|
||
sheet closes it first. Welcome/Log In/Create Account switch without
|
||
stacking dialogs; the desktop welcome prompt no longer races an in-progress
|
||
sign-in. Language selection returns to the menu. APK sha256 `bdb25384…`,
|
||
versionCode 10705. Web forced live.
|
||
- **1.7.6 (2026-10-28):** auth is app-wide via `AuthDialogContext` (Layout owns
|
||
the dialogs), so signup survives the conditional unmount of any page-card
|
||
`LoginArea`; Esc/keyboard close wired explicitly; language search resets on
|
||
close; trip-itinerary URLs sanitized. APK sha256 `d8cf3e4a…`, versionCode
|
||
10706. Web forced live.
|
||
- **1.7.7 (2026-10-28):** native private-chat tab shows an unread dot and a
|
||
founder request dot; the crew panel is force-mounted (hidden) so the
|
||
passive nsec/bunker poll can keep those fresh. Pending join requests are
|
||
remembered per account+room for 30 days and resume auto-check after a
|
||
reload. Background signing is gated to nsec/bunker only (Amber/extension
|
||
users are not prompted behind their back); quiet request polling never
|
||
toggles the composer. APK sha256 `009dcf41…`, versionCode 10707. Web
|
||
forced live.
|
||
- **1.7.8 (2026-10-28):** two-device QR handoff (new device publishes a key
|
||
package; an existing member scans and adds it without a join request),
|
||
MLS member removal, founder link to Edit Crew for admin roles, message
|
||
copy/reply, and a live Cordn benchmark. Dependency overrides pin
|
||
`protobufjs@7.6.3` and `sharp@0.35.5`; `npm audit` is now **0 critical,
|
||
1 high (write-only xlsx)**, 9 dev-only moderate. APK sha256 `960052b8…`,
|
||
versionCode 10708. Web forced live.
|
||
- **1.7.9 (2026-10-28):** weekly newsletter automation (generated drafts,
|
||
rotating roasts, current tech wires, Blossom cover from the Drop #006
|
||
foundation), admin Weekly draft ready banner + Load draft/Preview composer,
|
||
Monday 08:00 draft timer (auto-publish opt-in), and static/live issue
|
||
dedupe. APK sha256 `96b01ec8…`, versionCode 10709. Web forced live at
|
||
`d33f65e`. First draft: issue #7 (`kind 30078`,
|
||
`d=skatespots:newsletter:draft`) ready for review; not auto-published.
|
||
- **1.7.10 (2026-10-28):** the map footer links are permanently back (the
|
||
hide toggle is gone); inline founder promote/demote in the Private chat
|
||
member row; encrypted MLS message reactions, delete-for-everyone and read
|
||
receipts; the minute Cordn join watcher (NIP-17 DMs + encrypted bell
|
||
cards); and the browser translation-matrix runner. First matrix evidence:
|
||
en↔es ~54s, en↔ca ~21s, Basque downloads >25min — a real M2M100-WASM
|
||
performance problem. APK sha256 `9b48e47c…`, versionCode 10710. Web forced
|
||
live at `775d1f9`. The watcher timer is installed and runs every minute;
|
||
its key is `82ce71cf…`, state in `~/.local/state/skatespots-cordn-watcher`.
|
||
Low-end/APK translation verification is still open.
|
||
- **1.7.11 (2026-10-28):** performance pass — batched author profiles (one
|
||
relay round-trip per 100 authors, not 3 per card), map layers gated by the
|
||
actual toggles (Events/Crews no longer fetch at startup), and the public
|
||
query persistence allow-list aligned with real keys (crews, tours, shops,
|
||
newsletter). No analytics/vendor/server shortcut. APK sha256 `a5d99ab9…`,
|
||
versionCode 10711. Web forced live at `c903a51`.
|
||
- **1.7.12 (2026-10-28):** the notification bell is code-split into
|
||
`NotificationBellLazy` (idle-mounted or loaded on tap) so its five data
|
||
sources no longer compete with the map/route queries on first paint. The
|
||
bell button is a lightweight placeholder until then. APK sha256
|
||
`34195aa3…`, versionCode 10712. Web forced live at `e75a50c`.
|
||
- **1.7.13 (2026-10-28):** `ImageWithFallback` defaults to `loading=lazy` /
|
||
`decoding=async`; `SpotCard` too. The bell now uses `useCrewsByMember`
|
||
(`#p` query) instead of the full 200-crew directory; the full query only
|
||
runs when an admin has an incoming join request. APK sha256 `591e5779…`,
|
||
versionCode 10713. Web forced live at `72d9cd7`.
|
||
- **1.7.14 (2026-10-28):** repository slim-down — removed the 1,295 tracked
|
||
Gradle build files, deleted unused `sticker-design.png`, recompressed
|
||
large logo/cover/guide images, and removed the unused threaded ORT WASM
|
||
(worker is single-threaded; SIMD + non-SIMD remain). README rewritten.
|
||
Tracked tree ~47 MB; `git gc` pack ~37.6 MB. APK sha256 `5478df1e…`,
|
||
versionCode 10714. Web forced live at `752d104`. No feature removed.
|
||
- **1.7.15 (2026-10-28):** Amber-friendly Cordn join notices (encrypted bell
|
||
notices are only decrypted when the bell is opened; passive signers still
|
||
preload) and a refreshed Zapstore listing (screenshots, current feature
|
||
copy, wider NIP list, publisher pubkey guard). APK sha256 `7061f5c2…`,
|
||
versionCode 10715. Web forced live at `c0e8938`.
|
||
- **1.7.16 (2026-10-28):** Zapstore listing media refresh — fresh map/spot
|
||
screenshots and the community room instead of the empty marketplace image;
|
||
screenshots compressed. No app code change; version bump only to publish
|
||
the listing metadata. APK sha256 `7e152574…`, versionCode 10716. Web
|
||
forced live at `2be49ef`.
|
||
- **1.7.17 (2026-10-28):** Android hardware Back (close overlay → history →
|
||
minimise), verified `https://skate-spots.org` App Links + custom scheme via
|
||
`@capacitor/app` and `public/.well-known/assetlinks.json`; web app-shell
|
||
service worker in `push-sw.js` (static cache + network-first navigation).
|
||
APK sha256 `36f20525…`, versionCode 10717. Web forced live at `93e1f5e`.
|
||
⚠️ Device Back/App Link verification still needs a real phone.
|
||
- **1.7.18 (2026-10-28):** Amber prompt-budget card on `/signers` (explains
|
||
which actions prompt, group-DM per-recipient cost, Cordn coordinator
|
||
requests, and when to use a bunker/local key); service-worker and
|
||
assetlinks cache headers tightened. APK sha256 `7a2a7cce…`, versionCode
|
||
10718. Web forced live at `0a13f21`.
|
||
- **1.7.19 (2026-10-28):** global command palette (`Cmd/Ctrl+K` or header
|
||
search) across spots, shops, crews, events, gear, skaters and pages;
|
||
recent searches are local; datasets are queried only while the palette is
|
||
open. Browser Back/Forward scroll restoration; Events/Crews map-layer
|
||
choices persist. APK sha256 `e8e38ba8…`, versionCode 10719. Web forced live
|
||
at `959999d`.
|
||
- **1.7.20 (2026-10-28):** NIP-17 **group DMs** get the same unread dot /
|
||
tinted card as 1:1 DMs and mark read when opened (`GroupConversationPage`
|
||
uses `getGroupId` + `useDMRead`). APK sha256 `1eecf9f4…`, versionCode
|
||
10720. Web forced live at `b7f3fd7`.
|
||
- **1.7.21 (2026-10-28):** envelope-only DM unread in the bell. A new
|
||
`useDmUnreadSummary` counts gift-wrap ids not seen locally (no decrypt, no
|
||
Amber prompt), excludes this device's own outbound wraps
|
||
(`lib/dmOwnWraps.ts`), and shows a Messages row/link with a badge; opening
|
||
Messages acknowledges. 1:1 + group unread dots clear on read. APK sha256
|
||
`8d658e5b…`, versionCode 10721. Web forced live at `9baa1bd`.
|
||
- **1.7.22 (2026-10-28):** map remembers center/zoom
|
||
(`skatespots:map-view`), in addition to Events/Crews layer persistence;
|
||
first-run checklist includes the Community room. APK sha256 `614b0387…`,
|
||
versionCode 10722. Web forced live at `c163666`.
|
||
- **1.7.23 (2026-10-29):** first-run tip made frictionless — only optional
|
||
Near Me + Got it; no Add Spot/Crew/Wallet/Community CTAs and no crew query.
|
||
Inline **Mute** added to spot/event/tour/gear card menus
|
||
(`components/MuteMenuItem.tsx`, NIP-51 mute list); report + author hidden
|
||
notice already existed. APK sha256 `d49837e7…`, versionCode 10723. Web
|
||
forced live at `d729f4369`.
|
||
- **1.7.24 (2026-10-29):** Gear Swap saved searches
|
||
(`skatespots:gear-saved-searches`, apply/delete), quick offer-note
|
||
templates (`marketplaceExtrasUi`), and a collapsible buyer/seller safety
|
||
guide linking `/marketplace-guidelines`. APK sha256 `3ddee2f7…`,
|
||
versionCode 10724. Web forced live at `ca1e5a2`.
|
||
- **1.7.25 (2026-10-29):** event detail shows the viewer's IANA timezone
|
||
under the local time, and the Gear Swap load-error card has a real Retry.
|
||
APK sha256 `b528e500…`, versionCode 10725. Web forced live at `fc7060a`.
|
||
- **1.7.26 (2026-10-29):** per-thread mute for 1:1 and group DMs
|
||
(`hooks/useDmMute.ts`, device-local, header toggle + inbox unread
|
||
suppression), card **Unmute** to undo a NIP-51 mute, event **Pass**
|
||
shortcut (`/card?type=event&event=<id>`) for organisers and RSVP'd
|
||
users, 200% text header truncation, and one quiet *Find a crew* link in
|
||
the first-run tip. APK sha256 `7948f2a6…`, versionCode 10726. Web
|
||
forced live at `444597b`.
|
||
- **1.7.27 (2026-10-29):** mobile map **Local Chat** icon button got an
|
||
accessible name; first-run close/Find a crew controls got a visible
|
||
keyboard focus ring; the bell's encrypted-message row is now
|
||
**“Messages & crew chats”** (crew chat is NIP-17 group envelopes, so it
|
||
is included in that single envelope-only count — muted threads still
|
||
cannot be subtracted without decrypting). APK sha256 `1b3c7379…`,
|
||
versionCode 10727. Web forced live at `d6f8345`.
|
||
- **1.7.28 (2026-10-29):** `/c` now redirects into the app Community room
|
||
instead of Cordn web; community join copy names the bot/admin, adds
|
||
**Request again**, a second-device **Link device** hint and an admin
|
||
pending-request banner. The community bot now retires handled/stale
|
||
requests (fixes endless "Join request sent"/already-member retries),
|
||
prefers `kp_ref`, logs pending queues, and caps at 250 members / 5
|
||
devices per account. APK sha256 `03d47681…`, versionCode 10728. Web
|
||
forced live at `69ce773`.
|
||
- **1.7.29 (2026-10-29):** Advanced Cordn web link now warns that Cordn
|
||
web may stall; admins get a “Request handled” confirmation. Added
|
||
`scripts/deploy-community-bot.sh` (build → backup → upload → restart →
|
||
refresh the local 0600 state backup) and `ops-health.sh` now checks the
|
||
remote `skatespots-community` service. APK sha256 `c6f82fb5…`, versionCode
|
||
10729. Web forced live at `e02f2b4`.
|
||
- **1.7.30 (2026-10-29):** `/cordn` also redirects to the app; the external
|
||
Cordn open link is removed (copy-only). Approving a device now asks for
|
||
confirmation. Corrected the second-device hint (Link device or re-request
|
||
with the new device) and added the bot’s 30/hour approval brake plus daily
|
||
30-snapshot local/restic state backups. APK sha256 `51dfe67e…`, versionCode
|
||
10730. Web forced live at `bde3d2484`.
|
||
- **1.7.31 (2026-10-29):** animated orange-wheel launch splash (roll-in +
|
||
spin + sparks; reduced-motion static), native Android splash regenerated
|
||
from `resources/splash.png` instead of the stock blue placeholder, Cordn
|
||
**Private rooms** in Messages (Community + crews, Open/Set up, local
|
||
unread dots), per-room mute, in-room search, coordinator last-checked,
|
||
Chat-vs-Private explainer, approve/remove confirmations, and a device-local
|
||
Cordn outbox with bounded auto-retry. APK sha256 `d84b57de…`, versionCode
|
||
10731. Web forced live (manual `netlify deploy`; the release-script deploy
|
||
hit a transient build failure).
|
||
- **1.7.32 (2026-10-29):** sponsor-ready profiles — Follow/Unfollow,
|
||
Followers/Following counts, Events hosted (`useUserEvents`), a balanced
|
||
six-cell stats strip, and a sponsor/collab card with audience stats,
|
||
prefilled collab DM and Rider Card link. NIP-05 shows a verifying state;
|
||
public stash images use the shared fallback. APK sha256 `31aced78…`,
|
||
versionCode 10732. Web forced live (manual `netlify deploy`; the
|
||
release-script deploy still hits the Netlify build failure).
|
||
- **1.7.33 (2026-10-29):** explicit profile **Open to sponsorships** opt-in
|
||
(`sponsorships_open` + `sponsorships_note` in kind-0; only shown when
|
||
opted in), plus a Bitcoin grantmaker inquiry form on `/supporters`.
|
||
The form opens a private DM to the owner and stores nothing; its funny
|
||
disclosure says KYC is for grantees, not for SkateSpots. Followers stat
|
||
now has a “relay-reported, not audited” info hint. APK sha256
|
||
`fd6ffb8b…`, versionCode 10733. Web forced live (manual
|
||
`netlify deploy`; the release-script deploy still hits the Netlify build
|
||
failure).
|
||
- **1.7.34 (2026-10-29):** follower stat layout fixed (2/3/6 responsive
|
||
columns; relay-reported caveat moved to a footnote instead of an edge
|
||
tooltip), and the Bitcoin grantmaker section gained explicit ethos and
|
||
privacy boundaries (no KYC of SkateSpots, no tracking/CRM, private
|
||
NIP-17 DM, open-source-only grants). APK sha256 `a23e8c3d…`, versionCode
|
||
10734. Web forced live at `d3d82f7`.
|
||
- **1.7.35 (2026-10-29):** replaced the floating mobile “Links” pill over
|
||
the bottom nav with a slim always-visible footer strip. APK sha256
|
||
`d3cfdc3d…`, versionCode 10735. Web forced live at `5d40a67`.
|
||
- **1.7.36 (2026-10-29):** new **Transport / Driver** Lab craft (kind
|
||
`35016`, self-tag `skatetransport`) — vehicles, services, capabilities,
|
||
capacity and coverage; `/lab?craft=transport` filters; a self-attested
|
||
notice (no licence/insurance/operator verification); WoT/vouches/reviews
|
||
inherited from the shared Lab detail; stats/impact counts. All 24 locales
|
||
100%. APK sha256 `53286e0f…`, versionCode 10736. Web forced live at
|
||
`b0f6dd9`.
|
||
- **1.7.37 (2026-10-29):** Cordn coordinator client tolerates empty tool
|
||
responses: reads `structuredContent` or JSON text, and falls back to
|
||
`{requests:[]}`/`{welcomes:[]}`/`{messages:[]}`/`{keyPackages:[]}` instead
|
||
of a Zod “expected object, received undefined” error. APK sha256
|
||
`6cacedc0…`, versionCode 10737. Web forced live at `b94ac3d`.
|
||
- **1.7.38 (2026-10-29):** audit pass — follower query limit 100→500;
|
||
Clothing Designers added to `/stats` + Supporters impact; Sasquatch FAB
|
||
hidden on `/become`, create/edit and `/supporters` forms; protobufjs
|
||
override 7.6.5 (only remaining production advisory is write-only xlsx).
|
||
APK sha256 `c5975f8b…`, versionCode 10738. Web forced live at `41f420a`.
|
||
- **1.7.39 (2026-10-30):** replaced write-only SheetJS `xlsx` with a small
|
||
local OOXML writer (`lib/xlsx.ts` + fflate); `npm audit --omit=dev` is now
|
||
**0 vulnerabilities**. Personal profiles have a **Lab services** strip
|
||
linking their Lab profiles. Added parser/XLSX tests (156 tests). APK
|
||
sha256 `1202aded…`, versionCode 10739. Web forced live at `6aba9b3`.
|
||
- **1.7.40 (2026-10-30):** native boot fix for the white/black reload loop —
|
||
`boot-guard.js` and `registerAppServiceWorker` clear stale
|
||
`skate-spots-shell-*` service workers/caches on the Capacitor
|
||
`https://localhost` origin before the bundle runs. APK sha256 `162488b5…`,
|
||
versionCode 10740. Web forced live at `44be542`.
|
||
- **1.7.41 (2026-10-30):** app-shell cache bumped to v3; `/boot-guard.js` is
|
||
never cached by the worker and web registration uses
|
||
`updateViaCache: 'none'`, so the cleanup cannot be trapped by an old
|
||
worker. APK sha256 `a32d56bb…`, versionCode 10741. Web forced live at
|
||
`c36bf2f`.
|
||
- **1.7.42 (2026-10-30):** black document background before CSS (no native
|
||
white flash) and a cache-busted boot-guard URL. APK sha256 `5baf7129…`,
|
||
versionCode 10742. Web forced live at `7dc8c2c`.
|
||
- **1.7.43 (2026-10-30):** boot-guard reload-loop breaker — three loads
|
||
within a minute clear the app-shell worker/cache and reload once into a
|
||
clean shell. APK sha256 `d25e28a4…`, versionCode 10743. Web forced live at
|
||
`1a88f86`.
|
||
- **1.7.44 (2026-10-30):** native service-worker cleanup fully
|
||
feature-checked/wrapped; it can no longer abort boot on Android WebViews
|
||
with a partial Service Worker API. APK sha256 `6193ab1b…`, versionCode
|
||
10744. Web forced live at `c66aeb5`.
|
||
- **1.7.45 (2026-10-30):** `MapPage` is eager instead of a lazy Suspense
|
||
boundary; slow/hung native chunk import could leave the black PageLoader
|
||
forever. APK sha256 `bd6a498b…`, versionCode 10745. Web forced live at
|
||
`afd5a09`.
|
||
- **1.7.46 (2026-10-30):** smoother open transition — logo starts near the
|
||
native splash centre and settles into the wordmark column; splash content
|
||
lifts/softens on exit while the app stays still. APK sha256 `89c07493…`,
|
||
versionCode 10746. Web forced live via manual `netlify deploy` at
|
||
`008fb81`.
|
||
- **1.7.47 (2026-10-30):** faster open (~0.7s typical, 0.9s cap); logo no
|
||
longer spins — it drops in once with a single spark burst — and the exit
|
||
is an aperture iris closing into the logo, with no dead black frame.
|
||
APK sha256 `d4b0dd74…`, versionCode 10747. Web forced live at `e22e2ec`.
|
||
- **1.7.48 (2026-10-30):** Amber decrypt loops now honour query
|
||
cancellation — leaving the DM inbox or closing the bell stops the next
|
||
Amber prompt instead of continuing through every remaining message.
|
||
The seal half of a DM decrypt is cached when a two-step decrypt is cut
|
||
short. APK sha256 `a74f21de…`, versionCode 10748. Web forced live at
|
||
`3d905af`.
|
||
- **1.7.49 (2026-10-30):** Cordn setup is single-flight, so a double-tap on
|
||
“Set up” cannot launch overlapping coordinator/Amber flows. Amber users
|
||
see the two real setup steps (device key → join request); each coordinator
|
||
request is a separate approval. APK sha256 `9dee0c4d…`, versionCode 10749.
|
||
Web forced live at `7958ca5`.
|
||
- **1.7.50 (2026-10-30):** performance/navigation/Amber polish. Web entry
|
||
shrank from ~863 KB to ~648 KB gzip: the map is web-lazy (native remains
|
||
eager via a Capacitor Vite alias), the Cashu/Zap dialog is loaded on first
|
||
tap, and primary routes prefetch on hover/touch. Page-level Suspense keeps
|
||
the header/bottom nav mounted with a delayed skeleton instead of a
|
||
full-screen black loader; the burger closes on route changes. Native Amber
|
||
calls are serialized/coalesced and decrypt results are memory-cached.
|
||
APK sha256 `d338c158…`, versionCode 10750. Web live at `65a7f0b`.
|
||
Production dependency audit: 0 vulnerabilities.
|
||
- **1.7.51 (2026-10-30):** profile/payment polish. The profile tip card no
|
||
longer renders empty when neither method exists; Lightning addresses accept
|
||
a pasted `lightning:` prefix and the profile row is tap-to-copy; CLINK codes
|
||
are canonicalised on save in profile/shop/crew/Lab forms; marketplace
|
||
listing detail surfaces a seller CLINK tip when their profile has one.
|
||
CLINK remains a wallet handoff, not an in-app rail. APK sha256 `ac433d91…`,
|
||
versionCode 10751. Web live at `c65e6ef`.
|
||
- **1.7.52 (2026-10-30):** compact tabbed `/supporters` page (Support /
|
||
Supporters / Community / Grants). The three funding rails are now a
|
||
one-at-a-time selector; impact, pay-for cards, partners/friends and the
|
||
grantmaker form keep all content with tighter mobile spacing. `/support`
|
||
redirects to `/supporters`. APK sha256 `acd581a2…`, versionCode 10752.
|
||
Web live at `69a8649`.
|
||
- **1.7.53 (2026-10-30):** Cordn/Staircase reliability. Newlay coordinator
|
||
budget raised (300/min, burst 120, concurrency 64), client polling now
|
||
backs off with jitter and polls hidden tabs half as often; relay health
|
||
alerts cover Cordn 429s/announce/TLS; Settings shows per-relay health pills;
|
||
Crew coordinator card adds a Staircase/Cordn QR. APK sha256 `f124669c…`,
|
||
versionCode 10753. Web live at `95ff486`.
|
||
- **1.7.54 (2026-10-30):** image-only Blossom quotas. Newlay per-object cap
|
||
10 MiB, signed per-npub quota proxy (50/250 MiB daily, admins 3×, per-IP
|
||
hourly backstop, 20 GB relay cap), app-side image/type/size guard and clear
|
||
errors; uploads use only our Blossom (mirrors are read-only fallbacks).
|
||
APK sha256 `f554d934…`, versionCode 10754. Web live at `465683c`.
|
||
- **1.7.55 (2026-10-30):** private read-only `/ops` status page for the two
|
||
relay admins, NIP-98-signed status endpoint fed by the host health timer;
|
||
admin link in Settings → Advanced. Added HSTS to relay/blossom and CAA
|
||
records limiting their certificates to Let’s Encrypt. APK sha256
|
||
`1142c425…`, versionCode 10755. Web live at `52a8447`.
|
||
- **1.7.56 (2026-10-30):** mobile nav simplified to five intents — Spots,
|
||
The Lab, Events, Crews, More. Profile/login stays in the header avatar;
|
||
Mints and all secondary sections moved to More. Customizer edits three
|
||
middle slots; desktop default primary tabs use Community instead of Mints.
|
||
APK sha256 `ec15b80d…`, versionCode 10756. Web live at `cdf4a63`.
|
||
|
||
### Relay archive (dataset backup)
|
||
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**
|
||
re-signs one key's addressable **spots (30818) + shops (30017)** under another
|
||
key and publishes (optionally NIP-09-deleting the originals). Addressable events
|
||
are author-signed, so a "transfer" needs **both** keys — `TO_NSEC` for the new
|
||
events, `FROM_NSEC` only with `--delete-originals`. `--dry-run` (default) needs
|
||
no keys. ⚠️ A claimed/derived event can't be moved without the author's key;
|
||
the app's `/my data` can delete originals via the logged-in (Amber) signer.
|
||
- `scripts/archive-relay.mjs` snapshots **the app's own content** off the
|
||
community relay to gzipped JSONL of the raw signed events (restorable to any
|
||
relay, signatures intact). Default dir `~/.backups/relay` (override with
|
||
`SKATESPOTS_ARCHIVE_DIR`), keeps the last 12, and writes `manifest-*.json` +
|
||
`latest.json` (counts by kind, author count, sha256, byte size).
|
||
- ⚠️ **The legacy hosted relay `goskatespots.nostr1.com` is PUBLIC with general traffic**
|
||
(verified 2026-09-28): 15.7k kind-0 profiles, mostly non-skaters; 2k kind-30078
|
||
app-data (Armada/Plebs/Amethyst); 379 kind-30023 articles (Yakihonne); 408
|
||
NIP-99 listings (Shopstr). So the archive is deliberately **scoped** — exclusive
|
||
kinds wholesale (30818/30017/30823/35xxx/…), shared kinds only with the app's
|
||
own tags (`t=skateboarding` for events + listings, `t=skatespots` collections,
|
||
`t=skatespotsnews` newsletters, `d=skatespots*` app data), `org.skatespots.*`
|
||
labels, reports/RSVPs that reference archived content, and kind-0 profiles for
|
||
archived authors only. Result: ~114 events / ~63 KiB. **Never archive the whole
|
||
relay** — it is strangers' data, and a restore would republish it.
|
||
- ⚠️ **Query ONE kind at a time, and stop on "no new events", not on a short
|
||
page.** A single filter covering many kinds comes back truncated (once kept
|
||
only 3 of 54 spots), and `querySync` can resolve early on a large page (lost
|
||
~150 of 3.3k profiles). Paginate with `until` until a page adds nothing.
|
||
- `scripts/restore-relay.mjs <archive.jsonl.gz> --dry-run` re-verifies every
|
||
signature; `--to wss://relay` republishes. There is **no default target** —
|
||
restoring is deliberate, and the mistake to avoid is publishing a snapshot back
|
||
to the live relay. Use `scripts/drill-relay.mjs` as the throwaway target.
|
||
- Offsite: `scripts/backup-relay.sh` runs the archive, then `restic backup` +
|
||
`forget --prune` **if** `~/.config/skatespots/restic.env` exists (template:
|
||
`scripts/restic.env.example`). **Configured (2026-09-28):** the **CVM host over
|
||
SFTP**, repo `sftp:skatespots-cvm:/home/ubuntu/skatespots-backups/relay`, via an
|
||
`~/.ssh/config` alias `skatespots-cvm` (HostName/User/IdentityFile from
|
||
`~/.config/skatespots/cvm.env`) so no key/host lives in tracked files.
|
||
`~/.config/skatespots/restic.env` (600, outside the repo) holds the repo URL +
|
||
the generated `RESTIC_PASSWORD` — **that password is required to restore; keep a
|
||
copy in a password manager**. A second offsite is supported: put another repo
|
||
(e.g. Cloudflare R2 / Backblaze B2) in **`~/.config/skatespots/restic-2.env`**
|
||
and `backup-relay.sh` pushes to **both** — true 3-2-1 (local + CVM + object
|
||
storage). Verified 2026-09-28 with a throwaway second repo.
|
||
- ⚠️ **Don't run restic under a restrictive `umask`.** restic creates its cache
|
||
dirs from the umask, and a mode-600 dir has no execute bit → "permission
|
||
denied" on `~/.cache/restic/<repo>/version`. If it happens:
|
||
`find ~/.cache/restic -type d ! -perm -u+x -exec chmod u+rwx {} +`.
|
||
- **Restore drill (proven 2026-09-28):** `node scripts/drill-relay.mjs [port]`
|
||
starts a throwaway in-memory NIP-01 relay (`ws`, no persistence); then
|
||
`node scripts/restore-relay.mjs <archive> --to ws://localhost:<port>` and query
|
||
it back. Latest run: **114/114 events restored, 0 missing**. Re-run after any
|
||
archive-format change, or quarterly.
|
||
- `restic check --read-data-subset=10%` was clean after the first offsite push
|
||
(do this monthly to catch bitrot, not just existence).
|
||
- Weekly systemd user timer: `scripts/install-archive-timer.sh` copies
|
||
`scripts/systemd/skatespots-archive.{service,timer}` into `~/.config/systemd/user`
|
||
and enables them. Logs: `journalctl --user -u skatespots-archive`.
|
||
- Commits touching only `scripts/`, `*.md`, `cvm-server/`, `skills/`, `.agents/`
|
||
are **skipped by the deploy timer** → no Netlify credits spent.
|
||
- **XLSX writer without SheetJS (2026-10-30):** the only SheetJS use was the
|
||
deck-cost **export** (write-only). It is now `src/lib/xlsx.ts` using `fflate`
|
||
to emit a minimal OOXML workbook (inline strings, multi-sheet, safe names).
|
||
`xlsx` was removed; production `npm audit --omit=dev` is **0**. Keep the
|
||
writer write-only; never parse untrusted XLSX with it.
|
||
- **Dependency updates + vulnerability scan (2026-10-12):** `scripts/update-deps.sh`
|
||
runs `npm outdated` + `npm audit` (dated report in `~/.local/state/skatespots-deps/`),
|
||
applies **non-breaking** updates only (`npm update` + `npm audit fix`, never
|
||
`--force`), then gates on `npm test` + `smoke-test.mjs`: **green → commit + push**
|
||
(the deploy timer ships it); **red → restores the previous lockfile**. Self-gates
|
||
to one real run per **20 days** (`DEPS_INTERVAL_DAYS`), skips on a dirty tree, and
|
||
bounds the push (`timeout 180`) since the git remote signs via the remote signer.
|
||
Timer: `scripts/install-deps-timer.sh` → `skatespots-deps.timer` (daily tick).
|
||
Flags: `--scan-only`, `--no-push`, `--force`. Logs: `journalctl --user -u skatespots-deps`.
|
||
**osv-scanner added (2026-10-26):** the scan block now also runs
|
||
`osv-scanner scan source -L package-lock.json` (binary at
|
||
`~/go/bin/osv-scanner`, installed with `go install
|
||
github.com/google/osv-scanner/v2/cmd/osv-scanner@latest`; override with
|
||
`OSV_BIN`), pre- and post-update, report-only. ⚠️ It immediately found **107
|
||
known vulnerabilities (5 critical, 53 high)** that `npm audit` was NOT
|
||
surfacing — mostly **dev-only** `vite`/`vitest` (patchable within-range) plus
|
||
`xlsx@0.18.5` (two high; **no fix published**) and `zod` (patch). Dev-dep CVEs
|
||
don't ship to users; `xlsx` is lazy-loaded for exports. Run `--force` to apply
|
||
the fixable ones (gated on tests).
|
||
⚠️ **`--force` on 2026-10-26 failed the gate and auto-reverted:** the blind
|
||
`npm update` cut OSV findings 107 → 13 but produced **duplicate transitive
|
||
copies** (`@tanstack/query-core` nested under `react-query-persist-client`;
|
||
`@nostrify/nostrify`/`@nostrify/types` nested under `@nostrify/react`) and a
|
||
**TypeScript bump** (TS 5.9 typed-array errors in `stashBackup`/`usePushNotifications`),
|
||
so `tsc` failed. The remaining fixes are mostly **major** (vitest 4,
|
||
react-router 7, zod 4) plus **`xlsx@0.18.5` with no fix** (we only *write*
|
||
XLSX for export, so the parse-side CVEs likely don't apply — verify before
|
||
replacing). Lesson: do NOT rely on a blanket `npm update`; update in curated
|
||
batches (pin `typescript`, add `overrides`/`npm dedupe` for the duplicated
|
||
packages, keep majors as their own migrations) and let the timer gate each.
|
||
The job now also runs **`npm dedupe`** after `npm update` (to stop the duplicate
|
||
transitive copies that broke type identity).
|
||
✅ **Curated batch landed (2026-10-26):** pinned `typescript` **5.8.3 exact**,
|
||
updated `vite` **6.4.5**, `vitest` **3.2.7**, `@unhead/react`/`unhead`
|
||
**2.1.17**, `yaml` **2.9.1**, then `npm dedupe` — `npm test` green and OSV
|
||
**107 → 59** findings. Remaining are transitive/major (vitest 4, valibot 1,
|
||
uuid 11, zod 4) or **xlsx** (no fix — and it's **write-only**: only
|
||
`book_new`/`aoa_to_sheet`/`writeFile`, no `read`, so the parse CVEs don't
|
||
apply). `zapstore.yaml` license was `custom` (the listing showed "custom");
|
||
now **`AGPL-3.0-only`** (re-published in 1.6.8, verified on the 32267 tag).
|
||
On **failure** the job restores the lockfile and schedules a retry in **~3 days**
|
||
(a failed run no longer burns the whole 16-day window); every notification now
|
||
includes the **OSV total** ("N packages affected by M known vulnerabilities").
|
||
**Final security scan (2026-10-26):** researched the app-relevant advisories and
|
||
fixed them — **zod 3.25 → 4.6.7** (only `AppProvider` imports zod; no
|
||
zod-3-isms, zero TS changes) plus **@nostrify/nostrify 0.50.5 /
|
||
@nostrify/react 0.2.31 / @nostrify/types 0.36.9** (0.50 moved to zod 4), and the
|
||
safe `npm audit fix` transitives (dompurify 3.4.17, lodash 4.18.1,
|
||
@modelcontextprotocol/sdk 1.32.2). Then **react-router-dom 7.18.4** (v6 API
|
||
surface only — zero code changes; smoke covers all routes) and **vitest 5.0.5**
|
||
(dev-only; setup import switched to `@testing-library/jest-dom/vitest` for the
|
||
new expect types). **npm audit: 35 → 8** (0 critical, 1 high, 7 moderate).
|
||
Remaining: **xlsx** (no fix; we only *write* XLSX — parse CVEs don't apply) and
|
||
build-only tailwind/postcss/capacitor-cli/uuid. APK manifest is minimal
|
||
(INTERNET + coarse/fine location); `network_security_config` permits cleartext
|
||
**loopback only** (Citrine); CSP is strong (`default-src 'none'`, no
|
||
`unsafe-eval`, `object-src 'none'`, `frame-ancestors 'none'`); no tracked
|
||
secrets; the only `dangerouslySetInnerHTML` is shadcn's chart CSS-vars (static,
|
||
not user input). **Menu/a11y pass (same date):** added `aria-label`s to the
|
||
language trigger and the header settings icon (they had only `title`/a flag);
|
||
the desktop nav's `max-w-[1700px]` + `overflow-x-auto scrollbar-hide` +
|
||
`whitespace-nowrap` keeps the trailing **More** reachable, the mobile bottom
|
||
nav + burger append the full catalog, dropdowns have max-height + internal
|
||
scroll, and interactive targets meet the 44px rule. No other menu bugs found.
|
||
**Automation hardening (2026-10-27):** the dependency interval is now
|
||
**16 days** (`DEPS_INTERVAL_DAYS=16` in
|
||
`scripts/systemd/skatespots-deps.service` and the script default), plus a
|
||
separate **weekly scan-only** timer (`skatespots-deps-scan.timer`, Mon 06:00)
|
||
that reports npm/OSV findings without touching the lockfile. The deps service
|
||
has `TimeoutStartSec=30min` (the 90s default can kill a legitimate
|
||
update/test run) and both maintenance scripts resolve the newest nvm Node
|
||
instead of hard-coding `v22.23.1`. `@tanstack/query-core` is pinned with an
|
||
npm **override** to `$@tanstack/react-query`, so `npm update` cannot create a
|
||
second copy — the Oct 26 failure was duplicate `query-core` breaking tsc.
|
||
Failed runs now keep `test.log`/`smoke.log` + the attempted package files in
|
||
`~/.local/state/skatespots-deps/failures/` (8 kept, rotated) and a
|
||
`last-failure` marker; reports keep 12. Releases are one command via
|
||
**`scripts/release.sh`** (notes check → `npm test` → bump → Android build →
|
||
`zsp publish` → relay verification → commit/push + tag), reading the 0600
|
||
`~/.config/skatespots/zsp.env` by default; pass `--deploy` to also force the
|
||
web deploy. `scripts/ops-health.sh` now reports the dep timers, next-due,
|
||
latest report/OSV and `last-failure`. The deploy timer has
|
||
`TimeoutStartSec=20min`, all deploy/deps units are tracked in
|
||
`scripts/systemd/`, and `.nvmrc` pins Node 22. A 2026-10-27 scan-only run
|
||
reports **3 packages / 4 OSV findings** (0 critical; `xlsx` is write-only and
|
||
the rest is dev build tooling).
|
||
- **Pre-push + pre-deploy gate:** `scripts/preflight.sh` runs the app + server
|
||
typechecks → ESLint → i18n → vitest. It is installed as `.githooks/pre-push`
|
||
via `scripts/install-hooks.sh`, and `auto-deploy.sh` runs it before every
|
||
Netlify deploy; a failure records `preflight-failed` and `ops-health.sh`
|
||
flags it. Skip one push with `SKIP_PREFLIGHT=1` (or `git push --no-verify`).
|
||
The dependency and release scripts set `SKIP_PREFLIGHT=1` because they already
|
||
run the full `npm test` themselves.
|
||
- **Patch-only dependency fallback:** if the blanket `npm update` fails, the
|
||
script restores the lock and retries the **patch-level** outdated packages one
|
||
at a time, keeping those that pass the fast gate and skipping the rest; a
|
||
final `npm test` + boot smoke gates the result. If both the full and fallback
|
||
paths fail, it restores the original tree and schedules the ~3-day retry.
|
||
|
||
### Ops reports + admin alerts (2026-10-27)
|
||
- `scripts/ops-report.sh` prints a **plain-English** daily report (app version,
|
||
website state, dependency/security state, weekly scan). `--send` DMs it to
|
||
the two admins from `src/lib/admins.ts` (founder `npub1yrnuj…`, second admin
|
||
`npub1e2ay…`) via NIP-17 (`scripts/send-ops-report.ts`).
|
||
- Delivery mirrors the app: discover each admin's kind-10050 inbox, fall back to
|
||
NIP-65 write relays, always include `wss://relay.skate-spots.org`. One gift
|
||
wrap per recipient; the sender gets the copy. `OPS_DRY_RUN=1` prints
|
||
recipients/relays without publishing.
|
||
- `scripts/send-ops-alert.sh "…"` sends a short alert; wired into
|
||
`auto-deploy.sh` (preflight/smoke failure), `update-deps.sh` (full+fallback
|
||
failure) and `ops-health.sh --notify` (deduped by problem signature).
|
||
- **Push:** every admin copy (including the sender's) carries the app's
|
||
`["skatespots","push"]` marker, so the push service wakes the phone with the
|
||
app closed. A push test was sent 2026-10-27 (wraps `39fbbd4e…`/`c88c9f55…`).
|
||
- **Scan noise:** the weekly scan-only DM is sent only when the OSV summary
|
||
**changes**; unchanged scans are logged, not messaged.
|
||
- **Deploy alerts:** `auto-deploy.sh` alerts on a Netlify failure and on a
|
||
post-deploy `https://skate-spots.org/` health-check failure
|
||
(`deploy-failed` / `health-failed` show in ops-health and the daily report).
|
||
- `skatespots-ops-report.timer` runs daily at 07:00 (Persistent); install with
|
||
`scripts/install-ops-report-timer.sh`. A test report was DMed to both admins
|
||
on 2026-10-27 (kind 1059 on the community relay for each).
|
||
- `~/.config/skatespots/zsp.env` (mode 600) now actually holds `SIGN_WITH` (the
|
||
template was empty); `scripts/release.sh`, the alert sender and the ops report
|
||
read it and never print it. Old signer copies were removed from
|
||
`~/.bash_history`; clear any open shell's in-memory history with
|
||
`history -c; history -w` if it predates this.
|
||
|
||
### Message translation — on-device, all languages (2026-10-27)
|
||
- **Detector (all 24):** `src/lib/detectLanguage.ts` — **tinyld** (MIT,
|
||
lazy-loaded, local) + script rules (Devanagari→hi, Thai→th, kana→ja,
|
||
Hangul→ko, Han→zh-CN/TW, Greek→el) + a weighted **Catalan/Galician/Basque/
|
||
Spanish/Portuguese** rule for the short/close cases tinyld confuses. Callers
|
||
may pass a conversation/contact language as `prior`. No network/logs/storage.
|
||
**28/28 sample tests pass**, including one asserting `fetch` is never called.
|
||
- **Why not Lingua:** Lingua (Apache-2.0) compiles to a **99 MB** WASM with 24
|
||
languages — rejected as a detector. tinyld + the rule is small, MIT, and
|
||
covers all 24 (Galician comes from the rule; Lingua has no Galician).
|
||
- **Privacy invariant:** message text must **never** leave the device.
|
||
**No Chrome Translator, no server, no AI proxy/OpenRouter, no third-party
|
||
API.** Model files are public bytes; only the model is downloaded, never the
|
||
message.
|
||
- **All-language runtime (M2M100 + Basque Opus):**
|
||
- `Xenova/m2m100_418M` (MIT, **617 MB** q8) covers 23 app languages
|
||
(en/es/ca/gl/hi/th/ja/pt/fr/de/it/nl/pl/ru/ko/zh/uk/tr/vi/id/sv/cs/ro/el).
|
||
Basque is not in M2M100, so `Xenova/opus-mt-eu-en` + `en-eu` (Apache-2.0,
|
||
converted q8; ~374 MB each) pivot through English.
|
||
- Hosted at `https://relay.skate-spots.org/models/…` (Caddy
|
||
`handle_path /models/*` → `/srv/models`, CORS `*`; backup
|
||
`/etc/caddy/Caddyfile.bak-…`).
|
||
- **Verified from the host:** en↔es, ca↔gl, hi↔en, and **eu↔en after the
|
||
2026-10-27 fix**. The Basque packs are converted and hosted (ONNX q8).
|
||
Tested in Node against the same hosted tokenizer/ONNX bytes; outputs match
|
||
the original Python model. Two root causes: the ONNX **IR 9→8** mismatch,
|
||
and the converted `tokenizer.json` using SentencePiece IDs (special tokens
|
||
0/1/2) instead of the Marian model's `vocab.json` IDs — so `</s>` was never
|
||
recognized and generation looped. Rebuilt both tokenizer.json files in
|
||
model-vocab order with source-spm scores, special tokens at the model IDs,
|
||
and an EOS post-processor; also set Metaspace `add_prefix_space` so the first
|
||
word gets `▁`. Backups are on the model host.
|
||
- **Cache migration:** `src/lib/translationModelCache.ts` deletes only the old
|
||
cached `opus-mt-{eu-en,en-eu}` `tokenizer.json` entries on first Basque use
|
||
(the 390 MB ONNX stays cached). Unit-tested; the worker calls it before
|
||
loading a Basque translator.
|
||
- Runtime: **`@xenova/transformers` 2.17.2** + ONNX Runtime **1.14** WASM
|
||
self-hosted in `public/ort/` (37 MB, copied from `onnxruntime-web`), loaded
|
||
from `../ort/`; worker `src/workers/translate.worker.ts`. M2M100 is used
|
||
directly; Basque pivots eu→en→target / source→en→eu.
|
||
- **Hard guard:** `src/lib/translationGuard.ts` permits only model
|
||
GET/HEAD (no query string) and same-origin `/ort/` assets. Message text
|
||
only travels via `postMessage` to the local worker.
|
||
`translation.test.ts` + `translationGuard.test.ts` (9 tests) prove `fetch`
|
||
is never called with a sentinel message and every other request is blocked.
|
||
- UI: `TranslateMessage` in DMs (`ConversationPage`) and crew chat
|
||
(`CrewChat`); `TranslationSettings` in Settings → Appearance
|
||
(auto-translate **off** by default). Translations are **memory-only**;
|
||
reload/logout clears them + terminates the worker. First use downloads the
|
||
multilingual pack with progress. 12 `translateUi` keys in en/es + all 24
|
||
`drift.ts` (check 100%); the old “pilot” note is unused.
|
||
- **SpotChat was deliberately skipped** (owner request).
|
||
- **Provenance:** M2M100 is MIT open weights (Meta origin) but runs entirely
|
||
locally — nothing goes to Meta. Basque Opus is Apache-2.0. No Chrome,
|
||
server translation, AI proxy or third-party API.
|
||
- **Conversion:** Docker Python 3.11 + torch 2.4.1 + transformers 4.46.3 +
|
||
optimum 1.24; dynamic q8; `tokenizer.json` generated from the SentencePiece
|
||
models (needs protobuf + `sentencepiece_model_pb2.py`).
|
||
- **Browser matrix runner (2026-10-28):** `scripts/translation-matrix.mjs`
|
||
(`npm run translation:matrix`) serves `dist` and drives the built
|
||
`translate.worker` in headless Chromium; `MATRIX_LANGUAGES=es,ca` limits it.
|
||
It writes `/tmp/opencode/translation-matrix.json` and a per-language partial.
|
||
First runs on this box: en↔es **~54s**, en↔ca **~21s** (model download
|
||
included for es); Basque downloads did not finish in a 25-minute run. This is a
|
||
real performance finding, not just missing test coverage: the 617 MB M2M100
|
||
single-threaded WASM is too slow for low-end phones. Low-end/APK verification
|
||
still needs a device (and likely a smaller model or native ONNX).
|
||
- **Still to do:** verify all 24 pairs in the browser/APK and measure
|
||
speed/memory on low-end phones. **1.7.2 and 1.7.3 are released** (the phone
|
||
needs the Zapstore update; the web deploy timer batches for 24h unless forced).
|
||
No SpotChat.
|
||
- **Keyboard caveat:** a malicious OS keyboard or rooted device is outside the
|
||
app’s control. Warn/recommend a trusted offline keyboard, and avoid the OS
|
||
keyboard for **secrets** (custom secure input / QR / bunker/Amber,
|
||
`FLAG_SECURE`, no autofill/suggestions). Translation’s guarantee is
|
||
app-level: no plaintext egress.
|
||
|
||
### Sasquatch translation + privacy (2026-10-27)
|
||
- The Sasquatch **UI, guide and FAQ** strings (`sasquatchUi`/`sasquatchChat`/
|
||
`sasquatchGuide`) are translated in all 24 locales (i18n 100%).
|
||
- The **AI reply language** directive was only `es`/`th`/`ja`;
|
||
`SasquatchGuide.tsx` now has `SASQUATCH_LANGUAGES` for all 24 (skate trick
|
||
names and core vocabulary stay in English), so replies follow the app
|
||
language for every locale.
|
||
- **Privacy differs:** the Sasquatch AI is **not on-device** — it calls the CVM/
|
||
AI proxy (OpenRouter). Its questions/replies leave the device. Only the
|
||
**translation** is local/no-egress; do not conflate the two.
|
||
|
||
### Community relay — self-hosted Newlay (2026-10-12)
|
||
|
||
The community relay moved off the hosted relay.tools instance
|
||
(`wss://goskatespots.nostr1.com`) to **our own Newlay** at
|
||
**`wss://relay.skate-spots.org`**. Host: the same VPS as `ai.`/`push.`
|
||
(`185.18.221.2`, `vm-1705.lnvps.cloud`, ssh key `~/.ssh/skatespots-new`).
|
||
|
||
- **Stack:** Newlay (`artifacts.relay.tools/opensauce/newlay`, MIT) in **Docker**
|
||
(Compose), bound to **`127.0.0.1:7777` only**; **Caddy** terminates TLS (a
|
||
`relay.skate-spots.org` site block was **appended** to `/etc/caddy/Caddyfile`,
|
||
auto Let's Encrypt) and reverse-proxies it. Do NOT add nginx — Caddy is already
|
||
the proxy for ai/push/relay.
|
||
- **Repo on the box:** `~/newlay`; config `~/newlay/deploy/docker/newlay.toml`
|
||
(bind-mounted read-only into the container). `sudo docker compose up -d`.
|
||
- **Config choices:** `[tier.anon]` open read+write (no WoT gate, no spider);
|
||
`created_at_msecs_ago = 315360000000` (10 years — the default 365-day
|
||
window silently dropped an old spot on restore); publishing/filter limits raised
|
||
from the tight defaults. The relay is keyless (no NIP-43/NIP-29 membership).
|
||
⚠️ **`max_limit` was 1000 but the map asks `useSkateSpots(2000)`** — the REQ
|
||
exceeded the cap and the map looked empty. Raised anon **`max_limit` to 5000**
|
||
(2026-10-26). Also raised `[ip_gate] max_connections_per_ip` **16 → 64** and
|
||
`connect_attempts_per_min` **120 → 300**: our own test queries hit **429** on
|
||
the WebSocket upgrade (easy to mistake for an empty relay — always check the
|
||
handshake status, not just the event count). NIP-11's `auth_for_read: true` is
|
||
cosmetic; unauthenticated reads work. Config backup:
|
||
`~/newlay/deploy/docker/newlay.toml.bak-*`; restart with
|
||
`sudo docker compose up -d --force-recreate` from `~/newlay`.
|
||
**2026-10-30:** after 3,767 `cordn rate limit` rejects in 24h (`msg_fetch_many`,
|
||
mostly the two admin pubkeys), raised `[cordn] rate_per_min` 120→300,
|
||
`rate_burst` 30→120 and `max_concurrent_requests` 32→64. Config backup
|
||
`newlay.toml.bak-cordn-20261030-102947`; restarted and verified coordinator
|
||
announce + NIP-11 200. Recheck the reject count before/after a Staircase soak.
|
||
⚠️ **Content can silently miss the canonical relay:** the app publishes to
|
||
`getWriteRelays()` and resolves on the **first** relay that accepts, so a
|
||
rate-limited community-relay write can leave a spot only on ditto/damus (this
|
||
happened to a Chengdu spot, 2026-10-26 — republished the signed event with
|
||
`nak req … | nak event wss://relay.skate-spots.org`). Parity check (kind 30818):
|
||
our relay had **56**, ditto+damus union **26**; the only missing item was a
|
||
junk test spot. Re-check parity after any relay/IP-gate change.
|
||
- **Data:** restored from the archive — **115/115 events, 17 authors**, every kind
|
||
matching. LMDB + (unused) Blossom live in the Docker volume
|
||
`newlay_newlay-data`.
|
||
- **Admins / NIP-86 (2026-10-12):** `[tier.admin]` (`can_admin = true`) plus two
|
||
`[[assignment]]` rows grant the app's two admins (`20e7c953…` founder,
|
||
`caba497f…`) the NIP-86 management RPC that `src/lib/relayAdmin.ts` calls
|
||
(`banevent`, `listeventsneedingmoderation`, `getrelaystatus`, …). Verified with a
|
||
throwaway admin key: admin → 200 (method list), non-admin → 403, no auth → 401.
|
||
The app's admin hard-delete is now real on the relay (falls back to NIP-32 label
|
||
hiding only if NIP-86 is unreachable). Manage admins by editing
|
||
`~/newlay/deploy/docker/newlay.toml` and `docker compose up -d --force-recreate`.
|
||
- **systemd:** the host runs `skatespots-relay.service` (a `docker compose` wrapper,
|
||
`enable --now`; tracked at `scripts/systemd/skatespots-relay.service`). Restart
|
||
with `sudo systemctl restart skatespots-relay`. The container also carries
|
||
`restart: unless-stopped`.
|
||
- **Monitoring (2026-10-12):** `skatespots-health.{service,timer}` on the host run
|
||
`scripts/host/relay-health.sh` every 5 min — checks relay/ai/push/cvm/caddy + the
|
||
public endpoints + disk/memory, **restarts the relay** if its localhost NIP-11
|
||
dies, and posts to **ntfy.sh** when the problem set changes (deduped; also a
|
||
"recovered" message). The topic is in `/home/ubuntu/skatespots-health.env`
|
||
(mode 600, not tracked) — subscribe to it in the ntfy app. Units tracked at
|
||
`scripts/systemd/skatespots-health.{service,timer}`. **2026-10-30:** the host
|
||
script also alerts on ≥40 Cordn rate-limit rejects/15m, a missing coordinator
|
||
announce/20m, and TLS expiry <14d; deployed to `/home/ubuntu/skatespots-health.sh`.
|
||
- **Media / Blossom (2026-10-12):** the relay also serves Newlay's Blossom at
|
||
**`blossom.skate-spots.org`** (a Caddy site block → the same `127.0.0.1:7777`;
|
||
blobs stored at `/var/lib/newlay/data/blobs`, INSIDE the data volume so they
|
||
survive container recreation). `[blossom] enabled = true`,
|
||
`upload_requires_membership = false` (no membership model), `public_base_url`
|
||
set. The app uploads there first (`useUploadFile` `UPLOAD_SERVERS`) and keeps
|
||
`blossom.primal.net` + `blossom.band` as mirrors; `BLOSSOM_MIRROR_HOSTS`
|
||
(`lib/imageFallback.ts`) includes it so reads fall back across all three.
|
||
Verified end-to-end (signed PUT /upload → 201, GET /<sha> → 200). To fully
|
||
self-host media later, drop the two public hosts from both lists.
|
||
**2026-10-30 — image-only quota proxy:** `[blossom] max_size_bytes` lowered
|
||
to **10 MiB**. Caddy routes only `PUT /upload` through
|
||
`skatespots-quota.service` (`scripts/quota-proxy.mjs`, `127.0.0.1:8790`);
|
||
reads and all other Blossom routes still go straight to Newlay. The proxy
|
||
verifies the BUD-11 token, enforces image types and per-npub daily budgets
|
||
(50 uploads/250 MiB; admins 3×), a per-IP hourly backstop (100/500 MiB), and a
|
||
20 GB relay-wide cap. State is `/home/ubuntu/skatespots-quota/usage.json`;
|
||
the service is enabled, and the host health check checks its `/health`, the
|
||
service, and >18 GiB media. App uploads now use **only** our Blossom; public
|
||
mirrors are read-side fallbacks only, so a quota rejection cannot silently
|
||
land on a third party. No video upload support was added.
|
||
**2026-10-30 — transport/ops hardening:** HSTS is set on `relay.` and
|
||
`blossom.` (`max-age=31536000; includeSubDomains`); CAA records restrict
|
||
issuance for those two hosts to `letsencrypt.org`. The health timer writes an
|
||
aggregate `/home/ubuntu/skatespots-quota/status.json`; the quota proxy serves
|
||
it at `https://relay.skate-spots.org/ops` only for a NIP-98-signed request
|
||
from one of the two admin pubkeys. `/ops` is a read-only app page linked from
|
||
Settings → Advanced (AdminReviewCard). No restart/config actions are exposed.
|
||
- **Cordn coordinator (2026-10-12):** the relay also runs Newlay's **embedded Cordn
|
||
coordinator** for crew MLS chat (`[cordn] enabled`, `mode = "embedded"`,
|
||
`data_dir = /var/lib/newlay/data/cordn` inside the volume, `relay_url =
|
||
ws://127.0.0.1:7777`, `announce_interval_secs = 900` (15 min so the health probe
|
||
reads **Connected**, not "stale" — the example's 86400s/24h default was too long). Its identity is `cordn.key` (mounted read-only) — pubkey
|
||
`02816a05…` — ⚠️ the file must be owned by the container's `newlay` user
|
||
(uid 10001, `chown 10001:10001`), else the relay crashes on
|
||
`/etc/newlay/cordn.key: Permission denied`. BYO coordinator stays client-side.
|
||
In the app: `SKATESPOTS_COORDINATOR` (`src/lib/coordinator.ts`) is the provided
|
||
default; **Settings → Coordinator** shows a "Use SkateSpots coordinator" block
|
||
and **CrewForm** prefills it for new crews. Disclosure copy lives in
|
||
`socialUi.coordinator.ourDefault*` + the existing `privacyNote` (coordinators see
|
||
ciphertext + metadata, never contents). ⚠️ **`CrewCoordinatorCard` was rewritten
|
||
(2026-10-12, see `CORDN_UX_AUDIT.md`):** it used to badge **"Private chat · On"**
|
||
while the MLS tab said **"not created yet"** (a contradiction) and led with the
|
||
external apps. It now shows two explicit rows — **Chat** (NIP-17, always on) and
|
||
**Private chat** (MLS; On / Set up / Join / Checking from `nativeGroupExists`) —
|
||
with the **in-app chat as the primary CTA** (`?tab=mls`), and "Cordn (web)" /
|
||
"Copy for Staircase" demoted under an **"Open in another app"** label. Props are
|
||
`{ crew, isFounder, canManage, nativeGroupExists }`. Also fixed a status-badge bug:
|
||
`STATUS_META` labels in
|
||
`CoordinatorSettingsCard` were missing the `socialUi.` prefix and rendered raw
|
||
keys (e.g. `coordinator.statusStale`).
|
||
**Verified (2026-10-12):** the reference `@contextvm/sdk` client (encrypted
|
||
ContextVM transport) connects to our coordinator and lists its tools (`kp_*`,
|
||
`welcome_*`, `join_request_*`, `msg_*`) — a real Cordn client works. The
|
||
**cordn.net web app does not** complete the handshake (connects + NIP-42-auths,
|
||
then sends no request; upstream issue drafted). **Crew Cordn group link
|
||
(Level 1, 2026-10-12):** a crew may carry `['cordn', <cordn1-ref-or-url>]`
|
||
(`SkateCrew.cordnGroup`); `CrewForm` has a "Cordn group" field and
|
||
`CrewCoordinatorCard` shows an "Open crew chat" + "Copy for Staircase" row.
|
||
In-app Cordn/MLS (Level 2) remains the big client build.
|
||
- **Cutover (2026-10-12):** `SKATE_RELAY` (`src/lib/types.ts`) → the new relay; the
|
||
default pools, the DM/CVM/zap relay arrays, the `cvm-server` + `push-service`
|
||
defaults, and the archive default all point at the new relay.
|
||
- **Legacy relay retired (2026-10-12):** verified the old relay held the **same 115
|
||
events** as the new one (0 diff) before changing anything, so nothing is lost.
|
||
`wss://goskatespots.nostr1.com` was then removed from the pools, the servers and
|
||
the box env. ⚠️ Old native clients (a pre-update APK) that still point at the old
|
||
relay will lose skate content once the relay.tools subscription is cancelled —
|
||
cancel only after the Zapstore update has propagated. Historical newsletter text
|
||
still names the old relay (left as-is on purpose).
|
||
- **Stored relay lists are reconciled (2026-10-12):** `RETIRED_RELAY_HOSTS` in
|
||
`lib/relays.ts` (`goskatespots.nostr1.com`, dead `wotr.relatr.xyz`) are pruned at
|
||
runtime (`resolveRelays`), on config load (`AppProvider`) and on NIP-65 sync
|
||
(`NostrSync`); and the community relay (`SKATE_RELAY`) is added to the NIP-65 list
|
||
when missing (unless `useSkateRelay` is off). `NostrSync` also **republishes** the
|
||
corrected kind-10002 once for non-Amber signers (silent) so other clients see the
|
||
skate scene; Amber users keep it local (no unprompted popup) and republish from
|
||
Settings → Relays.
|
||
- ⚠️ `relay.skate-spots.org` can show a stale NXDOMAIN in a local resolver cache
|
||
right after creation (our dev box did). Verify with
|
||
`curl --resolve relay.skate-spots.org:443:185.18.221.2 …` or from another host.
|
||
- **Backups:** the weekly `skatespots-archive.timer` still runs
|
||
`scripts/backup-relay.sh`, but it now archives **through an SSH tunnel** to the
|
||
relay host's `127.0.0.1:7777` (overridable via `SKATESPOTS_RELAY_SSH`; falls back
|
||
to the public URL if the tunnel can't be established), so it never depends on
|
||
public DNS resolving from the dev box. Local snapshots stay in
|
||
`~/.backups/relay` (12 kept) and restic pushes to the host's repo
|
||
(`~/.config/skatespots/restic.env`). Two locations today (relay host + dev box);
|
||
a third would need a new offsite destination.
|
||
|
||
### Git / ngit publishing
|
||
- Push: `git push origin main` (handled by `git-remote-nostr`; publishes a signed repo-state event).
|
||
- Login (once): `ngit account login --interactive` → scan the QR in Amber ("Add a new application → Connect app"). Plain `ngit account login` errors; the `--interactive` flag is required.
|
||
- The NIP-65 write list contains a dead relay (`wss://wotr.relatr.xyz`), which makes `ngit repo edit` fail unless run with `--repo-relay-only`. Local git config `nostr.repo-relay-only true` is set as a workaround. Fully fix by removing that relay in the app (Settings → Relays). **Done in defaults 2026-09-28**: removed from `App.tsx` `defaultConfig`, `NostrSync` `NEW_USER_RELAYS`, `NostrProvider` `DEFAULT_RELAYS`, `TestApp`, and the CVM `DEPLOY.md` example. ⚠️ Existing accounts keep it in their **stored** config until they remove it themselves (no migration applied).
|
||
- `git push` may print a benign `cannot lock ref ... incorrect old value provided` retry warning; verify with `git fetch origin && git rev-list --left-right --count origin/main...HEAD`.
|
||
|
||
### Secrets — never commit or paste
|
||
`keystore.properties`, `*.jks`, `.env*`, and `android/` are gitignored. Never paste the nsec or keystore password into chat, logs, or command history — keep them in a password manager.
|
||
|
||
## Project Structure
|
||
|
||
- `/skills/`: Specialized implementation guides for specific features - each skill has a comprehensive `SKILL.md` file with complete patterns, code examples, and best practices. See the Skills Directory section below for details on available skills.
|
||
- `/src/components/`: UI components including NostrProvider for Nostr integration
|
||
- `/src/components/ui/`: shadcn/ui components (48+ components available)
|
||
- `/src/components/auth/`: Authentication-related components (LoginArea, LoginDialog, etc.)
|
||
- Zap components: `ZapButton`, `ZapDialog`, `WalletModal` for Lightning payments
|
||
- `/src/hooks/`: Custom hooks including:
|
||
- `useNostr`: Core Nostr protocol integration
|
||
- `useAuthor`: Fetch user profile data by pubkey
|
||
- `useCurrentUser`: Get currently logged-in user
|
||
- `useNostrPublish`: Publish events to Nostr
|
||
- `useUploadFile`: Upload files via Blossom servers
|
||
- `useAppContext`: Access global app configuration
|
||
- `useTheme`: Theme management
|
||
- `useToast`: Toast notifications
|
||
- `useLocalStorage`: Persistent local storage
|
||
- `useLoggedInAccounts`: Manage multiple accounts
|
||
- `useLoginActions`: Authentication actions
|
||
- `useIsMobile`: Responsive design helper
|
||
- `useZaps`: Lightning zap functionality with payment processing
|
||
- `useWallet`: Unified wallet detection (WebLN + NWC)
|
||
- `useNWC`: Nostr Wallet Connect connection management
|
||
- `useNWCContext`: Access NWC context provider
|
||
- `/src/pages/`: ~50 route pages (MapPage is the homepage; see `AppRouter.tsx`)
|
||
- `/src/lib/`: shared logic — `types.ts` (custom kind constants), `lazyWithRetry.ts`, `queryPersister.ts`, helpers
|
||
- `/src/contexts/`: React context providers (AppContext, LanguageContext, NWCContext)
|
||
- `NIP.md`: **source of truth** for every custom/adopted Nostr kind and its schema
|
||
- `AGENTS.md` (this file): the only cross-session memory — keep it current
|
||
- `UX_AUDIT.md` (pre-refactor, partly stale), `UX_AUDIT_2026-10-09.md`
|
||
(app-wide + marketplace UI/UX audit), and `MARKETPLACE_FLOW_AUDIT.md` (the deep
|
||
trade/escrow/offers/error audit, with severity-ranked findings and a fix order),
|
||
`GROUP_MESSAGING.md`, `ESCROW.md`: feature decision/backlog docs
|
||
- `/src/test/`: Testing utilities including TestApp component
|
||
- `/public/`: Static assets
|
||
- `App.tsx`: Main app component with provider setup (**CRITICAL**: this file is **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider` and other important providers - **read this file before making changes**. Changes are usually not necessary unless adding new providers. Changing this file may break the application)
|
||
- `AppRouter.tsx`: React Router configuration
|
||
|
||
**CRITICAL**: Always read the files mentioned above before making changes, as they contain important setup and configuration for the application. Never directly write to these files without first reading their contents.
|
||
|
||
## UI Components
|
||
|
||
The project uses shadcn/ui components located in `@/components/ui`. These are unstyled, accessible components built with Radix UI and styled with Tailwind CSS. Available components include:
|
||
|
||
- **Accordion**: Vertically collapsing content panels
|
||
- **Alert**: Displays important messages to users
|
||
- **AlertDialog**: Modal dialog for critical actions requiring confirmation
|
||
- **AspectRatio**: Maintains consistent width-to-height ratio
|
||
- **Avatar**: User profile pictures with fallback support
|
||
- **Badge**: Small status descriptors for UI elements
|
||
- **Breadcrumb**: Navigation aid showing current location in hierarchy
|
||
- **Button**: Customizable button with multiple variants and sizes
|
||
- **Calendar**: Date picker component
|
||
- **Card**: Container with header, content, and footer sections
|
||
- **Carousel**: Slideshow for cycling through elements
|
||
- **Chart**: Data visualization component
|
||
- **Checkbox**: Selectable input element
|
||
- **Collapsible**: Toggle for showing/hiding content
|
||
- **Command**: Command palette for keyboard-first interfaces
|
||
- **ContextMenu**: Right-click menu component
|
||
- **Dialog**: Modal window overlay
|
||
- **Drawer**: Side-sliding panel (using vaul)
|
||
- **DropdownMenu**: Menu that appears from a trigger element
|
||
- **Form**: Form validation and submission handling
|
||
- **HoverCard**: Card that appears when hovering over an element
|
||
- **InputOTP**: One-time password input field
|
||
- **Input**: Text input field
|
||
- **Label**: Accessible form labels
|
||
- **Menubar**: Horizontal menu with dropdowns
|
||
- **NavigationMenu**: Accessible navigation component
|
||
- **Pagination**: Controls for navigating between pages
|
||
- **Popover**: Floating content triggered by a button
|
||
- **Progress**: Progress indicator
|
||
- **RadioGroup**: Group of radio inputs
|
||
- **Resizable**: Resizable panels and interfaces
|
||
- **ScrollArea**: Scrollable container with custom scrollbars
|
||
- **Select**: Dropdown selection component
|
||
- **Separator**: Visual divider between content
|
||
- **Sheet**: Side-anchored dialog component
|
||
- **Sidebar**: Navigation sidebar component
|
||
- **Skeleton**: Loading placeholder
|
||
- **Slider**: Input for selecting a value from a range
|
||
- **Switch**: Toggle switch control
|
||
- **Table**: Data table with headers and rows
|
||
- **Tabs**: Tabbed interface component
|
||
- **Textarea**: Multi-line text input
|
||
- **Toast**: Toast notification component
|
||
- **ToggleGroup**: Group of toggle buttons
|
||
- **Toggle**: Two-state button
|
||
- **Tooltip**: Informational text that appears on hover
|
||
|
||
These components follow a consistent pattern using React's `forwardRef` and use the `cn()` utility for class name merging. Many are built on Radix UI primitives for accessibility and customized with Tailwind CSS.
|
||
|
||
## Skills Directory
|
||
|
||
The project includes a **`skills/`** directory containing specialized implementation guides for specific features. Each skill has a comprehensive `SKILL.md` file with complete patterns, code examples, and best practices. **Always reference the appropriate SKILL.md file when implementing related functionality.**
|
||
|
||
### Wallet Skills
|
||
|
||
- **`skills/bitcoin-wallet/SKILL.md`**: Use when implementing Bitcoin wallet features - provides complete architecture for on-chain Bitcoin transactions, UTXO management, address derivation from Nostr keys, and transaction handling with proper security patterns. Read when building Bitcoin wallet functionality, adding on-chain transaction support, implementing UTXO management, or integrating Bitcoin with Nostr-based applications.
|
||
|
||
- **`skills/lightning-wallet/SKILL.md`**: Use when implementing a standalone Lightning wallet using Cashu mints - provides complete patterns for minting (receiving Lightning payments), melting (sending Lightning payments), mint management, transaction history, and integrating with Lightning addresses and invoices. Read when building a Lightning wallet with Cashu mints, implementing Lightning payment flows (send/receive), managing Cashu mints, processing Lightning invoices, or integrating Lightning address functionality with a custodial wallet.
|
||
|
||
- **`skills/cashu-wallet/SKILL.md`**: Use when implementing Cashu token wallet functionality - provides complete patterns for sending and receiving Cashu tokens, token QR codes, automatic mint management, and integrating with Lightning wallet operations. Read when building a Cashu token wallet, implementing token send/receive operations, handling Cashu token transfers, managing token QR codes, or building a full-featured wallet that combines both Lightning and token operations.
|
||
|
||
- **`skills/nostr-wallet-connect/SKILL.md`**: Use when implementing Nostr Wallet Connect (NIP-47) wallet service - provides complete patterns for generating NWC connection strings, publishing info events, listening for requests, processing wallet commands, and sending encrypted responses. Client-side patterns included for reference. Read when building wallet services that expose Lightning functionality via NWC, generating NWC connection URIs for clients, implementing NIP-47 wallet service functionality, or handling encrypted wallet communication over Nostr relays.
|
||
|
||
### Lightning Integration Skills
|
||
|
||
- **`skills/lightning-address/SKILL.md`**: Use when implementing Lightning address functionality - provides complete patterns for resolving Lightning addresses to invoices, generating invoices from addresses, displaying Lightning addresses in UI, and integrating with QR codes. Read when adding Lightning address support, implementing LNURL-pay protocol, generating invoices from Lightning addresses, displaying Lightning addresses in wallet UIs, or integrating Lightning addresses with QR code functionality.
|
||
|
||
- **`skills/lightning-invoice-decoder/SKILL.md`**: Use when implementing BOLT11 Lightning invoice decoding - provides complete patterns for decoding invoice amounts, validating invoice format, and extracting satoshi values from Lightning invoices. Read when adding Lightning invoice parsing, validating BOLT11 invoices, extracting payment amounts from invoices, or implementing Lightning payment flows.
|
||
|
||
- **`skills/npub-cash-address/SKILL.md`**: Use when implementing npub.cash static Lightning address functionality - provides complete patterns for obtaining static Lightning addresses (username@npubx.cash), managing npub.cash account settings, syncing quotes from npub.cash, and integrating with Lightning wallets. Read when building Lightning wallet features that need static addresses, implementing npub.cash integration, managing user Lightning addresses, or syncing quotes from npub.cash to Cashu wallets.
|
||
|
||
### Utility Skills
|
||
|
||
- **`skills/qr-code-generator/SKILL.md`**: Use when implementing QR code generation - provides complete patterns for generating QR codes from text/URLs, displaying QR codes in modals, and customizing QR code appearance. Read when adding QR code generation functionality, displaying QR codes for addresses/invoices, or generating QR codes for sharing content.
|
||
|
||
- **`skills/qr-code-scanner/SKILL.md`**: Use when implementing QR code scanning - provides complete patterns for camera-based scanning, classifying scanned content (Bitcoin addresses, Lightning invoices, npubs, Cashu tokens), handling camera permissions, and paste from clipboard functionality. Read when adding QR code scanning functionality, implementing camera-based scanning, classifying scanned QR content, or handling QR code input from users.
|
||
|
||
- **`skills/exchange-rates/SKILL.md`**: Use when implementing exchange rate functionality - provides complete patterns for fetching BTC/fiat exchange rates from Coinbase API, caching strategies, conversion utilities, and React hooks for displaying rates in UI. Read when adding exchange rate display, converting between BTC and fiat currencies, showing USD equivalents for Bitcoin amounts, or implementing currency conversion features.
|
||
|
||
### Steganography Skills
|
||
|
||
- **`skills/emoji-encoder/SKILL.md`**: Use when implementing steganographic text encoding using Unicode Variation Selectors - allows encoding any message invisibly into emojis, letters, or any characters using Unicode Variation Selectors (U+FE00-U+FE0F and U+E0100-U+E01EF). Read when adding steganographic encoding, hiding data in plain text, encoding messages into emojis/characters, or implementing invisible data encoding using Unicode Variation Selectors.
|
||
|
||
- **`skills/lsb-steganography/SKILL.md`**: Use when implementing Least Significant Bit (LSB) steganography - allows hiding images or text within other images by encoding data in the least significant bits of pixel color channels. Read when adding steganographic image encoding, hiding images within images, encoding text into images, implementing invisible data storage in images, or building privacy-preserving image sharing features.
|
||
|
||
## System Prompt Management
|
||
|
||
`AGENTS.md` is the assistant's system prompt **and its only persistent memory**.
|
||
Future sessions start with nothing but this file, so treat it as the project's
|
||
long-term memory:
|
||
|
||
1. Edit `AGENTS.md` directly when you learn something important or when a
|
||
described detail has changed.
|
||
2. Changes take effect in the next session — so record now, not "later".
|
||
3. Re-read the relevant section before relying on it; if it's out of date, fix it
|
||
in the same session rather than working around it.
|
||
|
||
## License — AGPL-3.0-only (2026-10-09)
|
||
|
||
The repo is licensed **AGPL-3.0-only**: the full text is in **`/LICENSE`**,
|
||
**copied verbatim and unedited** from the `teambtcmap/btcmap-android` LICENSE
|
||
(the advice: never hand-edit a licence — hand-edited ones are why lawyers
|
||
exist), and `"license": "AGPL-3.0-only"` in `package.json`. No "or later" grant
|
||
is added, so v3 only. Chosen because the trust work reviews AGPL-licensed
|
||
reference implementations (GrapeRank / Trusted Assertions) and we reimplement
|
||
rather than copy; AGPL keeps that clean and matches the ecosystem. The
|
||
practical effect: derivatives and network deployments must stay open under the
|
||
same terms ("network use is distribution"); as the sole copyright holder the
|
||
owner may still license his own extra work differently.
|
||
|
||
⚠️ AGPL section 13 means a **hosted modified version must offer its source to
|
||
network users** — the public repo (nostr git + Netlify) plus the License page's
|
||
"View source" link satisfy that; keep it that way. Do not add a dependency whose
|
||
licence is incompatible with AGPL. The `LicensePage` copy still says "open"
|
||
without naming AGPL — update it when convenient. (Also: GitHub/license scanners
|
||
recognise `LICENSE`, so the file is deliberately **not** `LICENSE.md`.)
|
||
|
||
## Credits — no MKStack / Shakespeare (owner decision, 2026-09-28)
|
||
|
||
⚠️ This used to say: "include 'Vibed with MKStack' somewhere in the UI, linked to
|
||
https://soapbox.pub/mkstack". **The owner removed it on request — do NOT re-add
|
||
it.** `/about` → Credits no longer mentions MKStack or Shakespeare, and the
|
||
"Nostr" credit links to the protocol repo
|
||
(`https://github.com/nostr-protocol/nostr`), not nostr.com.
|
||
|
||
The credits should read (keep this shape when editing): **Nostr** (protocol) ·
|
||
**Cordn** (crew chat, cordn.net) · **Cashu** (ecash, cashu.space) · **NWC**
|
||
(docs.nwc.dev) · **Amber** (Android signer) · **BTCMap** (btcmap.org) · **Angor**
|
||
· **Zapstore** · **ContextVM**. (`useShakespeare.ts`, `proxy.shakespeare.diy` and
|
||
similar are internal endpoints/hook names, not credits — leave them.)
|
||
|
||
## Internationalisation (EN / ES-Castellano) — decisions (2026-10-01)
|
||
|
||
### Thai (ไทย) — third locale (2026-10-06)
|
||
- Added `th` alongside en/es on the **same framework**; core plumbing unchanged.
|
||
Translations live in **`src/i18n/th/`** as fragments composed in `index.ts`
|
||
(so parallel batches don't edit one giant file).
|
||
- ✅ **COMPLETE (2026-10-07): `th` is 100% — 7,889/7,889 keys.** The partial-locale
|
||
rule below still applies to any *future* keys.
|
||
- **Partial by design:** a missing `th` key falls back to English. `check:i18n`
|
||
is now **multi-way** — en/es stay key-for-key identical (unchanged), `th` (and
|
||
`ja`) may be partial and are reported as **coverage %**; a `th`/`ja` key absent
|
||
from `en` fails.
|
||
- **Font:** `@fontsource/kanit` (Kanit), **lazy-loaded per locale** (`lib/localeFonts.ts`) and added to the
|
||
CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have **no Thai
|
||
glyphs**, so without it Thai silently falls back to a system font.
|
||
- **Detection:** a Thai-device browser (`th*`) starts in `th` (`detectLanguage`).
|
||
- **The trick glossary (`src/lib/glossary.ts`) stays English.**
|
||
- **Do-not-translate is broader than tricks for Thai:** core skate vocabulary
|
||
stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr,
|
||
Lightning, Zap, Relay, sats) because Thai skaters use the English terms.
|
||
|
||
### Catalan, Basque, Galician, Hindi + pt-PT — 24 dictionaries, 25 codes, all 100% (2026-10-26)
|
||
- Added **ca (Català), eu (Euskara), gl (Galego), hi (हिन्दी)** → **24
|
||
dictionaries**; **pt-PT** is a variant that shares the `pt` dictionary
|
||
(25 selectable codes).
|
||
- Registered in `Language` + `LANGUAGE_LOADERS`, `detectLanguage` (ca/eu/gl/hi
|
||
checked before `es`; `pt-BR`→`pt`, `pt-PT`→`pt-PT`), the `LanguageToggle`
|
||
list, and `dateFnsI18n` (date-fns `ca`/`eu`/`gl`/`hi`/`pt`, codes
|
||
`ca-ES`/`eu-ES`/`gl-ES`/`hi-IN`/`pt-BR`/`pt-PT`).
|
||
- **Flags are real SVGs** in `public/flags/` for `ca`, `eu`, `gl` and `gb-eng`
|
||
(there are no Unicode region flags for Catalonia/Basque/Galicia, and England
|
||
is not the UK flag). `LanguageToggle` renders a path as `<img>` and an emoji
|
||
as text. **English now uses the England flag** (not the UK), Portuguese has
|
||
**Brasil 🇧🇷** (`pt`) + **Portugal 🇵🇹** (`pt-PT`), and Hindi uses 🇮🇳.
|
||
- **`pt-PT`** shares the `pt` base and applies a European drift
|
||
(`src/i18n/pt-PT/drift.ts`: Salvar→Guardar, Senha→Palavra-passe,
|
||
Ônibus→Autocarro, etc.). A full European-Portuguese review is a follow-up.
|
||
- **Map world clamp:** `SpotMap` uses `worldCopyJump: true` and
|
||
`applyWorldMinZoom()` (smallest integer zoom whose 256·2^z world covers the
|
||
viewport). That stops a second full copy of the continents and keeps the map
|
||
covering the surface (no grey bands), while still letting you drag across the
|
||
antimeridian to Hawaii/Pacific. On a wide desktop the whole globe cannot also
|
||
fit without distortion; the clamp prioritises coverage.
|
||
- Full dictionaries in `src/i18n/{ca,eu,gl,hi}/` — 7 namespace batches each
|
||
(8,703 keys), composed in `index.ts`; placeholders verified, only 2–4%
|
||
identical strings (brand/protocol names). `check-i18n.mjs` includes them in
|
||
`extraLocales` and reports 100%.
|
||
- Catalan/Galician/Portuguese are Latin; **Hindi is Devanagari**, so
|
||
`@fontsource/noto-sans-devanagari` (400/500/700) is **lazy-loaded** for `hi`
|
||
via `lib/localeFonts.ts` (same pattern as CJK) and the CSS stack includes
|
||
`'Noto Sans Devanagari'` before `system-ui`.
|
||
- **Menu hardening across all locales:** the language dropdown is `w-72` with
|
||
`truncate` + a fixed 24px flag slot (SVG and emoji align); the settings
|
||
language grid is 1 column on phones / 2 on `sm+`; the header **More** menu is
|
||
`w-64`; the header nav scrolls (`overflow-x-auto scrollbar-hide`) so long
|
||
labels never push the logo/controls; the bottom nav truncates its labels.
|
||
`:lang(hi/th/ja/ko/zh)` removes `uppercase`/`tracking-wide`, because
|
||
Devanagari/Thai/CJK labels look broken when letter-spaced.
|
||
- Translation sources were split by namespace into
|
||
`/tmp/opencode/i18n-src/batch1..7.json` (regenerate from `en.ts` if
|
||
re-translating). When adding new UI strings, add them to `en`/`es` **and all
|
||
24 dictionaries (25 codes)** (batch file or `drift.ts`).
|
||
|
||
### i18n drift closed — all 20 locales 100% (2026-10-13)
|
||
- Every non-en/es locale had fallen to **97%** (245 keys each — `trustUi`,
|
||
`labOptions`, `cordnUi`, `socialUi`, `walletUi`, `marketTerms`, …). Backfilled
|
||
**245 × 20 = 4,900 strings** into `src/i18n/<lang>/drift.ts` (a pure fragment).
|
||
- ⚠️ **`drift.ts` MUST be merged, not shallow-spread.** Its keys live in
|
||
namespaces that already exist in the `batchN` fragments, so `...drift` last
|
||
would **replace whole namespaces** (e.g. `socialUi` 688 → 24 keys, tanking
|
||
coverage). Every locale `index.ts` now does
|
||
`deepMerge({ ...batchN }, drift)` via the shared **`src/i18n/deepMerge.ts`**
|
||
(`deepMerge(base, extra)` — recursive, string-dictionary only). Add future
|
||
late keys to `drift.ts` and they merge the same way. `check-i18n.mjs` should
|
||
stay at 100% for all 20.
|
||
- (Backfilled by parallel translation subagents; brand/protocol/trick names kept
|
||
English, `{{placeholders}}` preserved — all verified.)
|
||
|
||
### Ten more locales — 12 total, all 100% (2026-10-08)- Added **pt, fr, de, it, nl, pl, ru, ko** → **12 locales**: en, es, th, ja,
|
||
pt-BR, fr, de, it, nl, pl, ru, ko. All at **100% (8,450 keys)**, en/es in parity.
|
||
- **Locales are LAZY-LOADED.** `language.ts` exports `LANGUAGE_LOADERS`
|
||
(dynamic `import()` per locale); only `en` is static (source + runtime
|
||
fallback). `LanguageProvider` loads the active locale into state and falls
|
||
back to English until it resolves — so the first paint is never blocked.
|
||
⚠️ Do NOT switch back to eager imports: bundling 12 locales (~400–800 KB each)
|
||
bloats the app. Each language is its own on-demand chunk.
|
||
- **Fonts:** added **Noto Sans KR** (Korean) and **Inter** (Cyrillic/Russian) to
|
||
the CSS + Tailwind stacks; JP/KR/TH each need their own font (Outfit/Inter
|
||
lack CJK). **(2026-10-12) CJK fonts are lazy-loaded per locale** (`lib/localeFonts.ts`,
|
||
wired in `contexts/LanguageContext.tsx`) — they're ~16 MB of font payload that
|
||
non-CJK users no longer download at boot.
|
||
- `check-i18n.mjs` now reports **all 12**; new locales are *partial* (coverage %)
|
||
until complete. Adding a locale = register in `language.ts` (`Language` union +
|
||
`LANGUAGE_LOADERS`), `LanguageContext.detectLanguage`, `LanguageToggle`,
|
||
`dateFnsI18n`, and scaffold `src/i18n/<lang>/{index,batch1..8}.ts`.
|
||
- **Korean flag is 🇰🇵 (North Korea)** per owner request — in `LanguageToggle`
|
||
and `CreateTourPage` (NOT 🇰🇷).
|
||
- ⚠️ **One writer per file.** Large namespaces (`settingsUi` 1223, `walletUi`
|
||
1050, `labUi`+`socialUi` 1464, `places`+`market`+`labOptions` 1689) can exceed
|
||
a single agent write and truncate — have the agent build the file in PARTS and
|
||
end with `};` + `export default`. Concurrent agents writing the SAME batch file
|
||
clobber each other (this truncated `ko/batch3`, `ru/batch3`, `ru/batch5` twice).
|
||
|
||
### Japanese (日本語) — fourth locale (2026-10-07)
|
||
- Added `ja` alongside en/es/th on the **same framework**. Translations live in
|
||
**`src/i18n/ja/`** as fragments composed in `index.ts`.
|
||
- ✅ **COMPLETE (2026-10-07): `ja` is 100% — 7,889/7,889 keys**, same as `th`.
|
||
Both partial-locale rules below still hold for any *future* keys.
|
||
- **Partial by design** (same as `th`): a missing `ja` key falls back to English.
|
||
`check-i18n.mjs` reports `ja` coverage and fails if a `ja` key is absent from `en`.
|
||
- **Font:** `@fontsource/noto-sans-jp` (Noto Sans JP), **lazy-loaded per locale** (`lib/localeFonts.ts`) and
|
||
added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow/Kanit have
|
||
**no Japanese glyphs**, so without it Japanese falls back to a system font.
|
||
- **Detection:** a Japanese-device browser (`ja*`) starts in `ja` (`detectLanguage`).
|
||
- ⚠️ **`LanguageToggle` inside a Radix Sheet/Dialog must use the `inline` prop.**
|
||
The dropdown is a Radix `DropdownMenu` that **portals to `<body>`**, and an open
|
||
Sheet/Dialog sets `pointer-events: none` on `<body>` — so the portaled menu
|
||
inherits it and its items become **unclickable**. `LanguageToggle inline`
|
||
renders plain buttons (no portal) instead. The mobile nav sheet uses `inline`;
|
||
the header keeps the dropdown (it is not inside a dialog). Same trap applies to
|
||
any portaled popover/menu placed inside a dialog.
|
||
|
||
### Hardcoded-string sweep (2026-10-07) — 7,889 → 8,337 keys
|
||
`check:i18n` only sees keys wired through `t()`, so **hardcoded English that never
|
||
called `t()` stayed English in EVERY locale** (es/th/ja included). Those are now
|
||
keyed. New namespaces live in **`src/i18n/legacy/`** as `{ en, es }` fragments
|
||
composed in `legacy/index.ts` and spread into `en`/`es` (same pattern as
|
||
`batch3/`–`batch4/`); the `th`/`ja` fragments mirror them:
|
||
- `deckCostUi` — the whole `/diy/deck-cost` calculator (170 keys)
|
||
- `becomeRolesUi` — `/become/*` role forms + `CraftProfileForm` (123 keys; added
|
||
`*Key` fields to `becomeRoles.tsx`)
|
||
- `weatherUi` — weather/AQI/skateability labels (60 keys; `weather.ts` gained
|
||
`*Key` fields, consumers updated: SpotDetailPage, SpotMap, TripItineraryCard,
|
||
the Sasquatch weather tool)
|
||
**Weather UI pass (2026-10-12):** added a map **legend**
|
||
(`components/WeatherLegend.tsx`; colours mirror `calculateSkateability`), a
|
||
**freshness + manual refresh** line and an **error/retry** state on the spot
|
||
weather card, **sunrise/sunset** (`weather.ts` fetches Open-Meteo daily
|
||
`sunrise,sunset`), and the score's reasons now render as **chips** instead of a
|
||
bullet-joined run-on. Popup `text-gray-500` → `gray-300` for contrast. Units stay
|
||
**Celsius / km/h** (deliberate — no unit toggle). The forecast is now **6 days**
|
||
(`FORECAST_DAYS` in `lib/weather.ts` is the single source for the fetch and the
|
||
labels; the legend colours live in `lib/skateabilityLevels.ts`). Labels use
|
||
`spotUi.forecastDays` / `mapUi.forecastDays` with a `{{count}}` param (the other
|
||
locales fall back to English until translated).
|
||
- `skaterLabelsUi` (21) — skater stance/style/level + sponsor tiers in `types.ts`
|
||
- `notifyUi` (28) — OS notification titles/bodies (`OsNotificationBridge`)
|
||
- `legacyMiscUi` (~160) — scattered component/page literals, image `alt`/`aria-label`,
|
||
and the generated export-document text (PDF/XLSX/PNG/Markdown/clipboard)
|
||
⚠️ Still English by design: the `src/i18n/glossary.ts` trick glossary, image
|
||
`alt`/`aria-label` (mostly brands or shadcn internals), example placeholders
|
||
(`you@example.com`, `npub1…`), and generated export-document bodies (PDF/XLSX).
|
||
Re-scan with `grep -rnP ">\s*[A-Z][A-Za-z][A-Za-z ,.!?&*()'-]{3,}<" src --include=*.tsx`.
|
||
- **Katakana/English for skate vocabulary** (spot→スポット, park→パーク, crew→クルー;
|
||
Stash/The Lab/Nostr/Lightning/Zap/sats stay English). Trick names stay English.
|
||
- **Castellano term change (2026-10-07):** crew is **"peña"** in `es.ts`, not
|
||
"cuadrilla" — a case-preserving replace of 158 occurrences (`Cuadrilla→Peña`,
|
||
`cuadrilla→peña`, `CUADRILLA→PEÑA`). ⚠️ Do **not** use `perl -CSD` on these files
|
||
(it mojibakes UTF-8); use a UTF-8-safe edit/Node.
|
||
- The app ships **English + Spanish (Castellano, Spain)**. The framework already
|
||
exists: `src/contexts/language.ts` (context/types/hooks) + `LanguageContext.tsx`
|
||
(provider), `src/i18n/en.ts` + `src/i18n/es.ts` (one default-export object
|
||
each), `t()` with dot-notation + `{{param}}` interpolation, English fallback and
|
||
a console warning for a missing key. Toggle in the header (`LanguageToggle`) and
|
||
Settings → Language; persisted in `localStorage` `app-language`.
|
||
- **First-visit auto-detect:** `detectLanguage()` in `LanguageContext.tsx` starts
|
||
Spanish-device visitors in `es` (Castellano), everyone else in English. A stored
|
||
choice always wins.
|
||
- **Translation policy (owner decision 2026-10-01):** Castellano; **only skate
|
||
TRICK names stay English** (kickflip, ollie, nollie, heelflip, 50-50, …).
|
||
Everything else — UI chrome, descriptions, buttons, errors — translates. Gear
|
||
words DO translate (decks → tablas, wheels → ruedas); trucks/bearings are common
|
||
as-is. Keep the do-not-translate list limited to tricks.
|
||
- **The Sasquatch replies in the app language:** `SasquatchGuide` appends a
|
||
`languageDirective` to the system prompt (both the first streaming pass and the
|
||
tool-result synthesis pass) when `language === 'es'`.
|
||
- **Keep `en.ts` and `es.ts` key-for-key in sync** — the fallback + warn makes
|
||
drift silent otherwise.
|
||
- **User-generated content is never translated** (spot / listing / DM / crew text
|
||
is whatever the author wrote).
|
||
- **Safety net:** `scripts/check-i18n.mjs` (wired into `npm test` as part of the
|
||
chain) fails the build if any literal `t('…')` key is missing from `en` OR `es`,
|
||
or if `en`/`es` drift. It **also scans data tables** for keys declared as
|
||
`key:`/`titleKey:`/`labelKey:`/`badgeKey:` (e.g. `src/lib/coachOptions.ts`),
|
||
because those are looked up dynamically via `t(option.key)` and a plain literal
|
||
scan misses them — that gap shipped raw `labUi.…` keys to the UI once
|
||
(fix 2026-10-04: 29 option keys were referenced but never translated). Still
|
||
unchecked: template keys built by interpolation (`t(\`ns.${x}\`)`) — keep those
|
||
few in sync by hand.
|
||
- **Coverage (2026-10-04, batches 1–4 done):** **~7,030 keys** (EN=ES) and ~250
|
||
files wired. Namespaces: batch 1 — `mapUi`, `spotUi`, `marketUi`, `labUi`;
|
||
batch 2 — `nav` (header/More/burger labels) + `chromeUi` (footer, toolbar
|
||
widgets, spot-type labels), `placesUi` (events/shops/tours/food), `socialUi`
|
||
(crews/DMs/notifications/collectives), `settingsUi` (settings/auth/signup/
|
||
onboarding/profile/legal), `walletUi` (wallet/Cashu, stash, mints, app shop,
|
||
newsletter, community stats, leaderboards, profile page); **batch 3** — see
|
||
below.
|
||
- **Batch 3 (2026-10-02) — done.** Its namespaces live in **fragment files under
|
||
`src/i18n/batch3/`**, spread into `en`/`es` via `batch3/index.ts`
|
||
(`export default { ...en, ...batch3En }`), so **parallel translation passes
|
||
never edit the big `en.ts`/`es.ts` literals** — add keys to the fragment, not
|
||
the literal.
|
||
- `routeUi.*` — `RouteDialog` (follow mode, transit time picker + mode filters,
|
||
itinerary legs), `StopDepartures`, `TripItineraryCard`, and the **routing
|
||
helpers** (`skateTime`, `distanceReadout`, `maneuverDescriptor` →
|
||
`MODE_LABEL_KEYS`/`MANEUVER_LABEL_KEYS` static maps, `routeCache.formatAge`).
|
||
- `sasquatchUi.*` — `SasquatchGuide`, `SasquatchProDialog`, `SasquatchActionCard`,
|
||
`SasquatchAiStatusCard`, plus `sasquatchPro.ts` `PRO_TIERS.labelKey/badgeKey`
|
||
and `useSasquatchActions.summarizeDraft(d, t)`. **LLM system prompts and chat
|
||
bodies stay English** (the model is language-directed separately).
|
||
- `collectionUi.*` / `bootUi.*` — `AddToCollectionDialog` placeholders,
|
||
`SplashScreen` status lines.
|
||
- `labelsUi.*` (221 keys) — the **shared data-module labels**: spot categories/
|
||
features/types, access, shelter, event types, gear categories/conditions,
|
||
price/listings, tours, crews, member-visibility, tag categories, spot status.
|
||
`src/lib/labelKeys.ts` holds the shared `*_KEYS` value→key maps (single
|
||
source; the five duplicated `EVENT_TYPE_KEYS` copies now import it).
|
||
`statusTimeAgo()` and `formatPrice(price, type, t?)` are locale-aware now.
|
||
- **Dates & relative times are localized (2026-10-02):**
|
||
**`src/lib/dateFnsI18n.ts`** re-exports `date-fns` with
|
||
`formatDistanceToNow`/`…Strict`/`formatDistance`/`format` defaulting to the
|
||
active locale, plus `dateLocaleCode()` for native `toLocale{Date,Time}String`.
|
||
`LanguageProvider` calls `setDateLocale(language)` **during render** so the
|
||
first paint is in-language. **Import from `@/lib/dateFnsI18n`, not
|
||
`date-fns`.** Number `.toLocaleString()` calls are deliberately left on the
|
||
runtime default.
|
||
- **Batch 4 (2026-10-04) — done.** Fragment layer `src/i18n/batch4/`
|
||
(spread as `batch4En`/`batch4Es` alongside batch 3).
|
||
- `labOptions.*` (430 keys) — every Lab `*_LABELS`/`*_DESCRIPTIONS` map in
|
||
`src/lib/types.ts` (coach/filmer/editor/photo/builder/artist/graffiti/tattoo/
|
||
trainer/musician/judge/organizer/barber) + `TOUR_SKILL_LEVELS`; **51 shared
|
||
key maps appended to `src/lib/labelKeys.ts`**. Consumers: `becomeRoles.tsx`,
|
||
`CraftProfileForm` (`SkillSection` gained a `keyMap`), `LabPage`,
|
||
`CoachDetailPage`, the five bespoke `/become/*` pages, `TourCard`/
|
||
`TourDetailPage`/`CreateTourPage`/`EventsPage`. Trick names and tool/brand
|
||
nouns intentionally stay English.
|
||
- `sasquatchChat.*` (83 keys) — the free-tier `STATIC_FAQ` (English matcher →
|
||
`sasquatchChat.faq.*` key) and local-result/fallback chat bodies. Live AI is
|
||
already language-directed; **prompts stay English**.
|
||
- `feesUi.*` (57 keys) — `usePurchasePro` receipt prose/errors and
|
||
`PlatformFeePayment` fee/duration tiers (`LabProUpgrade`/`FeaturedEventUpgrade`
|
||
were already `labUi`/`placesUi`).
|
||
- ✅ **Not translated, deliberately:** LLM system/synthesis prompts and
|
||
tool-result instruction strings (model-facing), feed-supplied data (line
|
||
names, headsigns, roads, stops, weather condition labels, operator names),
|
||
brand/proper nouns, and the separate `src/lib/glossary.ts` trick glossary.
|
||
`TagCategory` names/descriptions and `MUSICIAN_ACT_LABELS` have keys but no
|
||
rendering consumer. Number `.toLocaleString()` stays on the runtime default.
|
||
Grep a module before assuming it is untranslated.
|
||
- ⚠️ A batch-2 agent dropped the `socialUi.` namespace prefix in ~36 files — the
|
||
`check:i18n` guard caught all 738 usages. **Always run `node scripts/check-i18n.mjs`
|
||
(it's in `npm test`) after wiring a file.**
|
||
- ⚠️ **When wiring a file, keep `en.ts`/`es.ts` key-for-key in sync** and run
|
||
`node scripts/check-i18n.mjs` before committing.
|
||
|
||
## Security & correctness audit fixes (2026-10-04)
|
||
|
||
A five-part audit (security · bugs/logic · UI/UX · Nostr interop · flows) was run;
|
||
this is what was FIXED (each behind `npm test` + smoke) and what remains.
|
||
|
||
**Fixed — money & auth.** Marketplace payments verify the invoice amount
|
||
(`usePayPaymentPointer` → `assertInvoiceAmount`), so a seller can't overcharge via
|
||
a raw BOLT11 or an inflated LNURL invoice. NIP-17 DMs call `verifyEvent(seal)` in
|
||
all four read paths (sender impersonation). `push-service` enforces endpoint
|
||
ownership on subscribe/unsubscribe and validates the endpoint (`isSafePushEndpoint`,
|
||
SSRF) — ⚠️ **requires `npm run deploy:push` to take effect**. The Amber callback no
|
||
longer logs in from a bare `?event=<pubkey>` (must match a pending
|
||
`get_public_key`) and no longer logs the pending-op payload. `usePlatformFee`
|
||
records are per-pubkey (was device-global).
|
||
|
||
**Fixed — trust/data integrity.** `useAdminModeration` fetches the `cleared` label
|
||
(Admin Undo now propagates cross-device). Admin takedowns publish the NIP-32
|
||
`hidden` label instead of an ignored kind-5. The `/stats` "partial outage" banner
|
||
works (`queryCanonical` gained an `onError` hook). `useShops` has its own cache
|
||
namespace (no longer collides with `useSkateShops`). Listing NIP-05 is verified
|
||
(`verifyNip05`), not self-asserted. NIP-22 spot comments/transfers use uppercase
|
||
root `A`/`K`/`P` and readers accept legacy `#a`. Lab profile edits preserve foreign
|
||
`a` spot refs; collections preserve foreign NIP-51 tags; `LAB_KINDS` = all 13.
|
||
Skip-to-content is a button that focuses `<main>` (HashRouter-safe).
|
||
`CustomizeNavSheet` is translated; `useLocalStorage` re-reads on key change and
|
||
keeps `stateRef` in sync cross-tab; the backup flag is per-account
|
||
(`lib/backup.ts`). Listing detail / Events render `ErrorState` on outage.
|
||
|
||
**2026-10-28 audit tail.** `npm audit` is now **10 advisories (0 critical, 1
|
||
high, 9 moderate)**. The remaining high is **`xlsx`**, which is write-only in
|
||
this app (parse CVEs do not apply). Overrides pin `protobufjs@7.6.3` (the
|
||
critical ONNX model-parsing path) and `sharp@0.35.5` (Node-only transformers
|
||
dependency). The moderates are dev-only (`@capacitor/cli`/xcode/uuid, Tailwind
|
||
typography/postcss). AI-generated itinerary links go through `sanitizeUrl`;
|
||
every `target="_blank"` has `rel=noopener noreferrer`; no `javascript:`/raw user
|
||
HTML paths found. Auth is app-wide via `AuthDialogContext` so signup survives
|
||
card/page unmounts, and all internal nav links match a route.
|
||
|
||
**Deferred (documented, NOT fixed).**
|
||
- `H1` — **PARTIAL**: `claim-from` now stores the original **coordinate**
|
||
(`30818:pubkey:d`, not an ambiguous bare pubkey), `SkateSpot` parses
|
||
`claimedFrom`/`claimReason`, and a claimed listing shows a "Claimed listing"
|
||
notice. **Reader-side supersede is still open** — gating it safely (only an
|
||
admin-approved private claim, or an aged original) is a design item; a wrong
|
||
rule would hide real spots.
|
||
- `C5b` — **FIXED**: `src/lib/featuredVerification.ts` verifies a provider-authored
|
||
NIP-57 receipt (cached provider lookup) and the tour/event/Lab list hooks rank
|
||
only receipt-backed `featured` items; falls back to the self-asserted tag when
|
||
the provider is unreachable (availability over strictness).
|
||
- `H12` — **FIXED**: a preimage/deposit proof counts at most once (dedupe), so a
|
||
real payment can't be reused to inflate `paymentVerified`.
|
||
- `H13` — **FIXED**: the offer seller is always the listing author (derived from
|
||
the event pubkey), never a self-declared `role` tag.
|
||
- `H14` — **FIXED**: after an NWC `pay()` timeout, `useNWC` polls `lookupInvoice`
|
||
for settlement and, if still unknown, throws a coded error
|
||
(`PAYMENT_STATUS_UNKNOWN`). The callers that used to fall through to another
|
||
rail (`useZaps`, `usePurchasePro`, `PlatformFeePayment`) now stop and tell the
|
||
user to check their wallet — no automatic second payment.
|
||
- Medium/Low **mostly fixed in a follow-up "tier 3" commit**: tour sponsor URL
|
||
sanitized (XSS); stats persisted-cache reads null-guarded; Overpass no longer
|
||
assumes `.elements`; `user-listings` query key memoized; Pro grant requires the
|
||
`l` label; verify-card RSVP maps accepted/tentative; expired listings can't be
|
||
bought by URL; batch spot photo/status hooks take the relay suffix +
|
||
`querySkateContent`; offline `publishing` rows requeued; replaceable-event
|
||
dedupe by author+kind; route cache key includes origin; MarketplacePage uses
|
||
`<Link>`; EventCountdown reacts to language.
|
||
- Still open (lower priority): manual Pro "paid" isn't receipt-verified; the
|
||
background poll tokens are cleaned up server-side (register replaces per
|
||
pubkey) but `disable()` doesn't unregister; **H1** reader-side spot supersede
|
||
(partial — schema + notice done); a few i18n leftovers (some StashPage
|
||
literals) and gray-500/600 contrast.
|
||
|
||
## Event comms — Concord + Cordn (idea only, NOT started — 2026-10-03)
|
||
|
||
Parked for later evaluation; **no decision, no code.** The idea: a **host tool**
|
||
for running each event's communications, split by **event visibility** (public
|
||
vs private) — *not* a "staff room". Owner = the event's main host; per event,
|
||
plus a host console that lists/manages them. We likely run **no infrastructure**.
|
||
|
||
**Recommended default → Concord** (`concordprotocol.org`, MIT; Discord-shaped
|
||
E2EE over Nostr; relays only, no server):
|
||
- CORD-01 private streams · CORD-02 communities (key possession = membership;
|
||
owner-rooted roster; `community_id`; `community_root`/`control_root`; epochs;
|
||
Control/Chat/Guestbook planes) · CORD-03 public **and private** channels (each
|
||
its own key) · CORD-04 roles · CORD-05 invites (revocable links + kind-`3313`
|
||
direct invites) · CORD-06 rekeys/refoundings (removal = rotate) · CORD-07 A/V ·
|
||
CORD-08 disappearing messages.
|
||
- ⚠️ Concord "public" is **still E2EE** (open membership, not world-readable) —
|
||
world-readable chatter is plain Nostr (kind 1/`1111` on the event). Relays must
|
||
NOT enforce NIP-59's `p` guard (members subscribe by stream author).
|
||
- Kinds: 1059/20013/20014/21059 envelopes; 9/1111/7/5/3302 chat; 3308 control;
|
||
3303 rekey; 3306/3309/3312 guestbook; 33301 invite bundle; 13303 invite list;
|
||
33302 community list; 3313 direct invite.
|
||
- ⚠️ **No official JS SDK** (`vector_sdk` is Rust). TS references: **Armada**
|
||
(Capacitor/TS) and **Accordion** (browser TS). Writing one is a real project
|
||
(frozen HKDF derivations, the Control-plane **fold**, epochs, byte-identical
|
||
encoding; interop failures are silent empty rooms).
|
||
|
||
**Optional upgrade (private events only) → Cordn/MLS**
|
||
(`github.com/Cordn-msg/cordn`; ts-mls coordinator + ContextVM server; per-group
|
||
cursors; KeyPackages; Docker `ghcr.io/cordn-msg/cordn`). Gives **forward secrecy
|
||
+ post-compromise security + true member removal**. ⚠️ **Requires a coordinator**
|
||
— if we don't run one, the host supplies one (public default on
|
||
`relay.contextvm.org`, or self-host). Cordn has **no A/V**. We already ship a
|
||
Coordinator settings card + `src/lib/cordn/*` + a crew `coordinator` tag, but the
|
||
app currently uses a Cordn URL **as a relay** and does NOT do coordinator RPC —
|
||
real MLS + RPC is new work.
|
||
|
||
**Open questions on revisit:** (1) public event = open-join E2EE (Concord) vs
|
||
world-readable (plain Nostr) vs both; (2) one host community with a channel per
|
||
event vs a community per event; (3) is a Concord **private channel** (zero infra)
|
||
enough for private events, or is MLS worth requiring a coordinator?
|
||
|
||
## Identity & conflict — parked until there are users (2026-10-07)
|
||
|
||
Owner decision: **build nothing here yet.** Zero users — revisit organically
|
||
rather than guessing. Thesis on the table (not acted on): internet toxicity is
|
||
downstream of **centralized identity + centralized conflict resolution**; the fix
|
||
is identity at a lower level + **plural, viewer-applied filtering**. A full idea
|
||
list was discussed this session but is deliberately **NOT started**.
|
||
|
||
**Related:** `TRUST_SCORE.md` collects the trust-scoring review and plan. Its
|
||
**first slice has landed**: `src/lib/nip85.ts` (kind-10040 designation +
|
||
kind-30382 assertion parsing; absence = unknown, never distrust),
|
||
`src/hooks/useTrustAssertions.ts` (batch, signature + author verified),
|
||
`src/lib/trustLadder.ts` (buckets Verified/Unknown/Flagged;
|
||
`FLAGGED_REPORTERS_THRESHOLD` 2), `src/hooks/useTrustBucket.ts`, and
|
||
`components/TrustBadge.tsx` now renders the three buckets (Unknown hidden by
|
||
default, Flagged always shown, and Verified suppressed for accounts you already
|
||
follow). `TrustBadge` is used by the map/tour/event cards, marketplace card,
|
||
listing detail and coach detail, so those surfaces get it for free. The provider
|
||
read is **batched** (one query per viewer for the union of on-screen subjects,
|
||
in `useTrustAssertions.ts`), and `useUserReports` + `components/TrustFlaggedNotice.tsx`
|
||
add a flagged-counterparty notice (reporters grouped by the viewer's network,
|
||
Report/Dismiss) wired into the listing seller and coach detail. **Legibility
|
||
pass:** the badge tooltip attributes the source (your network / community
|
||
provider / your provider) and links to a public **`/trust`** explainer;
|
||
**Settings → Trust** (`?section=trust`, `components/TrustSettingsCard.tsx`) has a
|
||
show/hide-marks toggle (`useTrustDisplay`, Flagged is never hidden) and shows the
|
||
active provider + relay (`useTrustProvider`), and lets the viewer **publish a
|
||
kind-10040 provider designation** (`useSetTrustProvider`: custom npub/hex +
|
||
`wss://` relay, or clear to fall back to the community provider). The local
|
||
(non-provider) ladder is computed **once per viewer** by `useViewerGraph` and
|
||
shared, so `TrustBadge` no longer fires a per-card `useTrustScore` graph query.
|
||
The local ladder has a **confidence gate**: `useViewerGraph` counts distinct
|
||
follows per friend-of-a-friend and `deriveLocalScore` only verifies at
|
||
`FOF_MIN_RATERS` (2) or more, so a lone endorsement is not trust; provider
|
||
assertions are unaffected.
|
||
**Mute landed:** `useMuteList` + `useMuteUser` (NIP-51 kind-10000, optimistic via
|
||
`lib/localMutes.ts`) feed `useAdminModeration`'s new `mutedPubkeys`, so the
|
||
shared `filterAdminHidden` drops muted authors across every list; the flagged
|
||
notice has a Mute action. **Alerts landed:** `useNetworkAlerts` + `/alerts`
|
||
(`pages/NetworkAlertsPage.tsx`) list accounts you follow that trusted people
|
||
reported/muted, with Ignore/Unfollow/Mute/Report; Settings → Trust links to it
|
||
with a count (ignored set in `skatespots:trust:alerts-ignored`). Deferred: our
|
||
own GrapeRank-style scorer — see the dated note below. Check
|
||
`TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` /
|
||
`trustFilter`.
|
||
|
||
**Own GrapeRank-style scorer — DEFERRED (2026-10-09).** Do NOT build the
|
||
`cvm-server` per-observer scorer yet. The full confidence model is provider-side
|
||
and already consumed via NIP-85, and the client fallback now has a local
|
||
confidence gate (`useViewerGraph`, `FOF_MIN_RATERS`). Why hold: personalized
|
||
kind-30382 needs a **per-observer signing key** (server key custody or per-user
|
||
delegation — a trust/key-management burden, not a checkbox); an 8-hop traversal is
|
||
heavy without a graph store; plus a provider identity, the publish/delete
|
||
lifecycle, deploy to the external host and monitoring. With ~zero users the ROI is
|
||
negative. Cheaper path if a controlled score is ever wanted: a
|
||
**house-perspective** scorer (one key, one observer, computed on a schedule,
|
||
publishes kind-30382) — Settings → Trust's provider choice consumes it with no
|
||
client change. **Revisit when:** there are users, no trustworthy provider exists,
|
||
or there are skate-specific signals (vouches, trade attestations) worth baking
|
||
into a score only we can compute. The client is already provider-ready
|
||
(`useTrustAssertions` + kind-10040 choice), so this decision costs nothing today.
|
||
|
||
**Keep the door open (cheap):** admin keys and relay sets must stay *data you can
|
||
swap*, not constants. Hardcoded `ADMIN_PUBKEYS` is fine now — converting it later
|
||
is a small refactor, not a trap.
|
||
|
||
**Trigger 1 — first time you want to hide/remove something, or an admin key is
|
||
compromised:** add **labeler choice** (users subscribe to signed moderator lists;
|
||
ship a *default* that is forkable, not baked in) + **show who decided** (signed
|
||
verdicts + author right-of-reply). Today: NIP-32 `hidden`/`verified`/`cleared`
|
||
from a hardcoded admin set = one key is the single point of capture.
|
||
|
||
**Trigger 2 — first shop or coach (stranger-trust, not just friends):** add
|
||
**social re-vouch** (a new key claims succession of an old key, endorsed by N ≥
|
||
known contacts, optional cooling period — no crypto shares) + **issuer-signed**
|
||
credentials (a federation/body key signs certs; users choose which issuers to
|
||
trust). Re-vouching rebuilds *trust only* — it can't move old addressable events,
|
||
the NIP-05 handle, zaps, or account age, and it can't help a shop/coach/stranger.
|
||
|
||
**Do NOT sell Stash backup as key recovery.** `src/lib/stashBackup.ts` password
|
||
mode survives key loss but restores **only the gear list**; nip44 mode +
|
||
multi-device sync need the key. The key *is* the identity (npub, spots, crew,
|
||
passes, handle, rep). PoW is anti-spam friction, **not** identity.
|
||
|
||
## Nostr Protocol Integration
|
||
|
||
This project comes with custom hooks for querying and publishing events on the Nostr network.
|
||
|
||
### Nostr Implementation Guidelines
|
||
|
||
- Always check the full list of existing NIPs before implementing any Nostr features to see what kinds are currently in use across all NIPs.
|
||
- If any existing kind or NIP might offer the required functionality, read the relevant NIPs to investigate thoroughly. Several NIPs may need to be read before making a decision.
|
||
- Only generate new kind numbers if no existing suitable kinds are found after comprehensive research.
|
||
|
||
Knowing when to create a new kind versus reusing an existing kind requires careful judgement. Introducing new kinds means the project won't be interoperable with existing clients. But deviating too far from the schema of a particular kind can cause different interoperability issues.
|
||
|
||
#### Choosing Between Existing NIPs and Custom Kinds
|
||
|
||
When implementing features that could use existing NIPs, follow this decision framework:
|
||
|
||
1. **Thorough NIP Review**: Before considering a new kind, always perform a comprehensive review of existing NIPs and their associated kinds. Get an overview of all NIPs, and then read specific NIPs and kind documentation to investigate any potentially relevant NIPs or kinds in detail. The goal is to find the closest existing solution.
|
||
|
||
2. **Prioritize Existing NIPs**: Always prefer extending or using existing NIPs over creating custom kinds, even if they require minor compromises in functionality.
|
||
|
||
3. **Interoperability vs. Perfect Fit**: Consider the trade-off between:
|
||
- **Interoperability**: Using existing kinds means compatibility with other Nostr clients
|
||
- **Perfect Schema**: Custom kinds allow perfect data modeling but create ecosystem fragmentation
|
||
|
||
4. **Extension Strategy**: When existing NIPs are close but not perfect:
|
||
- Use the existing kind as the base
|
||
- Add domain-specific tags for additional metadata
|
||
- Document the extensions in `NIP.md`
|
||
|
||
5. **When to Generate Custom Kinds**:
|
||
- No existing NIP covers the core functionality
|
||
- The data structure is fundamentally different from existing patterns
|
||
- The use case requires different storage characteristics (regular vs replaceable vs addressable)
|
||
- If you have a tool available to generate a kind, you **MUST** call the tool to generate a new kind rather than picking an arbitrary number
|
||
|
||
6. **Custom Kind Publishing**: When publishing events with custom generated kinds, always include a NIP-31 "alt" tag with a human-readable description of the event's purpose.
|
||
|
||
**Example Decision Process**:
|
||
```
|
||
Need: Equipment marketplace for farmers
|
||
Options:
|
||
1. NIP-15 (Marketplace) - Too structured for peer-to-peer sales
|
||
2. NIP-99 (Classified Listings) - Good fit, can extend with farming tags
|
||
3. Custom kind - Perfect fit but no interoperability
|
||
|
||
Decision: Use NIP-99 + farming-specific tags for best balance
|
||
```
|
||
|
||
#### Tag Design Principles
|
||
|
||
When designing tags for Nostr events, follow these principles:
|
||
|
||
1. **Kind vs Tags Separation**:
|
||
- **Kind** = Schema/structure (how the data is organized)
|
||
- **Tags** = Semantics/categories (what the data represents)
|
||
- Don't create different kinds for the same data structure
|
||
|
||
2. **Use Single-Letter Tags for Categories**:
|
||
- **Relays only index single-letter tags** for efficient querying
|
||
- Use `t` tags for categorization, not custom multi-letter tags
|
||
- Multiple `t` tags allow items to belong to multiple categories
|
||
|
||
3. **Relay-Level Filtering**:
|
||
- Design tags to enable efficient relay-level filtering with `#t: ["category"]`
|
||
- Avoid client-side filtering when relay-level filtering is possible
|
||
- Consider query patterns when designing tag structure
|
||
|
||
4. **Tag Examples**:
|
||
```json
|
||
// ❌ Wrong: Multi-letter tag, not queryable at relay level
|
||
["product_type", "electronics"]
|
||
|
||
// ✅ Correct: Single-letter tag, relay-indexed and queryable
|
||
["t", "electronics"]
|
||
["t", "smartphone"]
|
||
["t", "android"]
|
||
```
|
||
|
||
5. **Querying Best Practices**:
|
||
```typescript
|
||
// ❌ Inefficient: Get all events, filter in JavaScript
|
||
const events = await nostr.query([{ kinds: [30402] }]);
|
||
const filtered = events.filter(e => hasTag(e, 'product_type', 'electronics'));
|
||
|
||
// ✅ Efficient: Filter at relay level
|
||
const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
|
||
```
|
||
|
||
#### `t` Tag Filtering for Community-Specific Content
|
||
|
||
For applications focused on a specific community or niche, you can use `t` tags to filter events for the target audience.
|
||
|
||
**When to Use:**
|
||
- ✅ Community apps: "farmers" → `t: "farming"`, "Poland" → `t: "poland"`
|
||
- ❌ Generic platforms: Twitter clones, general Nostr clients
|
||
|
||
**Implementation:**
|
||
```typescript
|
||
// Publishing with community tag
|
||
createEvent({
|
||
kind: 1,
|
||
content: data.content,
|
||
tags: [['t', 'farming']]
|
||
});
|
||
|
||
// Querying community content
|
||
const events = await nostr.query([{
|
||
kinds: [1],
|
||
'#t': ['farming'],
|
||
limit: 20
|
||
}], { signal });
|
||
```
|
||
|
||
### Kind Ranges
|
||
|
||
An event's kind number determines the event's behavior and storage characteristics:
|
||
|
||
- **Regular Events** (1000 ≤ kind < 10000): Expected to be stored by relays permanently. Used for persistent content like notes, articles, etc.
|
||
- **Replaceable Events** (10000 ≤ kind < 20000): Only the latest event per pubkey+kind combination is stored. Used for profile metadata, contact lists, etc.
|
||
- **Addressable Events** (30000 ≤ kind < 40000): Identified by pubkey+kind+d-tag combination, only latest per combination is stored. Used for articles, long-form content, etc.
|
||
|
||
Kinds below 1000 are considered "legacy" kinds, and may have different storage characteristics based on their kind definition. For example, kind 1 is regular, while kind 3 is replaceable.
|
||
|
||
### Content Field Design Principles
|
||
|
||
When designing new event kinds, the `content` field should be used for semantically important data that doesn't need to be queried by relays. **Structured JSON data generally shouldn't go in the content field** (kind 0 being an early exception).
|
||
|
||
#### Guidelines
|
||
|
||
- **Use content for**: Large text, freeform human-readable content, or existing industry-standard JSON formats (Tiled maps, FHIR, GeoJSON)
|
||
- **Use tags for**: Queryable metadata, structured data, anything that needs relay-level filtering
|
||
- **Empty content is valid**: Many events need only tags with `content: ""`
|
||
- **Relays only index tags**: If you need to filter by a field, it must be a tag
|
||
|
||
#### Example
|
||
|
||
**✅ Good - queryable data in tags:**
|
||
```json
|
||
{
|
||
"kind": 30402,
|
||
"content": "",
|
||
"tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]]
|
||
}
|
||
```
|
||
|
||
**❌ Bad - structured data in content:**
|
||
```json
|
||
{
|
||
"kind": 30402,
|
||
"content": "{\"title\":\"Camera\",\"price\":250,\"category\":\"photo\"}",
|
||
"tags": [["d", "product-123"]]
|
||
}
|
||
```
|
||
|
||
### NIP.md
|
||
|
||
The file `NIP.md` is used by this project to define a custom Nostr protocol document. If the file doesn't exist, it means this project doesn't have any custom kinds associated with it.
|
||
|
||
Whenever new kinds are generated, the `NIP.md` file in the project must be created or updated to document the custom event schema. Whenever the schema of one of these custom events changes, `NIP.md` must also be updated accordingly.
|
||
|
||
### The `useNostr` Hook
|
||
|
||
The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively.
|
||
|
||
```typescript
|
||
import { useNostr } from '@nostrify/react';
|
||
|
||
function useCustomHook() {
|
||
const { nostr } = useNostr();
|
||
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### Connecting to Multiple Nostr Relays
|
||
|
||
By default, the `nostr` object from `useNostr` uses a pool configuration that reads data from 1 relay and publishes to all configured relays. However, you can connect to specific relays or groups of relays for more granular control:
|
||
|
||
#### Single Relay Connection
|
||
|
||
To read and publish from one specific relay, use `nostr.relay()` with a WebSocket URL:
|
||
|
||
```typescript
|
||
import { useNostr } from '@nostrify/react';
|
||
|
||
function useSpecificRelay() {
|
||
const { nostr } = useNostr();
|
||
|
||
// Connect to a specific relay
|
||
const relay = nostr.relay('wss://relay.damus.io');
|
||
|
||
// Query from this specific relay only
|
||
const events = await relay.query([{ kinds: [1], limit: 20 }], { signal });
|
||
|
||
// Publish to this specific relay only
|
||
await relay.event({ kind: 1, content: 'Hello from specific relay!' });
|
||
}
|
||
```
|
||
|
||
#### Multiple Relay Group
|
||
|
||
To read and publish from a specific set of relays, use `nostr.group()` with an array of relay URLs:
|
||
|
||
```typescript
|
||
import { useNostr } from '@nostrify/react';
|
||
|
||
function useRelayGroup() {
|
||
const { nostr } = useNostr();
|
||
|
||
// Create a group of specific relays
|
||
const relayGroup = nostr.group([
|
||
'wss://relay.damus.io',
|
||
'wss://relay.nostr.band',
|
||
'wss://nos.lol'
|
||
]);
|
||
|
||
// Query from all relays in the group
|
||
const events = await relayGroup.query([{ kinds: [1], limit: 20 }], { signal });
|
||
|
||
// Publish to all relays in the group
|
||
await relayGroup.event({ kind: 1, content: 'Hello from relay group!' });
|
||
}
|
||
```
|
||
|
||
#### API Consistency
|
||
|
||
Both `relay` and `group` objects have the same API as the main `nostr` object, including:
|
||
|
||
- `.query()` - Query events with filters
|
||
- `.req()` - Create subscriptions
|
||
- `.event()` - Publish events
|
||
- All other Nostr protocol methods
|
||
|
||
#### Use Cases
|
||
|
||
**Single Relay (`nostr.relay()`):**
|
||
- Testing specific relay behavior
|
||
- Querying relay-specific content
|
||
- Debugging connectivity issues
|
||
- Working with specialized relays
|
||
|
||
**Relay Group (`nostr.group()`):**
|
||
- Querying from trusted relay sets
|
||
- Publishing to specific communities
|
||
- Load balancing across relay subsets
|
||
- Geographic relay optimization
|
||
|
||
**Default Pool (`nostr`):**
|
||
- General application queries
|
||
- Maximum reach for publishing
|
||
- Default user experience
|
||
- Simplified relay management
|
||
|
||
### Query Nostr Data with `useNostr` and Tanstack Query
|
||
|
||
When querying Nostr, the best practice is to create custom hooks that combine `useNostr` and `useQuery` to get the required data.
|
||
|
||
```typescript
|
||
import { useNostr } from '@nostrify/react';
|
||
import { useQuery } from '@tanstack/query';
|
||
|
||
function usePosts() {
|
||
const { nostr } = useNostr();
|
||
|
||
return useQuery({
|
||
queryKey: ['posts'],
|
||
queryFn: async (c) => {
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]);
|
||
const events = await nostr.query([{ kinds: [1], limit: 20 }], { signal });
|
||
return events; // these events could be transformed into another format
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
### Efficient Query Design
|
||
|
||
**Critical**: Always minimize the number of separate queries to avoid rate limiting and improve performance. Combine related queries whenever possible.
|
||
|
||
**✅ Efficient - Single query with multiple kinds:**
|
||
```typescript
|
||
// Query multiple event types in one request
|
||
const events = await nostr.query([
|
||
{
|
||
kinds: [1, 6, 16], // All repost kinds in one query
|
||
'#e': [eventId],
|
||
limit: 150,
|
||
}
|
||
], { signal });
|
||
|
||
// Separate by type in JavaScript
|
||
const notes = events.filter((e) => e.kind === 1);
|
||
const reposts = events.filter((e) => e.kind === 6);
|
||
const genericReposts = events.filter((e) => e.kind === 16);
|
||
```
|
||
|
||
**❌ Inefficient - Multiple separate queries:**
|
||
```typescript
|
||
// This creates unnecessary load and can trigger rate limiting
|
||
const [notes, reposts, genericReposts] = await Promise.all([
|
||
nostr.query([{ kinds: [1], '#e': [eventId] }], { signal }),
|
||
nostr.query([{ kinds: [6], '#e': [eventId] }], { signal }),
|
||
nostr.query([{ kinds: [16], '#e': [eventId] }], { signal }),
|
||
]);
|
||
```
|
||
|
||
**Query Optimization Guidelines:**
|
||
1. **Combine kinds**: Use `kinds: [1, 6, 16]` instead of separate queries
|
||
2. **Use multiple filters**: When you need different tag filters, use multiple filter objects in a single query
|
||
3. **Adjust limits**: When combining queries, increase the limit appropriately
|
||
4. **Filter in JavaScript**: Separate event types after receiving results rather than making multiple requests
|
||
5. **Consider relay capacity**: Each query consumes relay resources and may count against rate limits
|
||
|
||
The data may be transformed into a more appropriate format if needed, and multiple calls to `nostr.query()` may be made in a single queryFn.
|
||
|
||
### Event Validation
|
||
|
||
When querying events, if the event kind being returned has required tags or required JSON fields in the content, the events should be filtered through a validator function. This is not generally needed for kinds such as 1, where all tags are optional and the content is freeform text, but is especially useful for custom kinds as well as kinds with strict requirements.
|
||
|
||
```typescript
|
||
// Example validator function for NIP-52 calendar events
|
||
function validateCalendarEvent(event: NostrEvent): boolean {
|
||
// Check if it's a calendar event kind
|
||
if (![31922, 31923].includes(event.kind)) return false;
|
||
|
||
// Check for required tags according to NIP-52
|
||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
||
const start = event.tags.find(([name]) => name === 'start')?.[1];
|
||
|
||
// All calendar events require 'd', 'title', and 'start' tags
|
||
if (!d || !title || !start) return false;
|
||
|
||
// Additional validation for date-based events (kind 31922)
|
||
if (event.kind === 31922) {
|
||
// start tag should be in YYYY-MM-DD format for date-based events
|
||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||
if (!dateRegex.test(start)) return false;
|
||
}
|
||
|
||
// Additional validation for time-based events (kind 31923)
|
||
if (event.kind === 31923) {
|
||
// start tag should be a unix timestamp for time-based events
|
||
const timestamp = parseInt(start);
|
||
if (isNaN(timestamp) || timestamp <= 0) return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function useCalendarEvents() {
|
||
const { nostr } = useNostr();
|
||
|
||
return useQuery({
|
||
queryKey: ['calendar-events'],
|
||
queryFn: async (c) => {
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]);
|
||
const events = await nostr.query([{ kinds: [31922, 31923], limit: 20 }], { signal });
|
||
|
||
// Filter events through validator to ensure they meet NIP-52 requirements
|
||
return events.filter(validateCalendarEvent);
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
### The `useAuthor` Hook
|
||
|
||
To display profile data for a user by their Nostr pubkey (such as an event author), use the `useAuthor` hook.
|
||
|
||
```tsx
|
||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
|
||
function Post({ event }: { event: NostrEvent }) {
|
||
const author = useAuthor(event.pubkey);
|
||
const metadata: NostrMetadata | undefined = author.data?.metadata;
|
||
|
||
const displayName = metadata?.name ?? genUserName(event.pubkey);
|
||
const profileImage = metadata?.picture;
|
||
|
||
// ...render elements with this data
|
||
}
|
||
```
|
||
|
||
### `NostrMetadata` type
|
||
|
||
```ts
|
||
/** Kind 0 metadata. */
|
||
interface NostrMetadata {
|
||
/** A short description of the user. */
|
||
about?: string;
|
||
/** A URL to a wide (~1024x768) picture to be optionally displayed in the background of a profile screen. */
|
||
banner?: string;
|
||
/** A boolean to clarify that the content is entirely or partially the result of automation, such as with chatbots or newsfeeds. */
|
||
bot?: boolean;
|
||
/** An alternative, bigger name with richer characters than `name`. `name` should always be set regardless of the presence of `display_name` in the metadata. */
|
||
display_name?: string;
|
||
/** A bech32 lightning address according to NIP-57 and LNURL specifications. */
|
||
lud06?: string;
|
||
/** An email-like lightning address according to NIP-57 and LNURL specifications. */
|
||
lud16?: string;
|
||
/** A short name to be displayed for the user. */
|
||
name?: string;
|
||
/** An email-like Nostr address according to NIP-05. */
|
||
nip05?: string;
|
||
/** A URL to the user's avatar. */
|
||
picture?: string;
|
||
/** A web URL related in any way to the event author. */
|
||
website?: string;
|
||
}
|
||
```
|
||
|
||
### The `useNostrPublish` Hook
|
||
|
||
To publish events, use the `useNostrPublish` hook in this project. This hook automatically adds a "client" tag to published events.
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
|
||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
|
||
export function MyComponent() {
|
||
const [ data, setData] = useState<Record<string, string>>({});
|
||
|
||
const { user } = useCurrentUser();
|
||
const { mutate: createEvent } = useNostrPublish();
|
||
|
||
const handleSubmit = () => {
|
||
createEvent({ kind: 1, content: data.content });
|
||
};
|
||
|
||
if (!user) {
|
||
return <span>You must be logged in to use this form.</span>;
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} disabled={!user}>
|
||
{/* ...some input fields */}
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
The `useCurrentUser` hook should be used to ensure that the user is logged in before they are able to publish Nostr events.
|
||
|
||
### Nostr Login
|
||
|
||
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
|
||
|
||
```tsx
|
||
import { LoginArea } from "@/components/auth/LoginArea";
|
||
|
||
function MyComponent() {
|
||
return (
|
||
<div>
|
||
{/* other components ... */}
|
||
|
||
<LoginArea className="max-w-60" />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
The `LoginArea` component handles all the login-related UI and interactions, including displaying login dialogs, sign up functionality, and switching between accounts. It should not be wrapped in any conditional logic.
|
||
|
||
`LoginArea` displays both "Log in" and "Sign Up" buttons when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like `flex` (to make it a block element) or `w-full`. If it is left as inline-flex, it's recommended to set a max width.
|
||
|
||
**Important**: Social applications should include a profile menu button in the main interface (typically in headers/navigation) to provide access to account settings, profile editing, and logout functionality. Don't only show `LoginArea` in logged-out states.
|
||
|
||
### `npub`, `naddr`, and other Nostr addresses
|
||
|
||
Nostr defines a set of bech32-encoded identifiers in NIP-19. Their prefixes and purposes:
|
||
|
||
- `npub1`: **public keys** - Just the 32-byte public key, no additional metadata
|
||
- `nsec1`: **private keys** - Secret keys (should never be displayed publicly)
|
||
- `note1`: **event IDs** - Just the 32-byte event ID (hex), no additional metadata
|
||
- `nevent1`: **event pointers** - Event ID plus optional relay hints and author pubkey
|
||
- `nprofile1`: **profile pointers** - Public key plus optional relay hints and petname
|
||
- `naddr1`: **addressable event coordinates** - For parameterized replaceable events (kind 30000-39999)
|
||
- `nrelay1`: **relay references** - Relay URLs (deprecated)
|
||
|
||
#### Key Differences Between Similar Identifiers
|
||
|
||
**`note1` vs `nevent1`:**
|
||
- `note1`: Contains only the event ID (32 bytes) - specifically for kind:1 events (Short Text Notes) as defined in NIP-10
|
||
- `nevent1`: Contains event ID plus optional relay hints and author pubkey - for any event kind
|
||
- Use `note1` for simple references to text notes and threads
|
||
- Use `nevent1` when you need to include relay hints or author context for any event type
|
||
|
||
**`npub1` vs `nprofile1`:**
|
||
- `npub1`: Contains only the public key (32 bytes)
|
||
- `nprofile1`: Contains public key plus optional relay hints and petname
|
||
- Use `npub1` for simple user references
|
||
- Use `nprofile1` when you need to include relay hints or display name context
|
||
|
||
#### NIP-19 Routing Implementation
|
||
|
||
**Critical**: NIP-19 identifiers should be handled at the **root level** of URLs (e.g., `/note1...`, `/npub1...`, `/naddr1...`), NOT nested under paths like `/note/note1...` or `/profile/npub1...`.
|
||
|
||
This project includes a boilerplate `NIP19Page` component that provides the foundation for handling all NIP-19 identifier types at the root level. The component is configured in the routing system and ready for AI agents to populate with specific functionality.
|
||
|
||
**How it works:**
|
||
|
||
1. **Root-Level Route**: The route `/:nip19` in `AppRouter.tsx` catches all NIP-19 identifiers
|
||
2. **Automatic Decoding**: The `NIP19Page` component automatically decodes the identifier using `nip19.decode()`
|
||
3. **Type-Specific Sections**: Different sections are rendered based on the identifier type:
|
||
- `npub1`/`nprofile1`: Profile section with placeholder for profile view
|
||
- `note1`: Note section with placeholder for kind:1 text note view
|
||
- `nevent1`: Event section with placeholder for any event type view
|
||
- `naddr1`: Addressable event section with placeholder for articles, marketplace items, etc.
|
||
4. **Error Handling**: Invalid, vacant, or unsupported identifiers show 404 NotFound page
|
||
5. **Ready for Population**: Each section includes comments indicating where AI agents should implement specific functionality
|
||
|
||
**Example URLs that work automatically:**
|
||
- `/npub1abc123...` - User profile (needs implementation)
|
||
- `/note1def456...` - Kind:1 text note (needs implementation)
|
||
- `/nevent1ghi789...` - Any event with relay hints (needs implementation)
|
||
- `/naddr1jkl012...` - Addressable event (needs implementation)
|
||
|
||
**Features included:**
|
||
- Basic NIP-19 identifier decoding and routing
|
||
- Type-specific sections for different identifier types
|
||
- Error handling for invalid identifiers
|
||
- Responsive container structure
|
||
- Comments indicating where to implement specific views
|
||
|
||
**Error handling:**
|
||
- Invalid NIP-19 format → 404 NotFound
|
||
- Unsupported identifier types (like `nsec1`) → 404 NotFound
|
||
- Empty or missing identifiers → 404 NotFound
|
||
|
||
To implement NIP-19 routing in your Nostr application:
|
||
|
||
1. **The NIP19Page boilerplate is already created** - populate sections with specific functionality
|
||
2. **The route is already configured** in `AppRouter.tsx`
|
||
3. **Error handling is built-in** - all edge cases show appropriate 404 responses
|
||
4. **Add specific components** for profile views, event displays, etc. as needed
|
||
|
||
#### Event Type Distinctions
|
||
|
||
**`note1` identifiers** are specifically for **kind:1 events** (Short Text Notes) as defined in NIP-10: "Text Notes and Threads". These are the basic social media posts in Nostr.
|
||
|
||
**`nevent1` identifiers** can reference any event kind and include additional metadata like relay hints and author pubkey. Use `nevent1` when:
|
||
- The event is not a kind:1 text note
|
||
- You need to include relay hints for better discoverability
|
||
- You want to include author context
|
||
|
||
#### Use in Filters
|
||
|
||
The base Nostr protocol uses hex string identifiers when filtering by event IDs and pubkeys. Nostr filters only accept hex strings.
|
||
|
||
```ts
|
||
// ❌ Wrong: naddr is not decoded
|
||
const events = await nostr.query(
|
||
[{ ids: [naddr] }],
|
||
{ signal }
|
||
);
|
||
```
|
||
|
||
Corrected example:
|
||
|
||
```ts
|
||
// Import nip19 from nostr-tools
|
||
import { nip19 } from 'nostr-tools';
|
||
|
||
// Decode a NIP-19 identifier
|
||
const decoded = nip19.decode(value);
|
||
|
||
// Optional: guard certain types (depending on the use-case)
|
||
if (decoded.type !== 'naddr') {
|
||
throw new Error('Unsupported Nostr identifier');
|
||
}
|
||
|
||
// Get the addr object
|
||
const naddr = decoded.data;
|
||
|
||
// ✅ Correct: naddr is expanded into the correct filter
|
||
const events = await nostr.query(
|
||
[{
|
||
kinds: [naddr.kind],
|
||
authors: [naddr.pubkey],
|
||
'#d': [naddr.identifier],
|
||
}],
|
||
{ signal }
|
||
);
|
||
```
|
||
|
||
#### Implementation Guidelines
|
||
|
||
1. **Always decode NIP-19 identifiers** before using them in queries
|
||
2. **Use the appropriate identifier type** based on your needs:
|
||
- Use `note1` for kind:1 text notes specifically
|
||
- Use `nevent1` when including relay hints or for non-kind:1 events
|
||
- Use `naddr1` for addressable events (always includes author pubkey for security)
|
||
3. **Handle different identifier types** appropriately:
|
||
- `npub1`/`nprofile1`: Display user profiles
|
||
- `note1`: Display kind:1 text notes specifically
|
||
- `nevent1`: Display any event with optional relay context
|
||
- `naddr1`: Display addressable events (articles, marketplace items, etc.)
|
||
4. **Security considerations**: Always use `naddr1` for addressable events instead of just the `d` tag value, as `naddr1` contains the author pubkey needed to create secure filters
|
||
5. **Error handling**: Gracefully handle invalid or unsupported NIP-19 identifiers with 404 responses
|
||
|
||
### Nostr Edit Profile
|
||
|
||
To include an Edit Profile form, place the `EditProfileForm` component in the project:
|
||
|
||
```tsx
|
||
import { EditProfileForm } from "@/components/EditProfileForm";
|
||
|
||
function EditProfilePage() {
|
||
return (
|
||
<div>
|
||
{/* you may want to wrap this in a layout or include other components depending on the project ... */}
|
||
|
||
<EditProfileForm />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
The `EditProfileForm` component displays just the form. It requires no props, and will "just work" automatically.
|
||
|
||
### Uploading Files on Nostr
|
||
|
||
Use the `useUploadFile` hook to upload files. This hook uses Blossom servers for file storage and returns NIP-94 compatible tags.
|
||
|
||
```tsx
|
||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||
|
||
function MyComponent() {
|
||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||
|
||
const handleUpload = async (file: File) => {
|
||
try {
|
||
// Provides an array of NIP-94 compatible tags
|
||
// The first tag in the array contains the URL
|
||
const [[_, url]] = await uploadFile(file);
|
||
// ...use the url
|
||
} catch (error) {
|
||
// ...handle errors
|
||
}
|
||
};
|
||
|
||
// ...rest of component
|
||
}
|
||
```
|
||
|
||
To attach files to kind 1 events, each file's URL should be appended to the event's `content`, and an `imeta` tag should be added for each file. For kind 0 events, the URL by itself can be used in relevant fields of the JSON content.
|
||
|
||
### Nostr Encryption and Decryption
|
||
|
||
The logged-in user has a `signer` object (matching the NIP-07 signer interface) that can be used for encryption and decryption. The signer's nip44 methods handle all cryptographic operations internally, including key derivation and conversation key management, so you never need direct access to private keys. Always use the signer interface for encryption rather than requesting private keys from users, as this maintains security and follows best practices.
|
||
|
||
```ts
|
||
// Get the current user
|
||
const { user } = useCurrentUser();
|
||
|
||
// Optional guard to check that nip44 is available
|
||
if (!user.signer.nip44) {
|
||
throw new Error("Please upgrade your signer extension to a version that supports NIP-44 encryption");
|
||
}
|
||
|
||
// Encrypt message to self
|
||
const encrypted = await user.signer.nip44.encrypt(user.pubkey, "hello world");
|
||
// Decrypt message to self
|
||
const decrypted = await user.signer.nip44.decrypt(user.pubkey, encrypted) // "hello world"
|
||
```
|
||
|
||
### Rendering Rich Text Content
|
||
|
||
Nostr text notes (kind 1, 11, and 1111) have a plaintext `content` field that may contain URLs, hashtags, and Nostr URIs. These events should render their content using the `NoteContent` component:
|
||
|
||
```tsx
|
||
import { NoteContent } from "@/components/NoteContent";
|
||
|
||
export function Post(/* ...props */) {
|
||
// ...
|
||
|
||
return (
|
||
<CardContent className="pb-2">
|
||
<div className="whitespace-pre-wrap break-words">
|
||
<NoteContent event={post} className="text-sm" />
|
||
</div>
|
||
</CardContent>
|
||
);
|
||
}
|
||
```
|
||
|
||
## App Configuration
|
||
|
||
The project includes an `AppProvider` that manages global application state including theme and NIP-65 relay configuration. The default configuration includes:
|
||
|
||
```typescript
|
||
const defaultConfig: AppConfig = {
|
||
theme: "light",
|
||
relayMetadata: {
|
||
relays: [
|
||
{ url: 'wss://relay.ditto.pub', read: true, write: true },
|
||
{ url: 'wss://relay.nostr.band', read: true, write: true },
|
||
{ url: 'wss://relay.damus.io', read: true, write: true },
|
||
],
|
||
updatedAt: 0,
|
||
},
|
||
};
|
||
```
|
||
|
||
The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations.
|
||
|
||
### Relay Management
|
||
|
||
The project includes a complete NIP-65 relay management system:
|
||
|
||
- **RelayListManager**: Component for managing multiple relays with read/write permissions
|
||
- **NostrSync**: Automatically syncs user's NIP-65 relay list when they log in
|
||
- **Automatic Publishing**: Changes to relay configuration are automatically published as NIP-65 events when the user is logged in
|
||
|
||
Use the `RelayListManager` component to provide relay management interfaces:
|
||
|
||
```tsx
|
||
import { RelayListManager } from '@/components/RelayListManager';
|
||
|
||
function SettingsPage() {
|
||
return (
|
||
<div>
|
||
<h2>Relay Settings</h2>
|
||
<RelayListManager />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
## Routing
|
||
|
||
The project uses `react-router-dom`'s **HashRouter** with a centralized
|
||
configuration in `AppRouter.tsx`. Because it's a hash router, the route is the
|
||
part after `#` — `https://skate-spots.org/#/settings`. Links must use `<Link>`
|
||
(a bare `<a href="/settings">` or a plain path loads the homepage / 404s). To add
|
||
a route:
|
||
|
||
1. Create your page component in `/src/pages/`
|
||
2. Add it with **`lazyWithRetry`** (not bare `lazy`) and a `<Route>` above the
|
||
catch-all `*` route:
|
||
|
||
```tsx
|
||
const YourPage = lazyWithRetry(() => import("./pages/YourPage"));
|
||
// …
|
||
<Route path="/your-path" element={<YourPage />} />
|
||
```
|
||
|
||
The router includes automatic scroll-to-top functionality and a 404 NotFound
|
||
page for unmatched routes.
|
||
|
||
## Development Practices
|
||
|
||
- Uses React Query for data fetching and caching
|
||
- Follows shadcn/ui component patterns
|
||
- Implements Path Aliases with `@/` prefix for cleaner imports
|
||
- Uses Vite for fast development and production builds
|
||
- Component-based architecture with React hooks
|
||
- Default connection to one Nostr relay for best performance
|
||
- Comprehensive provider setup with NostrLoginProvider, QueryClientProvider, and custom AppProvider
|
||
- **Never use the `any` type**: Always use proper TypeScript types for type safety
|
||
|
||
## Loading States
|
||
|
||
**Use skeleton loading** for structured content (feeds, profiles, forms). **Use spinners** only for buttons or short operations.
|
||
|
||
```tsx
|
||
// Skeleton example matching component structure
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center space-x-3">
|
||
<Skeleton className="h-10 w-10 rounded-full" />
|
||
<div className="space-y-1">
|
||
<Skeleton className="h-4 w-24" />
|
||
<Skeleton className="h-3 w-16" />
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-4/5" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
```
|
||
|
||
### Empty States and No Content Found
|
||
|
||
When no content is found (empty search results, no data available, etc.), display a minimalist empty state with helpful messaging. The application uses NIP-65 relay management, so users can manage their relays through the settings or relay management interface.
|
||
|
||
```tsx
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
|
||
// Empty state example
|
||
<div className="col-span-full">
|
||
<Card className="border-dashed">
|
||
<CardContent className="py-12 px-8 text-center">
|
||
<div className="max-w-sm mx-auto space-y-6">
|
||
<p className="text-muted-foreground">
|
||
No results found. Try checking your relay connections or wait a moment for content to load.
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
```
|
||
|
||
## CRITICAL Design Standards
|
||
|
||
- Create breathtaking, immersive designs that feel like bespoke masterpieces, rivaling the polish of Apple, Stripe, or luxury brands
|
||
- Designs must be production-ready, fully featured, with no placeholders unless explicitly requested, ensuring every element serves a functional and aesthetic purpose
|
||
- Avoid generic or templated aesthetics at all costs; every design must have a unique, brand-specific visual signature that feels custom-crafted
|
||
- Headers must be dynamic, immersive, and storytelling-driven, using layered visuals, motion, and symbolic elements to reflect the brand’s identity—never use simple “icon and text” combos
|
||
- Incorporate purposeful, lightweight animations for scroll reveals, micro-interactions (e.g., hover, click, transitions), and section transitions to create a sense of delight and fluidity
|
||
|
||
### Design Principles
|
||
|
||
- Achieve Apple-level refinement with meticulous attention to detail, ensuring designs evoke strong emotions (e.g., wonder, inspiration, energy) through color, motion, and composition
|
||
- Deliver fully functional interactive components with intuitive feedback states, ensuring every element has a clear purpose and enhances user engagement
|
||
- Use custom illustrations, 3D elements, or symbolic visuals instead of generic stock imagery to create a unique brand narrative; stock imagery, when required, must be sourced exclusively from Pexels (NEVER Unsplash) and align with the design’s emotional tone
|
||
- Ensure designs feel alive and modern with dynamic elements like gradients, glows, or parallax effects, avoiding static or flat aesthetics
|
||
- Before finalizing, ask: "Would this design make Apple or Stripe designers pause and take notice?" If not, iterate until it does
|
||
|
||
### Avoid Generic Design
|
||
|
||
- No basic layouts (e.g., text-on-left, image-on-right) without significant custom polish, such as dynamic backgrounds, layered visuals, or interactive elements
|
||
- No simplistic headers; they must be immersive, animated, and reflective of the brand’s core identity and mission
|
||
- No designs that could be mistaken for free templates or overused patterns; every element must feel intentional and tailored
|
||
|
||
### Interaction Patterns
|
||
|
||
- Use progressive disclosure for complex forms or content to guide users intuitively and reduce cognitive load
|
||
- Incorporate contextual menus, smart tooltips, and visual cues to enhance navigation and usability
|
||
- Implement drag-and-drop, hover effects, and transitions with clear, dynamic visual feedback to elevate the user experience
|
||
- Support power users with keyboard shortcuts, ARIA labels, and focus states for accessibility and efficiency
|
||
- Add subtle parallax effects or scroll-triggered animations to create depth and engagement without overwhelming the user
|
||
|
||
### Technical Requirements
|
||
|
||
- Curated color FRpalette (3-5 evocative colors + neutrals) that aligns with the brand’s emotional tone and creates a memorable impact
|
||
- Ensure a minimum 4.5:1 contrast ratio for all text and interactive elements to meet accessibility standards
|
||
- Use expressive, readable fonts (18px+ for body text, 40px+ for headlines) with a clear hierarchy; pair a modern sans-serif (e.g., Inter) with an elegant serif (e.g., Playfair Display) for personality
|
||
- Design for full responsiveness, ensuring flawless performance and aesthetics across all screen sizes (mobile, tablet, desktop)
|
||
- Adhere to WCAG 2.1 AA guidelines, including keyboard navigation, screen reader support, and reduced motion options
|
||
- Follow an 8px grid system for consistent spacing, padding, and alignment to ensure visual harmony
|
||
- Add depth with subtle shadows, gradients, glows, and rounded corners (e.g., 16px radius) to create a polished, modern aesthetic
|
||
- Optimize animations and interactions to be lightweight and performant, ensuring smooth experiences across devices
|
||
|
||
### Components
|
||
|
||
- Design reusable, modular components with consistent styling, behavior, and feedback states (e.g., hover, active, focus, error)
|
||
- Include purposeful animations (e.g., scale-up on hover, fade-in on scroll) to guide attention and enhance interactivity without distraction
|
||
- Ensure full accessibility support with keyboard navigation, ARIA labels, and visible focus states (e.g., a glowing outline in an accent color)
|
||
- Use custom icons or illustrations for components to reinforce the brand’s visual identity
|
||
|
||
### Adding Fonts
|
||
|
||
To add custom fonts, follow these steps:
|
||
|
||
1. **Install a font package** using npm:
|
||
|
||
**Any Google Font can be installed** using the @fontsource packages. Examples:
|
||
- For Inter Variable: `@fontsource-variable/inter`
|
||
- For Roboto: `@fontsource/roboto`
|
||
- For Outfit Variable: `@fontsource-variable/outfit`
|
||
- For Poppins: `@fontsource/poppins`
|
||
- For Open Sans: `@fontsource/open-sans`
|
||
|
||
**Format**: `@fontsource/[font-name]` or `@fontsource-variable/[font-name]` (for variable fonts)
|
||
|
||
2. **Import the font** in `src/main.tsx`:
|
||
```typescript
|
||
import '@fontsource-variable/<font-name>';
|
||
```
|
||
|
||
3. **Update Tailwind configuration** in `tailwind.config.ts`:
|
||
```typescript
|
||
export default {
|
||
theme: {
|
||
extend: {
|
||
fontFamily: {
|
||
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
|
||
},
|
||
},
|
||
},
|
||
}
|
||
```
|
||
|
||
### Recommended Font Choices by Use Case
|
||
|
||
- **Modern/Clean**: Inter Variable, Outfit Variable, or Manrope
|
||
- **Professional/Corporate**: Roboto, Open Sans, or Source Sans Pro
|
||
- **Creative/Artistic**: Poppins, Nunito, or Comfortaa
|
||
- **Technical/Code**: JetBrains Mono, Fira Code, or Source Code Pro (for monospace)
|
||
|
||
### Theme System
|
||
|
||
The project includes a complete light/dark theme system using CSS custom properties. The theme can be controlled via:
|
||
|
||
- `useTheme` hook for programmatic theme switching
|
||
- CSS custom properties defined in `src/index.css`
|
||
- Automatic dark mode support with `.dark` class
|
||
|
||
### Color Scheme Implementation
|
||
|
||
When users specify color schemes:
|
||
- Update CSS custom properties in `src/index.css` (both `:root` and `.dark` selectors)
|
||
- Use Tailwind's color palette or define custom colors
|
||
- Ensure proper contrast ratios for accessibility
|
||
- Apply colors consistently across components (buttons, links, accents)
|
||
- Test both light and dark mode variants
|
||
|
||
### Component Styling Patterns
|
||
|
||
- Use `cn()` utility for conditional class merging
|
||
- Follow shadcn/ui patterns for component variants
|
||
- Implement responsive design with Tailwind breakpoints
|
||
- Add hover and focus states for interactive elements
|
||
|
||
## Writing Tests vs Running Tests
|
||
|
||
There is an important distinction between **writing new tests** and **running existing tests**:
|
||
|
||
### Writing Tests (Creating New Test Files)
|
||
|
||
**Do not write tests** unless the user explicitly requests them in plain language. Writing unnecessary tests wastes significant time and money. Only create tests when:
|
||
|
||
1. **The user explicitly asks for tests** to be written in their message
|
||
2. **The user describes a specific bug in plain language** and requests tests to help diagnose it
|
||
3. **The user says they are still experiencing a problem** that you have already attempted to solve (tests can help verify the fix)
|
||
|
||
**Never write tests because:**
|
||
- Tool results show test failures (these are not user requests)
|
||
- You think tests would be helpful
|
||
- New features or components are created
|
||
- Existing functionality needs verification
|
||
|
||
### Running Tests (Executing the Test Suite)
|
||
|
||
**ALWAYS run the test script** after making any code changes. This is mandatory regardless of whether you wrote new tests or not.
|
||
|
||
- **You must run the test script** to validate your changes
|
||
- **Your task is not complete** until the test script passes without errors
|
||
- **This applies to all changes** - bug fixes, new features, refactoring, or any code modifications
|
||
- **The test script includes** TypeScript compilation, ESLint checks, and existing test validation
|
||
|
||
### Test Setup
|
||
|
||
The project uses Vitest with jsdom environment and includes comprehensive test setup:
|
||
|
||
- **Testing Library**: React Testing Library with jest-dom matchers
|
||
- **Test Environment**: jsdom with mocked browser APIs (matchMedia, scrollTo, IntersectionObserver, ResizeObserver)
|
||
- **Test App**: `TestApp` component provides all necessary context providers for testing
|
||
|
||
The project includes a `TestApp` component that provides all necessary context providers for testing. Wrap components with this component to provide required context providers:
|
||
|
||
```tsx
|
||
import { describe, it, expect } from 'vitest';
|
||
import { render, screen } from '@testing-library/react';
|
||
import { TestApp } from '@/test/TestApp';
|
||
import { MyComponent } from './MyComponent';
|
||
|
||
describe('MyComponent', () => {
|
||
it('renders correctly', () => {
|
||
render(
|
||
<TestApp>
|
||
<MyComponent />
|
||
</TestApp>
|
||
);
|
||
|
||
expect(screen.getByText('Expected text')).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
## Validating Your Changes
|
||
|
||
**CRITICAL**: After making any code changes, you must validate your work by running available validation tools.
|
||
|
||
**Your task is not considered finished until the code successfully type-checks and builds without errors.**
|
||
|
||
### Validation Priority Order
|
||
|
||
Run available tools in this priority order:
|
||
|
||
1. **Type Checking** (Required): Ensure TypeScript compilation succeeds
|
||
2. **Building/Compilation** (Required): Verify the project builds successfully
|
||
3. **Linting** (Recommended): Check code style and catch potential issues
|
||
4. **Tests** (If Available): Run existing test suite
|
||
5. **Git Commit** (Required): Create a commit with your changes when finished
|
||
|
||
**Minimum Requirements:**
|
||
- Code must type-check without errors
|
||
- Code must build/compile successfully
|
||
- Fix any critical linting errors that would break functionality
|
||
- Create a git commit when your changes are complete
|
||
|
||
The validation ensures code quality and catches errors before deployment, regardless of the development environment.
|
||
|
||
### Using Git
|
||
|
||
If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files.
|
||
|
||
When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes. |