- Attendee card = 'MY TICKET', organiser card = 'EVENT · HOST' — never present a
self-made card as organiser-issued.
- A ticket's QR now carries ?h=<holder-npub>; /verify fetches that holder's RSVP
(kind 31925, #a = the event) and shows Ticket holder + RSVP status, so a door
has a real check with no server.
- NIP.md + AGENTS updated; i18n EN/ES.
- Park Access is only offered if you own a venue (a spot you authored), and
publishing REQUIRES a venue binding — only the venue owner's key can issue it.
The pass names the holder; /verify shows holder + venue match + expiry.
- Card types are now gated by what you can actually issue/derive (Park/ Crew /
Event), with a note explaining how to unlock more.
- Park panel shows a 'map a spot you own' prompt when you have no venue; the
Publish button is disabled until a venue + holder are set.
- NIP.md: venue binding is required for new passes. i18n EN/ES.
- QR 150->178px and moved up (bottom-right, above the identity block).
- Bottom scrim now fades into the THEME's dark background + an accent glow
(was plain black) so the card reads as branded.
- Studio shows an 'add a profile photo' nudge when the account has no kind-0
picture (the card's placeholder name was the tell).
The card had a portrait panel + dashed stub divider — two vertical lines with a
dead middle. Rebuilt as one composition: full-bleed photo/cover, a scrim, the
name + identity overlaid bottom-left, and the QR floating bottom-right. No photo
falls back to a themed gradient with a monogram watermark. Applied to all four
front types; the back is a plain list. Also: DEFAULT_RIDER_CARD.style is now ''
so a profile-less card doesn't claim 'Street'. Verified by rendering a real
profile, an app-profile, and an empty npub.
The card showed the placeholder (now initials) even when the npub had a photo:
useAuthor missed profiles that only live on the community relay and could drop
a profile its strict schema rejected. Card pages now use a tolerant
useCardProfile() that reads kind 0 via querySkateContent (community + user
relays) and JSON.parses leniently. Also: useAuthor now queries the community
relay as its own parallel branch (a flaky relay in the group used to reject the
whole batch); and '_@domain' NIP-05 no longer renders a lone '@'.
Verified by rendering a real profile's card headlessly.
- Name 82px -> 46px (was truncating 'KEEN HAWK' and dominating).
- QR now a proper right-hand verify stub: 214px band with a dashed divider and
a 156px QR (was a small bottom-corner stub). Applied to all front types.
- Rebalanced geometry (portrait 244, text 338, stub 214); identity/badges sit on
the lower band; back side stays plain typographic with a small right QR.
- Fixed a string of fillText(fitText(...)) calls that were missing their x.
Design: the card read like a spec sheet (16 competing elements, 5-6px type).
Rebuilt as an editorial credential:
- Left full-height portrait panel; right column = brand, ONE huge name, a single
identity line (style / level / local), nip05, crew monograms + witnessed
badges, and a clean QR verify stub. Dropped the tiny stats row, npub clutter,
'Powered by Nostr' filler and the 'SKATER LICENSE' chrome.
- Same system applied to Park / Crew / Event fronts; typed-up back side.
Engine: the studio preview and /card/:npub now render the canvas PNG, so there
is a SINGLE renderer (src/lib/riderCardCanvas.ts) — no React/canvas drift. The
old React card components + CardQr are deleted. Holographic is now a canvas
sheen. Also fixed several fillText calls missing an x-coord.
- Custom accent colour (native picker) overrides the theme accent via
--ss-accent in CSS + the canvas palette.
- Optional personal logo/crest (Blossom upload) shown next to the name.
- Printable back side (RiderCardBack React + drawCardBack canvas); Print renders
both sides, and the preview + public card can flip.
- Park passes enforce valid-thru: passExpiry() drives an EXPIRED badge on the
card and verify page; the input is now a date picker.
- Card schema -> v:3 (accent/logo/back). i18n; NIP.md + AGENTS.
- Two-pane studio: sticky live preview (desktop) + inspector column; mobile
stacks preview-first.
- Actions decluttered to a single primary Share + a '...' menu (Save PNG /
Print / Copy ID); removed the competing big buttons.
- Unsaved-changes state + manual Save changes button (no autosave, so an
interactive signer like Amber isn't prompted per keystroke).
- Card type is now a compact 4-up chip row; the two toggle grids merged into
ONE 'Show on card' list with live values.
- Skills: removed the self-claimed skill chips (marginal/unverifiable). The card
shows witnessed badges (kind 1293) only; style + level are now DERIVED from the
Skater profile (kind 35000) instead of a manual 'Skate Style' select.
- React card + canvas export updated; i18n cardUi.studio.*; NIP.md + AGENTS.
- /card gains a fourth type: Event. Renders an event (kind 31923) you organise
or RSVP'd going/interested to (useMyRsvpCoords, kind 31925) as a passport/
wristband: title, date/time, venue, cover, who it admits, going count.
Derived from the event's own signed event — its QR verifies that event;
/verify/:naddr renders kind 31923 too. React + canvas renderers; i18n.
- /review gains a dedicated Claims tab (pending private-spot ownership claims),
replacing the banner that sat above the tabs.
Private spots can't be domain-verified like shops, so ownership is a gated
two-step flow — never an instant takeover:
- A NIP-05-verified user files a claim (kind 1985 label in the separate
org.skatespots.spotclaim namespace) on a spot marked access=private.
- A moderator approves by publishing claim-approved (moderation namespace,
a=coord + p=claimant); surfaced in a pending-claims list atop /review.
- The claimant then finishes by re-publishing the spot with
claim-reason=private-approved (existing claim mechanism).
Adds useSpotClaims/useApprovedSpotClaims/useAllSpotClaims/useSubmitSpotClaim,
useAdminApproveClaim, the private branch in SpotOwnership, i18n, and docs.
Non-private spots keep the abandoned-claim flow. Nothing is auto-transferred.
- The per-route 'slammed' screen only offered 'Go back', so a stale-chunk
failure after a deploy trapped the user. Add a primary 'Reload' that clears
the lazyWithRetry session flag and does a fresh reload.
- Smoke test now boots /shop/:pubkey/:identifier, /spot/…, /event/…, /crew/…
so a broken detail route can't ship again.
- Investigation note: no reproducible slam across 11 live shops; the report
matched a transient chunk miss from two rapid deploys.
- /card gains a third type: Crew. Pick one of your crews and render a printable
crew card (name, logo, home turf, founded, members, spots, vibes, Open call).
- No new event: the card is derived from the crew's own signed kind 35006, so its
QR verifies that event (the founder's key IS the signature).
- Rendered in the React card + canvas export; /verify/:naddr now renders kind
35006 with a link to the crew page.
- i18n cardUi.crew.* / verify.kindCrew; NIP.md + AGENTS.md updated.
- Card shows crew crests (kind 35006 memberships), a local badge (skater
profile location/home_spot), and Lab role chips (kinds 35001-35014) — all
DERIVED LIVE, so a card can't claim a role/crew the skater doesn't have.
- Rendered in the React card + the canvas export (print/PNG); local/crews as
text lines, roles as blue chips.
- Per-facet show toggles in the editor; card schema bumped to v2 so the new
flags default ON for cards saved before them.
- Also fixed: verifyUrl wasn't passed to the canvas renderer (QR fell back to
the profile path).
- i18n cardUi.facets.* / cardUi.roles.*; NIP.md + AGENTS.md updated.
- Publish the Rider Card as an addressable event (kind 32271, d=rider-card) and
hydrate the editor from it, so customization persists across devices.
- New routes: /card/:npub (public read-only card) and /verify/:naddr
(signature check + park venue binding).
- Park Access Cards can bind to the issuer's own spot (a tag 30818:...) and the
verify page confirms the issuer owns the venue.
- QR on the card (+ canvas export) now encodes the verify URL; the real QR
renders in the React card too (was a placeholder / broken profile path).
- Extracted shared card visuals to src/components/RiderCard.tsx; helpers in
src/lib/riderCard.ts; hooks useRiderCard/useRiderBadges.
- Documented in NIP.md; i18n cardUi namespace (EN/ES); smoke test covers the
new routes.
- First-visit language auto-detect (Spanish devices land in es; stored choice wins).
- Sasquatch AI replies in the app language (skate trick names kept English).
- i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t()
key missing from en/es or any en/es key drift.
- ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and
~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace,
trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters.
- Policy: only skate TRICK names stay English; user-generated content untouched.
- CATEGORY_BY_KIND now maps crew invite (36229) / join-request (31969) -> crew and
zap (9735) / reply-mention (1) / comment (1111) -> social; the relay watch
includes those kinds. Reactions/reposts are deliberately not pushed (noisy) and
newsletter has no push channel.
- Master switch off now also disables web push and the native poller.
- Verified: a crew-only token is not woken by a zap but is by a crew invite.
- /subscribe + /native/poll/register carry the enabled categories; the server
stores them per subscription/token and only pushes matching ones (DM wrap ->
dms; offer/counter/accept/attestation/shipment -> trades). Absent = legacy
(all), empty array = none.
- /pending uses a per-category activity breakdown so it only moves for the
token's categories; the smoke test proves a DM does NOT wake a trades-only token.
- usePushNotifications/useBackgroundDelivery send categories (from device prefs)
and expose resubscribe(); NotificationSettingsCard re-sends on toggle change.
packages/skatespots-background: a local Capacitor Android plugin that polls our
own push-service /pending and raises local notifications. Modes: off / standard
(WorkManager ~15 min) / fast (foreground service ~5 min). Kotlin +
AndroidManifest (POST_NOTIFICATIONS, FOREGROUND_SERVICE_DATA_SYNC, WAKE_LOCK,
battery-exemption) + Gradle module. Client: useBackgroundDelivery (registers a
poll token via NIP-98, seeds the plugin) + a Background delivery (Android)
control in NotificationSettingsCard. The app registers by NAME, not by importing
the package, so the web bundle is unaffected. UNVERIFIED - needs cap sync + a
device.
Adds the content-free activity cursor the Android APK can poll from a native
worker: POST /native/poll/register (NIP-98 -> poll token; the pubkey joins the
relay watch), GET /pending?token&since -> { seq, at, changed }, and
/native/poll/unregister. pushTo() bumps recordActivity(). Verified locally:
register -> offer addressed to the pubkey -> seq 0 -> 1, changed=true. No Google,
no decryption; the native plugin is the remaining piece.
Server: /native/subscribe + /native/unsubscribe (NIP-98 proof), FCM device store,
and FCM HTTP v1 sending (service-account OAuth2, cached ~1h); /health reports
native + device count. 503 without PUSH_FIREBASE_SERVICE_ACCOUNT_JSON.
Client: @capacitor/push-notifications (lazily imported, native-only) in
usePushNotifications - request permission, register, store the FCM token, and
subscribe via /native/subscribe; tap deep-links into the app.
Needs a Firebase project: google-services.json + the Gradle plugin (android/),
and the service-account JSON in the host .env.
A pure swap has no invoice, so ListingTradeActions now offers a 'Good-faith
deposit' input for price_type=trade listings. Paying it stores a deposit-slot
receipt (saveReceipt(coord, r, 'deposit')); the buyer's confirm attaches it as
['proof','deposit',preimage,payment_hash] with method=trade, so a barter carries
a verifiable COST without pretending the goods were proven.
- getPaymentHashFromBolt11 (bolt11.ts, dependency-free; matches the BOLT11 spec
test vector) + verifyPreimage (sha256(preimage) === payment_hash).
- usePayPaymentPointer captures the preimage from NWC/WebLN/melt and returns a
PaymentReceipt; PayWithSats.onPaid carries it; stored device-locally so the
later confirm step attaches it; publishTrade writes
['proof','lnpreimage',preimage,hash] (and ['proof','deposit',...] for barter).
- useTradeReputation verifies and counts it in paymentVerified.
- Honest scope: proves PAYMENT, not delivery; money legs only - a pure hardware
swap has no invoice, stays attested; a deposit receipt gives barter real cost.
- HandoffHelper suggests the 3 nearest public skate spots to the listing as
one-tap DM meetup templates (public data only; never the viewer's location).
- New addressable kind 34967 + useShipmentStatus + ShippingStatusCard: seller
marks shipped, buyer received, either issue. p-tagged so the push service
notifies (added to MARKET_KINDS); d=ship:<listing-d>:<author> so a status
replaces only its author's.
- Honest framing: a self-reported claim, NOT proof - 'received' does not release
escrow; no address stored; parcel photos with an address discouraged.
- ['shipping','buyer'|'seller'|'split'] tag + create-page selector.
- Distance from the viewer's last known location (cache only, no GPS prompt) on
both gear cards, and a 'Nearest (local)' sort on GearSwapPage.
- HandoffHelper (buyer-only, in ListingTradeActions) opens the trade DM
pre-filled: suggest a time & place / share delivery details / use a locker,
with anti-scam copy. Details stay in the NIP-17 DM; the app never sees them.
- ships deals now nudge safety on all platforms, not just when escrow is ready.
pow.ts: minePow/verifyPow/countLeadingZeroBits. PoW pays NO ONE - it is a free
CPU tax, not a fee. useNostrPublish accepts pow and mines the nonce on the
UNSIGNED event before signing (id is signature-independent -> one signer prompt).
CreateListingPage sends 16 bits per listing (~80ms) and 20 bits when a newcomer
chooses work instead of the 1,000-sat fee (SpamFeePayment.onPowInstead = the free
alternative). parseMarketplaceItem exposes powDifficulty; the card shows a PoW
badge at >=16.
useTradeIntegrity.ts adds two anti-sybil levers to TradeReputationCard:
- useBatchActivity counts each counterparty's published spots+listings and weights
their contribution by min(1, activity/3), so a ring of empty accounts
contributes ~0 even when cross-confirmed.
- useTradeRings does a 2-hop query of the partners' own attestations and measures
closedness (share of partner-partner trades); >=3 partners with >=0.5 closedness
flags a closed cluster. PoW (NIP-13) remains, but needs mining at publish.
- Listings carry 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 NIP-17 trade chat, and ListingTradeActions nudges escrow for
shipped deals. No KYC rail, no labels, no carrier API.
- TradeReputationCard adds a low-diversity ring warning (verified >= 5 across <= 2
people) beyond the single-partner one.
A trade proof of ['proof','zap','<receiptId>'] is now checked by verifyZapReceipt
(kind-9735 signature + p = seller + decodable bolt11 amount + embedded 9734
request) and counted as paymentVerified, shown on TradeReputationCard as
'payment-verified (zap receipt)' - a real tier above the self-reported claim.
Honest ceiling: proves the seller's LNURL provider acknowledged a payment, not
that goods changed hands. NIP.md documents the zap proof variant.
A price_type=fixed listing now shows a direct 'Buy with sats' button in
ListingTradeActions (card + detail) - no negotiation needed. The price resolves
to sats (SATS directly, USD via useUsdToSats) and pays through PayWithSats (NWC ->
WebLN -> Cashu melt, falling back to the seller's lud16). The sheet mounts only
while open so the wallet hooks stay off every grid card. Reuses the accepted-offer
Pay rail.
The service now also subscribes to marketplace events addressed to subscribers
(kinds 30403/31872/31871) via #p filters built from the live subscription set,
re-subscribed on every subscribe/unsubscribe. Payloads stay generic ('New offer
on your listing', 'Your offer was accepted') - never the amount or item. Verified
locally: subscribe -> offer to #p -> push attempt.
The proof tag is self-reported and unverifiable client-side, so it must not read
as proof of payment. useTradeReputation now exposes withPaymentProof (mutual
trades carrying a proof), shown on TradeReputationCard as 'with payment proof
(self-reported - not verified)' distinct from the verified count.
The marketplace 'Sats Traded' was a naive sum of the self-reported amount tag on
31871, which two colluding accounts could inflate for free. tradedSats is now the
robust figure - mutual trades only (a listing attested by >=2 distinct pubkeys),
per-pair cap 3, amounts >1 BTC dropped - with tradedCount + tradedTraders, and
tradedSatsClaimed keeps the raw claim so the card shows both. Added query fields
=> PERSIST_BUSTER v5. No trade attestations exist on the relay yet (honest 0).
- public/push-sw.js: shows the content-free push; tap focuses/opens the app.
- src/lib/pushService.ts + usePushNotifications: register the SW, subscribe with
the service's VAPID key, sign a NIP-98 challenge proof, POST /subscribe.
- NotificationSettingsCard: a 'Background notifications' toggle (opt-in,
per-device) + honest metadata copy; corrected the now-stale 'we run no push
server' note.
- useSendDirectMessage: marks the RECIPIENT wrap ["skatespots","push"] (never
the self copy) and adds the community relay as the notify relay so the push
service sees it.
- /privacy: discloses the push server sees metadata only.
Our own keyless Bun service (not Linky's): watches relays for kind-1059 wraps
carrying the plaintext marker ["skatespots","push"] on the recipient copy,
extracts only {recipient, wrapId}, and sends a Web Push. NEVER decrypts - the
client fetches + decrypts the named wrap itself.
Endpoints: GET /health, GET /vapid-public-key, POST /auth/challenge ->
/subscribe|/unsubscribe with a one-time NIP-98 (kind 27235) challenge proof.
State is a JSON file; VAPID keys from env. Wired like the other standalone
servers (typecheck:push + check:push in npm test, deploy:push, Caddyfile +
systemd unit). Verified locally end-to-end: auth -> subscribe -> marked wrap ->
watcher -> push attempt, and proof replay rejected.
Client half (service worker, subscribe hook + Settings toggle, marker on DM
sends) is the next step.
Phases 1-2. The seller may attach a ["payment", value, kind] pointer to the
accepted 30403 message (lninvoice|lnaddress|creq|clink, optional payment_hash);
ListingOffersPanel attaches the seller's own lud16 on accept, falling back to
their profile lud16 on the buyer's side. The pointer rides the accepted event so
it flows through the existing decrypt-free trade notification path (a NIP-17 DM
can't reach the Trades tab without decrypting).
Buyer gets a 'Pay with sats' button -> PayWithSats dialog -> usePayPaymentPointer
pays via NWC -> WebLN -> Cashu melt (mirrors useZaps). Nothing is held or signed
by us; recording the trade stays the existing 31871 flow. creq/clink are
recognized but not payable yet. No new event kind.
Numo (and other POS) emit a BIP21 'bitcoin:?creq=...&lightning=...' QR. The
scanner treated it as 'unknown', so a Numo terminal couldn't be paid at all.
classifyQRCode now parses BIP21, extracting lightning= (paid via the existing
NWC/Cashu-melt path) and creq=, and recognizes standalone NUT-18 creq1.../creqA...
as a new 'cashu_request' type. cashu_request is recognized but not payable yet -
CashuWalletCard shows an honest message. BIP21 parsing avoids URLSearchParams so
a raw creqA... base64 '+' isn't coerced to a space.
Create/edit geocoding used Nominatim alone, so when it failed (e.g. the 429) a
new spot published with no city/country and landed in 'Uncategorised'. Now
reverseGeocodeRegions() tries Nominatim, then falls back to BigDataCloud's
client-side reverse-geocode-client, so a spot is region-tagged by default. Still
never throws (an outage can't block a publish); the spot keeps its geohash and
the stats' read-time derivation can still place it. derivedRegions reuses the
same geocodeBigDataCloud helper (one implementation).
Nominatim returned 429 for our IP because the read-time derivation runs from
every viewer on every load — its usage policy forbids that, and a 429 makes
reverseGeocodeRegions() (which returns empty on !res.ok) fail silently, freezing
the resolve counter and stalling the region card.
The stats fallback now uses BigDataCloud's reverse-geocode-client (no key, CORS
on both hops, English countryName/countryCode/city), spaced ~300ms. Nominatim
stays for create/edit geocoding only (author-initiated, one call per spot).
/privacy now names both providers.
Two issues from the first derived-regions pass:
- The 'Resolving N...' counter only updated when the whole pass finished, so it
looked frozen. fillDerivedRegions now reports progress per pin, the hook ticks
the counter down, and the card refreshes every 5 resolved pins so the numbers
climb while you watch.
- A country could appear twice ('United Kingdom' 1 + 1) when one pin carried a
country_code and another only a name. Learn name->code from pins that have
both and fold name-only pins into the same bucket.
The region card only placed pins carrying a city/country tag, so legacy pins
(coords but no tag) drew on the map yet landed in 'Uncategorised' — the map and
the stats disagreed. useGrowthStats now falls back to a cached reverse geocode
of the pin's own coordinates (lib/derivedRegions, localStorage keyed by ~110m
coords). useDeriveRegions (mounted on /stats) fills cache misses at 1.1s spacing
for Nominatim's 1 req/s policy and invalidates growth-stats; the card shows a
'Resolving N...' note meanwhile. Display-only fallback; the admin backfill still
writes real tags. Read-time geocoding disclosed on /privacy.
The admin backfill lived only in Settings -> Advanced, so an admin staring at
the 'Uncategorised' row had no obvious way to fix it. Render the admin-only
RegionBackfillCard inline in the region section when uncategorised > 0, and
clarify the zero-state (remaining untagged pins belong to other mappers).