fix(card): venues include shops; NIP.md: 35679 is an Access Pass (L1/L2)

- L2: a 'venue' can now be any spot (30818) OR shop (30017) you own — myVenues
  is normalised; the pass 'a' tag uses the venue's real kind; /verify confirms
  issuer ownership for spots, shops and events.
- L1: NIP.md renames kind 35679 from 'Park Access Card' to 'Access Pass' and
  documents the target binding (spot / shop / event).
This commit is contained in:
SkateSpots 2026-10-02 14:48:20 +07:00
parent 04a43a8afa
commit 40e62231b3
3 changed files with 29 additions and 17 deletions

24
NIP.md
View file

@ -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. 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 ```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 **Target binding (`a`) — REQUIRED for new passes:** it points at what the pass
spot (kind 30818). A pass is only issuable by the **key that owns that venue**, grants access to — a **venue spot** (`30818`), a **shop** (`30017`), or an
and the `/verify` page fetches the spot and confirms its **author pubkey equals **event** (`31923`). A pass is only issuable by the **key that owns the target**,
the pass's signer**. The `/card` studio only offers the Park type when you own a and the `/verify` page fetches it and confirms its **author pubkey equals the
venue, and won't publish without one. (Legacy passes with no `a` still verify as pass's signer**. The `/card` studio only offers the Park/Event types when you
a signature but are shown as **not venue-bound**.) 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 ### Card Themes

View file

@ -39,6 +39,7 @@ import { useRiderCard, useRiderRoles, useCardProfile, useIssuedPasses, useRevoke
import { useUserCrews } from '@/hooks/useCrews'; import { useUserCrews } from '@/hooks/useCrews';
import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useSkateShops } from '@/hooks/useSkateShops';
import { useSkateEvents } from '@/hooks/useSkateEvents'; import { useSkateEvents } from '@/hooks/useSkateEvents';
import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
@ -50,7 +51,7 @@ import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; 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 { 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 type { NostrEvent } from '@nostrify/nostrify';
import { Link } from 'react-router-dom'; 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'; 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 metadata = cardProfile.data;
const { data: savedCard } = useRiderCard(user?.pubkey); const { data: savedCard } = useRiderCard(user?.pubkey);
const { data: allSpots } = useSkateSpots(200); const { data: allSpots } = useSkateSpots(200);
const { data: allShops } = useSkateShops(200);
const { memberships } = useUserCrews(user?.pubkey); const { memberships } = useUserCrews(user?.pubkey);
const { data: skaterProfile } = useUserSkaterProfile(user?.pubkey); const { data: skaterProfile } = useUserSkaterProfile(user?.pubkey);
const { data: roles = [] } = useRiderRoles(user?.pubkey); const { data: roles = [] } = useRiderRoles(user?.pubkey);
@ -276,10 +278,14 @@ export default function RiderCardPage() {
const eventPasses = activeEvent const eventPasses = activeEvent
? issuedPasses.filter((p) => p.coord === `${CALENDAR_EVENT_KIND}:${activeEvent.pubkey}:${activeEvent.identifier}`) ? issuedPasses.filter((p) => p.coord === `${CALENDAR_EVENT_KIND}:${activeEvent.pubkey}:${activeEvent.identifier}`)
: []; : [];
const myVenues = useMemo( // A "venue" you can issue a pass for is any spot OR shop you own.
() => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey), const myVenues = useMemo(() => {
[allSpots, user?.pubkey], 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); const venueSpot = myVenues.find((s) => s.id === venueId);
// Hydrate the editor from the saved Rider Card the first time it loads. // Hydrate the editor from the saved Rider Card the first time it loads.
@ -566,7 +572,7 @@ export default function RiderCardPage() {
['d', d], ['d', d],
['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'], ['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
['issuer', issuerName.trim() || venueSpot.title], ['access-level', accessLevel], ['theme', theme], ['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]); if (validThru) tags.push(['valid-thru', validThru]);
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, { publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {

View file

@ -18,7 +18,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { RIDER_CARD_KIND, PARK_ACCESS_CARD_KIND, passExpiry } from '@/lib/riderCard'; 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'; import type { NostrEvent } from '@nostrify/nostrify';
interface ParsedNaddr { interface ParsedNaddr {
@ -85,7 +85,7 @@ export default function VerifyCardPage() {
venue = { status: 'mismatch', coord: aTag }; venue = { status: 'mismatch', coord: aTag };
const [k, pk, dTag] = aTag.split(':'); const [k, pk, dTag] = aTag.split(':');
const kindNum = Number(k); 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 { try {
const targets = await nostr.query( const targets = await nostr.query(
[{ kinds: [kindNum], authors: [pk], '#d': [dTag], limit: 1 }], [{ kinds: [kindNum], authors: [pk], '#d': [dTag], limit: 1 }],