diff --git a/NIP.md b/NIP.md index cfb8bf6..6c97620 100644 --- a/NIP.md +++ b/NIP.md @@ -1479,11 +1479,16 @@ When a match is found, the app can facilitate P2P trading via encrypted DMs (NIP SkateSpots supports physical-print cards that are cryptographically signed on Nostr and verifiable by anyone. -### Park Access Card (kind 35679) +### Access Pass (kind 35679) -Issued by a private skatepark owner to a specific rider, granting access rights. The park's Nostr key signs the event — anyone can verify the card is genuine by scanning the QR code. +A signed credential issued by an owner to a specific holder. The owner's Nostr +key signs the event and it is **bound to what it grants access to** via an `a` +tag — a **venue spot** (`30818`), a **shop** (`30017`), or an **event** (`31923`, +an organiser's check-in pass). Anyone can verify it by scanning the QR. -**Constraint:** Park Access Cards for private parks (`access=private` in kind 30818) should only be issued by the park's owner pubkey. Clients verify the issuer's pubkey matches the park event's `pubkey` field. +**Constraint:** an Access Pass may only be issued by the key that **owns** the +target (`a`). Clients verify the issuer's pubkey matches the target event's +`pubkey` — a venue/shop owner, or the event organiser. ```json { @@ -1503,12 +1508,13 @@ Issued by a private skatepark owner to a specific rider, granting access rights. } ``` -**Venue binding (`a`) — REQUIRED for new passes:** it points at the park's own -spot (kind 30818). A pass is only issuable by the **key that owns that venue**, -and the `/verify` page fetches the spot and confirms its **author pubkey equals -the pass's signer**. The `/card` studio only offers the Park type when you own a -venue, and won't publish without one. (Legacy passes with no `a` still verify as -a signature but are shown as **not venue-bound**.) +**Target binding (`a`) — REQUIRED for new passes:** it points at what the pass +grants access to — a **venue spot** (`30818`), a **shop** (`30017`), or an +**event** (`31923`). A pass is only issuable by the **key that owns the target**, +and the `/verify` page fetches it and confirms its **author pubkey equals the +pass's signer**. The `/card` studio only offers the Park/Event types when you +own a venue/event, and won't publish without one. (Legacy passes with no `a` +still verify as a signature but are shown as **not bound**.) ### Card Themes diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 9684728..0bcc54d 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -39,6 +39,7 @@ import { useRiderCard, useRiderRoles, useCardProfile, useIssuedPasses, useRevoke import { useUserCrews } from '@/hooks/useCrews'; import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useSkateSpots } from '@/hooks/useSkateSpots'; +import { useSkateShops } from '@/hooks/useSkateShops'; import { useSkateEvents } from '@/hooks/useSkateEvents'; import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { useNostrPublish } from '@/hooks/useNostrPublish'; @@ -50,7 +51,7 @@ import { genUserName } from '@/lib/genUserName'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; import { RIDER_CARD_KIND, RIDER_CARD_D, PARK_ACCESS_CARD_KIND, SKILL_BADGE_KIND, cardVerifyUrl, riderCardTags, type RiderCardConfig } from '@/lib/riderCard'; -import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types'; +import { SKATE_SPOT_KIND, SKATE_SHOP_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; import { Link } from 'react-router-dom'; import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket, MoreHorizontal, Pencil, CheckCircle2, RefreshCw, Upload } from 'lucide-react'; @@ -168,6 +169,7 @@ export default function RiderCardPage() { const metadata = cardProfile.data; const { data: savedCard } = useRiderCard(user?.pubkey); const { data: allSpots } = useSkateSpots(200); + const { data: allShops } = useSkateShops(200); const { memberships } = useUserCrews(user?.pubkey); const { data: skaterProfile } = useUserSkaterProfile(user?.pubkey); const { data: roles = [] } = useRiderRoles(user?.pubkey); @@ -276,10 +278,14 @@ export default function RiderCardPage() { const eventPasses = activeEvent ? issuedPasses.filter((p) => p.coord === `${CALENDAR_EVENT_KIND}:${activeEvent.pubkey}:${activeEvent.identifier}`) : []; - const myVenues = useMemo( - () => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey), - [allSpots, user?.pubkey], - ); + // A "venue" you can issue a pass for is any spot OR shop you own. + const myVenues = useMemo(() => { + const spots = (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey) + .map((s) => ({ id: `s:${s.id}`, kind: SKATE_SPOT_KIND, pubkey: s.pubkey, identifier: s.identifier, title: s.title })); + const shops = (allShops ?? []).filter((s) => s.pubkey === user?.pubkey) + .map((s) => ({ id: `h:${s.id}`, kind: SKATE_SHOP_KIND, pubkey: s.pubkey, identifier: s.identifier, title: s.name })); + return [...spots, ...shops]; + }, [allSpots, allShops, user?.pubkey]); const venueSpot = myVenues.find((s) => s.id === venueId); // Hydrate the editor from the saved Rider Card the first time it loads. @@ -566,7 +572,7 @@ export default function RiderCardPage() { ['d', d], ['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'], ['issuer', issuerName.trim() || venueSpot.title], ['access-level', accessLevel], ['theme', theme], - ['a', `${SKATE_SPOT_KIND}:${venueSpot.pubkey}:${venueSpot.identifier}`], + ['a', `${venueSpot.kind}:${venueSpot.pubkey}:${venueSpot.identifier}`], ]; if (validThru) tags.push(['valid-thru', validThru]); publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, { diff --git a/src/pages/VerifyCardPage.tsx b/src/pages/VerifyCardPage.tsx index a37476a..2a4ab11 100644 --- a/src/pages/VerifyCardPage.tsx +++ b/src/pages/VerifyCardPage.tsx @@ -18,7 +18,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useTranslation } from '@/contexts/language'; import { genUserName } from '@/lib/genUserName'; import { RIDER_CARD_KIND, PARK_ACCESS_CARD_KIND, passExpiry } from '@/lib/riderCard'; -import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, CALENDAR_RSVP_KIND } from '@/lib/types'; +import { SKATE_SPOT_KIND, SKATE_SHOP_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, CALENDAR_RSVP_KIND } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; interface ParsedNaddr { @@ -85,7 +85,7 @@ export default function VerifyCardPage() { venue = { status: 'mismatch', coord: aTag }; const [k, pk, dTag] = aTag.split(':'); const kindNum = Number(k); - if ((kindNum === SKATE_SPOT_KIND || kindNum === CALENDAR_EVENT_KIND) && pk && dTag) { + if ((kindNum === SKATE_SPOT_KIND || kindNum === SKATE_SHOP_KIND || kindNum === CALENDAR_EVENT_KIND) && pk && dTag) { try { const targets = await nostr.query( [{ kinds: [kindNum], authors: [pk], '#d': [dTag], limit: 1 }],