A crew is our own tag convention (unlike a kind-0 profile, whose nip05 is a
single string), so it can now advertise up to 5 handles, each verified
independently against the founder key.
- parseSkateCrew: collect all nip05 tags -> SkateCrew.nip05s (normalised,
deduped, regex-validated, cap 5); nip05 stays = first (back-compat).
- CrewForm: add/remove handle rows with a live verified/mismatch/loading status
(useCrewNip05 per row); format validation + max-5 hint; Enter adds.
- CrewNip05Badge renders every handle; CrewHero passes nip05s.
- NIP.md: nip05 documented as repeatable on kind 35006. i18n EN/ES.
No decision, no code — parked with the protocol facts, recommended default
(Concord, zero-infra) vs optional Cordn/MLS upgrade, and the open questions.
The stash export was plaintext JSON, so a leaked/shared file exposed the whole
gear list. Export now wraps the payload in a version-2 JSON envelope:
- password mode: scrypt (NIP-49 params, 2^16/r8/p1) -> AES-256-GCM, with the
header (mode+cipher+KDF) bound as AAD so it can't be downgraded.
- nip44 mode: NIP-44-encrypted to your own key (no password to remember; needs
the matching key to open).
- plaintext v1 exports still import unchanged.
New src/lib/stashBackup.ts (verified round-trip, wrong-password, tamper and
KDF-downgrade rejection) + StashBackupExportDialog/StashBackupUnlockDialog.
The import picker only prompts for a password on password-encrypted files.
Password mode needs no signer (Amber-friendly); 'My key' is hidden unless the
signer supports NIP-44.
The APK build failed compiling the plugin: Capacitor's `activity` is an
AppCompatActivity, so the module needs appcompat. Now `npm run android:build`
succeeds, wires skatespots-nfc in, and merges the NFC permission. Still needs a
device test for the actual write.
- 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.
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.
- 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.
- /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.
- /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.
- 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.
- ['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.