feat(crews): support multiple NIP-05 handles per crew

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.
This commit is contained in:
SkateSpots 2026-10-03 17:13:26 +07:00
parent 45d021fedb
commit 2daa0a9832
8 changed files with 174 additions and 58 deletions

View file

@ -63,6 +63,13 @@ session with **no other memory of this project**. So:
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and (which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
`AddPhotoToSpot`, replacing the old one-file-at-a-time inputs. `AddPhotoToSpot`, replacing the old one-file-at-a-time inputs.
- `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates - `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** - **Rider Cards (2026-10-02) — now REAL + verifiable.** `32271` **Rider Card**
(addressable, `d=rider-card`) — the skater signs their own card customization (addressable, `d=rider-card`) — the skater signs their own card customization
(theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags (theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags

8
NIP.md
View file

@ -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. | | `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). | | `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. | | `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 | | `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` | 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`. | | `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 ### NIP-05 Verification
The optional `nip05` tag is a crew handle on **any domain the crew controls** The optional `nip05` tag is a crew handle on **any domain the crew controls**
(fully decentralized — SkateSpots does not gatekeep the handle). Because the (fully decentralized — SkateSpots does not gatekeep the handle). The tag is
crew event is authored by the founder, a client verifies the handle by fetching **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://<domain>/.well-known/nostr.json?name=<local>` and checking that the `https://<domain>/.well-known/nostr.json?name=<local>` and checking that the
returned pubkey equals the **founder** pubkey: returned pubkey equals the **founder** pubkey:

View file

@ -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 { 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 { useCrewGoal } from '@/hooks/useCrewGoal';
import { useCoordinator } from '@/hooks/useCoordinator'; import { useCoordinator } from '@/hooks/useCoordinator';
import { useCrewNip05 } from '@/hooks/useCrewNip05';
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator'; import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator';
import { useRelays } from '@/lib/relays'; import { useRelays } from '@/lib/relays';
import { cn } from '@/lib/utils'; 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_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = { const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
@ -236,7 +237,8 @@ export function CrewForm({ existing }: CrewFormProps) {
const [bandUrl, setBandUrl] = useState(existing?.bandUrl ?? ''); const [bandUrl, setBandUrl] = useState(existing?.bandUrl ?? '');
const [socials, setSocials] = useState<string[]>(existing?.socials ?? []); const [socials, setSocials] = useState<string[]>(existing?.socials ?? []);
const [socialInput, setSocialInput] = useState(''); const [socialInput, setSocialInput] = useState('');
const [nip05, setNip05] = useState(existing?.nip05 ?? ''); const [nip05s, setNip05s] = useState<string[]>(existing?.nip05s ?? (existing?.nip05 ? [existing.nip05] : []));
const [nip05Input, setNip05Input] = useState('');
const [noffer, setNoffer] = useState(existing?.noffer ?? ''); const [noffer, setNoffer] = useState(existing?.noffer ?? '');
const [members, setMembers] = useState<string[]>(existing?.members ?? []); const [members, setMembers] = useState<string[]>(existing?.members ?? []);
const [admins, setAdmins] = useState<string[]>(existing?.admins ?? []); const [admins, setAdmins] = useState<string[]>(existing?.admins ?? []);
@ -346,6 +348,23 @@ export function CrewForm({ existing }: CrewFormProps) {
setSocials((prev) => [...prev, val]); 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<string | null>(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. // Live-decode whatever's typed in the member field for the preview chip.
const memberPreviewPk = decodePubkeyInput(memberInput); const memberPreviewPk = decodePubkeyInput(memberInput);
const memberInputDirty = memberInput.trim().length > 0; const memberInputDirty = memberInput.trim().length > 0;
@ -490,7 +509,7 @@ export function CrewForm({ existing }: CrewFormProps) {
for (const s of socials) { for (const s of socials) {
if (s.trim()) tags.push(['social', s.trim()]); 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()]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
// Members: admins get an `admin` role marker on their `p` tag. // Members: admins get an `admin` role marker on their `p` tag.
@ -1134,22 +1153,36 @@ export function CrewForm({ existing }: CrewFormProps) {
); );
const nip05Block = ( const nip05Block = (
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4"> <div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<Label htmlFor="crew-nip05" className="flex items-center gap-2 text-teal-400"> <Label className="flex items-center gap-2 text-teal-400">
<AtSign className="h-4 w-4" /> {t('socialUi.form.nip05Label')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span> <AtSign className="h-4 w-4" /> {t('socialUi.form.nip05Label')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
</Label> </Label>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{t('socialUi.form.nip05DescA')} <code className="text-teal-300">crew@yourdomain.com</code>{t('socialUi.form.nip05DescB')} {t('socialUi.form.nip05DescA')} <code className="text-teal-300">crew@yourdomain.com</code>{t('socialUi.form.nip05DescB')}
</p> </p>
<Input <div className="flex gap-2">
id="crew-nip05" <Input
placeholder="crew@yourdomain.com" id="crew-nip05"
value={nip05} placeholder={t('socialUi.form.nip05Placeholder')}
onChange={(e) => setNip05(e.target.value)} value={nip05Input}
spellCheck={false} onChange={(e) => { setNip05Input(e.target.value); if (nip05Error) setNip05Error(null); }}
autoCapitalize="none" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNip05(); } }}
className="bg-gray-800 border-gray-700 font-mono text-sm" spellCheck={false}
/> autoCapitalize="none"
className="bg-gray-800 border-gray-700 font-mono text-sm"
/>
<Button type="button" onClick={addNip05} className={cn('shrink-0 gap-1', TEAL)} disabled={nip05s.length >= MAX_NIP05}>
<Plus className="h-4 w-4" /> {t('socialUi.form.nip05Add')}
</Button>
</div>
{nip05Error && <p className="text-[11px] text-amber-400">{nip05Error}</p>}
{nip05s.length > 0 && (
<div className="space-y-1.5 pt-1">
{nip05s.map((handle) => (
<CrewNip05Row key={handle} handle={handle} founderPubkey={founderPubkey} onRemove={() => removeNip05(handle)} />
))}
</div>
)}
</div> </div>
); );
@ -1315,7 +1348,7 @@ export function CrewForm({ existing }: CrewFormProps) {
const hasExtras = const hasExtras =
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 || !!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
socials.length > 0 || !!website.trim() || !!instagram.trim() || socials.length > 0 || !!website.trim() || !!instagram.trim() ||
!!bandName.trim() || !!nip05.trim() || agentEnabled || !!coordinatorInput.trim() || !!bandName.trim() || nip05s.length > 0 || agentEnabled || !!coordinatorInput.trim() ||
(goalEnabled && !!goalTitle.trim()); (goalEnabled && !!goalTitle.trim());
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [ const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
@ -1550,3 +1583,58 @@ export function CrewForm({ existing }: CrewFormProps) {
</form> </form>
); );
} }
/** One crew NIP-05 handle row with a live verification status (verified / mismatch / unchecked). */
function CrewNip05Row({
handle,
founderPubkey,
onRemove,
}: {
handle: string;
founderPubkey?: string;
onRemove: () => void;
}) {
const { t } = useTranslation();
const { data, isLoading } = useCrewNip05(handle, founderPubkey);
const status = data?.status ?? (isLoading ? 'loading' : 'unreachable');
const icon =
status === 'verified' ? (
<BadgeCheck className="h-3.5 w-3.5 text-teal-300" />
) : status === 'mismatch' ? (
<AlertTriangle className="h-3.5 w-3.5 text-red-400" />
) : status === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />
) : (
<AtSign className="h-3.5 w-3.5 text-gray-500" />
);
const label =
status === 'verified'
? t('socialUi.nip05.verified')
: status === 'mismatch'
? t('socialUi.nip05.mismatch')
: t('socialUi.form.nip05Unverified');
return (
<div className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
{icon}
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-200">{handle}</span>
<span
className={cn(
'text-[10px] uppercase tracking-wide',
status === 'verified' ? 'text-teal-300' : status === 'mismatch' ? 'text-red-400' : 'text-gray-500',
)}
>
{label}
</span>
<button
type="button"
onClick={onRemove}
aria-label={t('socialUi.form.nip05Remove', { handle })}
className="rounded p-1 text-gray-500 transition-colors hover:bg-white/5 hover:text-red-400"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}

View file

@ -10,45 +10,55 @@ import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface CrewNip05BadgeProps { interface CrewNip05BadgeProps {
/** A single handle (back-compat). */
nip05?: string; nip05?: string;
/** All crew handles. Takes precedence over `nip05` when present. */
nip05s?: string[];
founderPubkey?: string; founderPubkey?: string;
className?: string; className?: string;
} }
/** /**
* Verified-handle badge for a crew's optional NIP-05. * Verified-handle badge(s) for a crew's optional NIP-05. A crew may carry
* several handles (our own tag convention — unlike the single kind-0 `nip05`),
* each verified against the founder key.
* *
* - `verified`: green check + the handle. The domain confirms the founder key. * - `verified`: green check + the handle. The domain confirms the founder key.
* - `mismatch`: red warning — the domain points the handle at a different key. * - `mismatch`: red warning — the domain points the handle at a different key.
* - `unreachable`: muted handle (shown but unverified) while the domain is down. * - `unreachable`: muted handle (shown but unverified) while the domain is down.
* - `none`: renders nothing.
*/ */
export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05BadgeProps) { export function CrewNip05Badge({ nip05, nip05s, founderPubkey, className }: CrewNip05BadgeProps) {
const handles = (nip05s && nip05s.length > 0 ? nip05s : nip05 ? [nip05] : [])
.map((h) => h.trim().toLowerCase())
.filter(Boolean)
.filter((h, i, arr) => arr.indexOf(h) === i);
if (handles.length === 0) return null;
return (
<span className={cn('inline-flex flex-wrap items-center gap-1.5', className)}>
{handles.map((handle) => (
<Nip05BadgeRow key={handle} handle={handle} founderPubkey={founderPubkey} />
))}
</span>
);
}
function Nip05BadgeRow({ handle, founderPubkey }: { handle: string; founderPubkey?: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading } = useCrewNip05(nip05, founderPubkey); const { data, isLoading } = useCrewNip05(handle, founderPubkey);
if (!nip05) return null;
const status = data?.status ?? (isLoading ? 'loading' : 'unreachable'); const status = data?.status ?? (isLoading ? 'loading' : 'unreachable');
const handle = nip05.trim().toLowerCase();
if (status === 'mismatch') { if (status === 'mismatch') {
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span className="inline-flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-bold text-red-400">
className={cn(
'inline-flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-bold text-red-400',
className,
)}
>
<AlertTriangle className="h-3 w-3" /> {handle} <AlertTriangle className="h-3 w-3" /> {handle}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>{t('socialUi.nip05.mismatch')}</TooltipContent>
{t('socialUi.nip05.mismatch')}
</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
@ -59,18 +69,11 @@ export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05Bad
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
className={cn(
'inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300',
className,
)}
>
<BadgeCheck className="h-3 w-3" /> {handle} <BadgeCheck className="h-3 w-3" /> {handle}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>{t('socialUi.nip05.verified')}</TooltipContent>
{t('socialUi.nip05.verified')}
</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
@ -78,15 +81,8 @@ export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05Bad
// loading / unreachable — show the handle, muted. // loading / unreachable — show the handle, muted.
return ( return (
<span <span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-medium text-gray-400">
className={cn( {status === 'loading' ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
'inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-medium text-gray-400',
className,
)}
>
{status === 'loading' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : null}
{handle} {handle}
</span> </span>
); );

View file

@ -179,7 +179,7 @@ export function CrewHero({
> >
{crew.name} {crew.name}
</h1> </h1>
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} /> <CrewNip05Badge nip05s={crew.nip05s} founderPubkey={crew.pubkey} />
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm text-gray-400"> <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm text-gray-400">

View file

@ -3843,8 +3843,14 @@ socialUi: {
nameRequired: 'Name required', nameRequired: 'Name required',
nameRequiredDesc: 'Give your crew a name.', nameRequiredDesc: 'Give your crew a name.',
nip05DescA: 'A verifiable handle you control on any domain, e.g.', 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', 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", notAKeyA: "That doesn't look like a Nostr key yet — paste an",
notAKeyB: ',', notAKeyB: ',',
notAKeyC: ', or hex pubkey.', notAKeyC: ', or hex pubkey.',

View file

@ -3843,8 +3843,14 @@ socialUi: {
nameRequired: 'Nombre obligatorio', nameRequired: 'Nombre obligatorio',
nameRequiredDesc: 'Ponle un nombre a tu cuadrilla.', nameRequiredDesc: 'Ponle un nombre a tu cuadrilla.',
nip05DescA: 'Un identificador verificable que controlas en cualquier dominio, p. ej.', 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', 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', notAKeyA: 'Eso todavía no parece una clave de Nostr — pega un',
notAKeyB: ',', notAKeyB: ',',
notAKeyC: ', o una pubkey hex.', notAKeyC: ', o una pubkey hex.',

View file

@ -2156,7 +2156,8 @@ export interface SkateCrew {
bandUrl?: string; // Link to the band's music (Spotify/Bandcamp/YT/Nostr) (`band-url` tag) bandUrl?: string; // Link to the band's music (Spotify/Bandcamp/YT/Nostr) (`band-url` tag)
social?: string; // First social link (backward compat) social?: string; // First social link (backward compat)
socials: string[]; // All additional social links (`social` tags) 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) noffer?: string; // Optional shared tip jar (CLINK)
members: string[]; // Member pubkeys (hex) via `p` tags members: string[]; // Member pubkeys (hex) via `p` tags
admins: string[]; // Delegated admin pubkeys (subset of members, `admin` marker) 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 social = socials[0];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; 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); const members = parseMemberPubkeys(event);
// Admins must be a subset of claimed members. // Admins must be a subset of claimed members.
const admins = parseAdminPubkeys(event).filter((pk) => members.includes(pk)); const admins = parseAdminPubkeys(event).filter((pk) => members.includes(pk));
@ -3106,6 +3116,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
social, social,
socials, socials,
nip05, nip05,
nip05s,
noffer, noffer,
members, members,
admins, admins,