diff --git a/AGENTS.md b/AGENTS.md index 5df9ed2..ab75901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,13 @@ session with **no other memory of this project**. So: (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`. - **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 diff --git a/NIP.md b/NIP.md index 3733c7d..2eae416 100644 --- a/NIP.md +++ b/NIP.md @@ -2387,7 +2387,7 @@ bare `p` tag on the crew is only a claim until the member self-labels. | `t: crew-agent` | Optional marker that the crew has enabled its **AI Crew Agent** ("🐝 Sasquatch"). When present, clients may derive the crew's agent identity (see below) and expose an "Ask Sasquatch" experience for members. Absent = no agent. | | `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). | | `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. | -| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. | +| `nip05` | Optional crew NIP-05 identifier (`local@domain`). **Repeatable** (up to 5): a crew may advertise several handles, each verified independently against the **founder** pubkey; the first is canonical. (Unlike a kind-0 profile, whose `nip05` is a single string — a crew is our own tag convention, so it isn't limited to one.) See NIP-05 Verification. | | `website`, `instagram`, `social` | Links | | `band` | Optional name of the crew's **band / music project**. Skate scenes and local music are tightly linked, so a crew may double as a band. Freeform text (≤100 chars). For a full band profile (genres, instruments, gigs) a crew should instead create a **Musician** Lab profile (kind `35011`), set its focus to `crew-focus-music`, and cross-link the profile via an `a` tag. | | `band-url` | Optional link to the crew band's music (Spotify, Bandcamp, YouTube, a Nostr music profile, etc.). Must be an `http(s)` URL. Only meaningful alongside `band`. | @@ -2838,8 +2838,10 @@ anonymous**, and a proof can't be replayed into a different crew. ### NIP-05 Verification The optional `nip05` tag is a crew handle on **any domain the crew controls** -(fully decentralized — SkateSpots does not gatekeep the handle). Because the -crew event is authored by the founder, a client verifies the handle by fetching +(fully decentralized — SkateSpots does not gatekeep the handle). The tag is +**repeatable (up to 5)**: a crew may advertise several handles and clients verify +each one independently (the first is canonical / used for back-compat). Because +the crew event is authored by the founder, a client verifies a handle by fetching `https:///.well-known/nostr.json?name=` and checking that the returned pubkey equals the **founder** pubkey: diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 2a2fb65..b0b88aa 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -25,10 +25,11 @@ import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_ import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys'; import { useCrewGoal } from '@/hooks/useCrewGoal'; import { useCoordinator } from '@/hooks/useCoordinator'; +import { useCrewNip05 } from '@/hooks/useCrewNip05'; import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator'; import { useRelays } from '@/lib/relays'; import { cn } from '@/lib/utils'; -import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server } from 'lucide-react'; +import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, BadgeCheck, AlertTriangle } from 'lucide-react'; const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness']; const FOCUS_ICONS: Record = { @@ -236,7 +237,8 @@ export function CrewForm({ existing }: CrewFormProps) { const [bandUrl, setBandUrl] = useState(existing?.bandUrl ?? ''); const [socials, setSocials] = useState(existing?.socials ?? []); const [socialInput, setSocialInput] = useState(''); - const [nip05, setNip05] = useState(existing?.nip05 ?? ''); + const [nip05s, setNip05s] = useState(existing?.nip05s ?? (existing?.nip05 ? [existing.nip05] : [])); + const [nip05Input, setNip05Input] = useState(''); const [noffer, setNoffer] = useState(existing?.noffer ?? ''); const [members, setMembers] = useState(existing?.members ?? []); const [admins, setAdmins] = useState(existing?.admins ?? []); @@ -346,6 +348,23 @@ export function CrewForm({ existing }: CrewFormProps) { setSocials((prev) => [...prev, val]); }; + // Crew NIP-05 handles — up to 5, each verified against the founder's key. + const NIP05_RE = /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/; + const MAX_NIP05 = 5; + const founderPubkey = existing?.pubkey ?? user?.pubkey; + const [nip05Error, setNip05Error] = useState(null); + const addNip05 = () => { + const val = nip05Input.trim().toLowerCase(); + setNip05Input(''); + if (!val) return; + if (!NIP05_RE.test(val)) return setNip05Error(t('socialUi.form.nip05Invalid')); + if (nip05s.includes(val)) return setNip05Error(null); + if (nip05s.length >= MAX_NIP05) return setNip05Error(t('socialUi.form.nip05TooMany', { count: MAX_NIP05 })); + setNip05Error(null); + setNip05s((prev) => [...prev, val]); + }; + const removeNip05 = (handle: string) => setNip05s((prev) => prev.filter((h) => h !== handle)); + // Live-decode whatever's typed in the member field for the preview chip. const memberPreviewPk = decodePubkeyInput(memberInput); const memberInputDirty = memberInput.trim().length > 0; @@ -490,7 +509,7 @@ export function CrewForm({ existing }: CrewFormProps) { for (const s of socials) { if (s.trim()) tags.push(['social', s.trim()]); } - if (nip05.trim()) tags.push(['nip05', nip05.trim().toLowerCase()]); + for (const handle of nip05s) tags.push(['nip05', handle]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); // Members: admins get an `admin` role marker on their `p` tag. @@ -1134,22 +1153,36 @@ export function CrewForm({ existing }: CrewFormProps) { ); const nip05Block = ( -
-
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ac105eb..6a4b4d4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -3843,8 +3843,14 @@ socialUi: { nameRequired: 'Name required', nameRequiredDesc: 'Give your crew a name.', nip05DescA: 'A verifiable handle you control on any domain, e.g.', - nip05DescB: ". It's verified against this crew's founder key — a green check shows when the domain confirms it.", + nip05DescB: ". It's verified against this crew's founder key, and you can add a few — each shows a green check when its domain confirms it.", nip05Label: 'Crew NIP-05', + nip05Placeholder: 'crew@yourdomain.com', + nip05Add: 'Add', + nip05Invalid: 'Enter a handle like crew@yourdomain.com', + nip05TooMany: 'You can add up to {{count}} handles.', + nip05Unverified: 'Unchecked', + nip05Remove: 'Remove {{handle}}', notAKeyA: "That doesn't look like a Nostr key yet — paste an", notAKeyB: ',', notAKeyC: ', or hex pubkey.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 7e48c3b..b2748f3 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -3843,8 +3843,14 @@ socialUi: { nameRequired: 'Nombre obligatorio', nameRequiredDesc: 'Ponle un nombre a tu cuadrilla.', nip05DescA: 'Un identificador verificable que controlas en cualquier dominio, p. ej.', - nip05DescB: '. Se verifica contra la clave del fundador de esta cuadrilla — aparece un check verde cuando el dominio lo confirma.', + nip05DescB: '. Se verifica contra la clave del fundador de la cuadrilla y puedes añadir varios: cada uno muestra un check verde cuando su dominio lo confirma.', nip05Label: 'NIP-05 de la cuadrilla', + nip05Placeholder: 'cuadrilla@tudominio.com', + nip05Add: 'Añadir', + nip05Invalid: 'Introduce un identificador como cuadrilla@tudominio.com', + nip05TooMany: 'Puedes añadir hasta {{count}} identificadores.', + nip05Unverified: 'Sin verificar', + nip05Remove: 'Quitar {{handle}}', notAKeyA: 'Eso todavía no parece una clave de Nostr — pega un', notAKeyB: ',', notAKeyC: ', o una pubkey hex.', diff --git a/src/lib/types.ts b/src/lib/types.ts index 02f80a8..40b3046 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2156,7 +2156,8 @@ export interface SkateCrew { bandUrl?: string; // Link to the band's music (Spotify/Bandcamp/YT/Nostr) (`band-url` tag) social?: string; // First social link (backward compat) socials: string[]; // All additional social links (`social` tags) - nip05?: string; // Crew's NIP-05 identifier (verified against founder pubkey) + nip05?: string; // Crew's NIP-05 identifier (first handle; verified against founder pubkey) + nip05s: string[]; // ALL crew NIP-05 handles (each verified against the founder pubkey) noffer?: string; // Optional shared tip jar (CLINK) members: string[]; // Member pubkeys (hex) via `p` tags admins: string[]; // Delegated admin pubkeys (subset of members, `admin` marker) @@ -3051,7 +3052,16 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null { const social = socials[0]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; - const nip05 = clampStr(event.tags.find(([name]) => name === 'nip05')?.[1], 100); + // Crew NIP-05 handles. Unlike a kind-0 profile (one `nip05` string), a crew is + // our own tag convention, so it can carry several — each verified against the + // founder's pubkey. Normalise, dedupe, drop malformed, cap at 5. + const nip05s = [...new Set( + event.tags + .filter(([name, value]) => name === 'nip05' && !!value?.trim()) + .map(([, value]) => value.trim().toLowerCase()) + .filter((v) => /^[a-z0-9\-_.]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(v)), + )].slice(0, 5); + const nip05 = clampStr(nip05s[0], 100); const members = parseMemberPubkeys(event); // Admins must be a subset of claimed members. const admins = parseAdminPubkeys(event).filter((pk) => members.includes(pk)); @@ -3106,6 +3116,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null { social, socials, nip05, + nip05s, noffer, members, admins,