Add crew NIP-05 (any-domain, founder-verified) + founder/delegated-admin governance
- SkateCrew: add nip05 + admins fields; parse `admin` role marker on p tags (admins = subset of members) - CrewForm: NIP-05 input + per-member "Make admin" toggle; write nip05 tag + admin-marked p tags (works on create & edit via shared form) - useCrewNip05: verify crew handle on ANY domain against the founder pubkey (verified/mismatch/unreachable), CORS-proxy fallback + caching - CrewNip05Badge: green ✓ / red ⚠ / muted handle states with tooltips - CollectiveMembers: show Admin chips, sort admins first, admin count in summary - useCrewRole: centralized founder/admin/member/none roles (canManage, canEditProfile) — founder owns identity, admins get delegated powers with their own keys; egalitarian shared-editing intentionally unsupported - CrewDetailPage: NIP-05 badge by crew name, viewer role badges (admin/member) - NIP.md: document crew nip05 verification, admin role marker, and governance/conflict-resolution model Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
e48a5e6bc5
commit
40e4a27565
8 changed files with 466 additions and 21 deletions
51
NIP.md
51
NIP.md
|
|
@ -1716,9 +1716,10 @@ bare `p` tag on the crew is only a claim until the member self-labels.
|
|||
["founded", "2019"],
|
||||
["t", "street"],
|
||||
["t", "transition"],
|
||||
["p", "<memberPubkey1>"],
|
||||
["p", "<memberPubkey1>", "", "admin"],
|
||||
["p", "<memberPubkey2>"],
|
||||
["a", "30818:<spotFounder>:<spotDtag>"],
|
||||
["nip05", "barcelonanightcrew@bcnnightcrew.example"],
|
||||
["website", "https://bcnnightcrew.example"],
|
||||
["noffer", "noffer1qqs..."],
|
||||
["alt", "Skate crew: Barcelona Night Crew"]
|
||||
|
|
@ -1743,8 +1744,9 @@ bare `p` tag on the crew is only a claim until the member self-labels.
|
|||
| `location` | Human-readable home location |
|
||||
| `founded` | Year founded (cosmetic) |
|
||||
| `t: <vibe>` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. |
|
||||
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). |
|
||||
| `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. |
|
||||
| `website`, `instagram`, `social` | Links |
|
||||
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
|
||||
|
||||
|
|
@ -1755,6 +1757,51 @@ belonging by publishing a NIP-32 label (kind `1985`) referencing the crew's
|
|||
`a`-coordinate, `["l", "member", "org.skatespots.membership"]`. Confirmed /
|
||||
pending chips and removal semantics are identical to Lab collectives.
|
||||
|
||||
### Governance (founder + delegated admins)
|
||||
|
||||
A crew's identity is a single **addressable event authored by the founder**.
|
||||
On Nostr, an addressable event can only be overwritten by its author's key, so
|
||||
the founder's pubkey is the crew's single source of truth — **conflicts are
|
||||
impossible at the data layer** (a fork by anyone else is a *different* event
|
||||
under a *different* author and is ignored by clients querying the founder's
|
||||
coordinate).
|
||||
|
||||
Roles:
|
||||
|
||||
| Role | Determined by | Powers |
|
||||
|------|---------------|--------|
|
||||
| **Founder** | Authored the crew event | Sole owner of the crew identity. Only role that can **edit the profile** (name, logo, members, admins, NIP-05, etc.). |
|
||||
| **Admin** | A confirmed member whose `p` tag carries the `"admin"` marker | Delegated *powers* exercised with the admin's **own key**: invite/vouch members, moderate the crew feed & chat, represent the crew. Cannot rewrite the profile. |
|
||||
| **Member** | Listed `p` tag + self-attested | Appears in the crew, posts to the feed/chat. |
|
||||
|
||||
Admins are always a **subset of members** — a pubkey marked `admin` that is not
|
||||
also a confirmed member is treated as a plain (pending) member until it attests.
|
||||
|
||||
> **Why not egalitarian shared editing?** Letting arbitrary members overwrite
|
||||
> the crew profile would require either a **shared private key** (a single leak
|
||||
> hijacks the crew) or a **consensus/multisig layer** (heavy and
|
||||
> non-interoperable). SkateSpots deliberately uses the owner + delegated-mods
|
||||
> model (like GitHub orgs, Discord, and NIP-72 communities): the founder owns
|
||||
> the record, admins wield delegated powers with their own keys.
|
||||
|
||||
### 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
|
||||
`https://<domain>/.well-known/nostr.json?name=<local>` and checking that the
|
||||
returned pubkey equals the **founder** pubkey:
|
||||
|
||||
| Result | Meaning | UI |
|
||||
|--------|---------|----|
|
||||
| name → founder pubkey | Verified | Green ✓ handle |
|
||||
| name → different pubkey | Impersonation / stale | Red ⚠ handle |
|
||||
| domain/JSON unreachable | Can't verify right now | Muted handle |
|
||||
| no `nip05` | — | (nothing) |
|
||||
|
||||
Verification is fetched directly with a CORS-proxy fallback and cached
|
||||
client-side.
|
||||
|
||||
### Map Integration
|
||||
|
||||
Crews with a `g` (home turf) render on the SkateSpots map as teal pins. Clicking
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ interface CollectiveMembersProps {
|
|||
founderPubkey: string;
|
||||
identifier: string;
|
||||
claimedMembers: string[];
|
||||
/** Delegated admin pubkeys (subset of members). */
|
||||
admins?: string[];
|
||||
accentText?: string;
|
||||
accentBorder?: string;
|
||||
}
|
||||
|
|
@ -48,7 +50,15 @@ function StatusChip({ status }: { status: CollectiveMember['status'] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function MemberRow({ member }: { member: CollectiveMember }) {
|
||||
function AdminChip() {
|
||||
return (
|
||||
<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">
|
||||
<ShieldCheck className="h-3 w-3" /> Admin
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boolean }) {
|
||||
const author = useAuthor(member.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? genUserName(member.pubkey);
|
||||
|
|
@ -64,6 +74,7 @@ function MemberRow({ member }: { member: CollectiveMember }) {
|
|||
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
|
||||
{isAdmin && <AdminChip />}
|
||||
<StatusChip status={member.status} />
|
||||
</Link>
|
||||
);
|
||||
|
|
@ -78,6 +89,7 @@ export function CollectiveMembers({
|
|||
founderPubkey,
|
||||
identifier,
|
||||
claimedMembers,
|
||||
admins = [],
|
||||
accentText = 'text-indigo-400',
|
||||
accentBorder = 'border-gray-700',
|
||||
}: CollectiveMembersProps) {
|
||||
|
|
@ -91,8 +103,18 @@ export function CollectiveMembers({
|
|||
|
||||
if (claimedMembers.length === 0) return null;
|
||||
|
||||
const adminSet = new Set(admins);
|
||||
const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0;
|
||||
|
||||
// Admins first, then the hook's existing confirmed-first ordering.
|
||||
const orderedMembers = members
|
||||
? [...members].sort((a, b) => {
|
||||
const aa = adminSet.has(a.pubkey) ? 1 : 0;
|
||||
const ba = adminSet.has(b.pubkey) ? 1 : 0;
|
||||
return ba - aa;
|
||||
})
|
||||
: members;
|
||||
|
||||
return (
|
||||
<Card className={cn('bg-black/90 border-2', accentBorder)}>
|
||||
<CardHeader>
|
||||
|
|
@ -108,6 +130,7 @@ export function CollectiveMembers({
|
|||
{confirmedCount > 0
|
||||
? `${confirmedCount} of ${claimedMembers.length} verified — each confirmed member signed with their own Nostr key.`
|
||||
: 'Membership is verified per member. Each member confirms with their own Nostr key.'}
|
||||
{admins.length > 0 && ` ${admins.length} admin${admins.length !== 1 ? 's' : ''} help run the crew.`}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
|
@ -123,8 +146,8 @@ export function CollectiveMembers({
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{members?.map((m) => (
|
||||
<MemberRow key={m.pubkey} member={m} />
|
||||
{orderedMembers?.map((m) => (
|
||||
<MemberRow key={m.pubkey} member={m} isAdmin={adminSet.has(m.pubkey)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,22 @@ import {
|
|||
type SkateCrew,
|
||||
} from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Users, Upload, X, Loader2, MapPin, Plus } from 'lucide-react';
|
||||
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign } from 'lucide-react';
|
||||
|
||||
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
|
||||
const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
|
||||
|
||||
function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void }) {
|
||||
function MemberRow({
|
||||
pubkey,
|
||||
isAdmin,
|
||||
onToggleAdmin,
|
||||
onRemove,
|
||||
}: {
|
||||
pubkey: string;
|
||||
isAdmin: boolean;
|
||||
onToggleAdmin: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? genUserName(pubkey);
|
||||
|
|
@ -42,9 +52,30 @@ function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void
|
|||
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{name}</p>
|
||||
<p className="flex items-center gap-1.5 truncate text-sm font-medium">
|
||||
{name}
|
||||
{isAdmin && (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
|
||||
<ShieldCheck className="h-2.5 w-2.5" /> Admin
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 shrink-0 gap-1 px-2 text-xs',
|
||||
isAdmin ? 'text-teal-300 hover:text-teal-200' : 'text-gray-400 hover:text-teal-300',
|
||||
)}
|
||||
onClick={onToggleAdmin}
|
||||
aria-label={isAdmin ? 'Remove admin role' : 'Make admin'}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
{isAdmin ? 'Admin' : 'Make admin'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label="Remove member">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -85,10 +116,20 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const [instagram, setInstagram] = useState(existing?.instagram ?? '');
|
||||
const [socials, setSocials] = useState<string[]>(existing?.socials ?? []);
|
||||
const [socialInput, setSocialInput] = useState('');
|
||||
const [nip05, setNip05] = useState(existing?.nip05 ?? '');
|
||||
const [noffer, setNoffer] = useState(existing?.noffer ?? '');
|
||||
const [members, setMembers] = useState<string[]>(existing?.members ?? []);
|
||||
const [admins, setAdmins] = useState<string[]>(existing?.admins ?? []);
|
||||
const [memberInput, setMemberInput] = useState('');
|
||||
const [memberError, setMemberError] = useState<string | null>(null);
|
||||
|
||||
const toggleAdmin = (pk: string) =>
|
||||
setAdmins((prev) => (prev.includes(pk) ? prev.filter((x) => x !== pk) : [...prev, pk]));
|
||||
|
||||
const removeMember = (pk: string) => {
|
||||
setMembers((prev) => prev.filter((m) => m !== pk));
|
||||
setAdmins((prev) => prev.filter((a) => a !== pk));
|
||||
};
|
||||
const [selectedSpots, setSelectedSpots] = useState<string[]>(
|
||||
(existing?.spotRefs ?? []).map((ref) => {
|
||||
const parts = ref.split(':');
|
||||
|
|
@ -232,9 +273,17 @@ 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()]);
|
||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||
|
||||
for (const pk of members) tags.push(['p', pk]);
|
||||
// Members: admins get an `admin` role marker on their `p` tag.
|
||||
for (const pk of members) {
|
||||
if (admins.includes(pk)) {
|
||||
tags.push(['p', pk, '', 'admin']);
|
||||
} else {
|
||||
tags.push(['p', pk]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const spotId of selectedSpots) {
|
||||
const [spotPubkey, spotIdentifier] = spotId.split(':');
|
||||
|
|
@ -346,6 +395,14 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<p className="mt-1 text-xs text-gray-500">
|
||||
Add your crew's Nostr accounts. Each member confirms from their own key — until then they show as “pending”.
|
||||
</p>
|
||||
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
|
||||
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
|
||||
<span>
|
||||
Make trusted members <strong className="text-teal-300">admins</strong> — they can invite members
|
||||
and moderate the crew feed & chat with their own keys. You (the founder) always stay the
|
||||
owner of the crew's identity.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
|
|
@ -365,7 +422,13 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
{members.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{members.map((pk) => (
|
||||
<MemberRow key={pk} pubkey={pk} onRemove={() => setMembers(members.filter((m) => m !== pk))} />
|
||||
<MemberRow
|
||||
key={pk}
|
||||
pubkey={pk}
|
||||
isAdmin={admins.includes(pk)}
|
||||
onToggleAdmin={() => toggleAdmin(pk)}
|
||||
onRemove={() => removeMember(pk)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -408,6 +471,26 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Crew NIP-05 identity */}
|
||||
<div className="space-y-2 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">
|
||||
<AtSign className="h-4 w-4" /> Crew NIP-05 <span className="font-normal text-gray-500">· optional</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
A verifiable handle you control on any domain, e.g. <code className="text-teal-300">crew@yourdomain.com</code>.
|
||||
It's verified against this crew's founder key — a green check shows when the domain confirms it.
|
||||
</p>
|
||||
<Input
|
||||
id="crew-nip05"
|
||||
placeholder="crew@yourdomain.com"
|
||||
value={nip05}
|
||||
onChange={(e) => setNip05(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="bg-gray-800 border-gray-700 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Links</Label>
|
||||
|
|
|
|||
91
src/components/CrewNip05Badge.tsx
Normal file
91
src/components/CrewNip05Badge.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { BadgeCheck, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useCrewNip05 } from '@/hooks/useCrewNip05';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CrewNip05BadgeProps {
|
||||
nip05?: string;
|
||||
founderPubkey?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified-handle badge for a crew's optional NIP-05.
|
||||
*
|
||||
* - `verified`: green check + the handle. The domain confirms the founder key.
|
||||
* - `mismatch`: red warning — the domain points the handle at a different key.
|
||||
* - `unreachable`: muted handle (shown but unverified) while the domain is down.
|
||||
* - `none`: renders nothing.
|
||||
*/
|
||||
export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05BadgeProps) {
|
||||
const { data, isLoading } = useCrewNip05(nip05, founderPubkey);
|
||||
|
||||
if (!nip05) return null;
|
||||
|
||||
const status = data?.status ?? (isLoading ? 'loading' : 'unreachable');
|
||||
const handle = nip05.trim().toLowerCase();
|
||||
|
||||
if (status === 'mismatch') {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
This domain points the handle at a different Nostr key than the crew founder. Not verified.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'verified') {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Verified — the domain confirms this handle belongs to the crew's founder key.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// loading / unreachable — show the handle, muted.
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'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}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
88
src/hooks/useCrewNip05.ts
Normal file
88
src/hooks/useCrewNip05.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export type Nip05Status = 'none' | 'verified' | 'mismatch' | 'unreachable';
|
||||
|
||||
interface Nip05Result {
|
||||
status: Nip05Status;
|
||||
/** The pubkey the domain claims for this name, if any. */
|
||||
resolvedPubkey?: string;
|
||||
}
|
||||
|
||||
/** Basic `local@domain.tld` shape check. */
|
||||
function parseNip05(nip05: string): { name: string; domain: string } | null {
|
||||
const trimmed = nip05.trim().toLowerCase();
|
||||
const at = trimmed.indexOf('@');
|
||||
if (at <= 0) return null;
|
||||
const name = trimmed.slice(0, at);
|
||||
const domain = trimmed.slice(at + 1);
|
||||
if (!/^[a-z0-9._-]+$/.test(name)) return null;
|
||||
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return null;
|
||||
return { name, domain };
|
||||
}
|
||||
|
||||
async function fetchWellKnown(name: string, domain: string, signal: AbortSignal) {
|
||||
const target = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`;
|
||||
|
||||
// Try direct first; fall back to the CORS proxy (many nostr.json hosts don't
|
||||
// send permissive CORS headers).
|
||||
const attempts = [
|
||||
target,
|
||||
`https://proxy.shakespeare.diy/?url=${encodeURIComponent(target)}`,
|
||||
];
|
||||
|
||||
for (const url of attempts) {
|
||||
try {
|
||||
const res = await fetch(url, { signal, headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json();
|
||||
const names = data?.names;
|
||||
if (names && typeof names === 'object') {
|
||||
return names as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
// try next attempt
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a crew's optional NIP-05 identifier against its founder pubkey.
|
||||
*
|
||||
* The crew profile is authored by the founder, so a NIP-05 is legitimate only if
|
||||
* the domain's `.well-known/nostr.json` maps the name to that same founder key.
|
||||
* This works with ANY domain the crew controls (fully decentralized) — SkateSpots
|
||||
* does not gatekeep the handle.
|
||||
*
|
||||
* States:
|
||||
* - `verified`: domain maps the name to the founder pubkey. Trusted ✓
|
||||
* - `mismatch`: domain maps the name to a DIFFERENT pubkey. Do not trust.
|
||||
* - `unreachable`: the domain / nostr.json couldn't be fetched (network/CORS).
|
||||
* - `none`: no NIP-05 set, or malformed.
|
||||
*/
|
||||
export function useCrewNip05(nip05: string | undefined, founderPubkey: string | undefined) {
|
||||
return useQuery<Nip05Result>({
|
||||
queryKey: ['crew-nip05', nip05, founderPubkey],
|
||||
queryFn: async (c): Promise<Nip05Result> => {
|
||||
if (!nip05 || !founderPubkey) return { status: 'none' };
|
||||
const parsed = parseNip05(nip05);
|
||||
if (!parsed) return { status: 'none' };
|
||||
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||
const names = await fetchWellKnown(parsed.name, parsed.domain, signal);
|
||||
if (!names) return { status: 'unreachable' };
|
||||
|
||||
const resolved = names[parsed.name]?.toLowerCase();
|
||||
if (!resolved) return { status: 'unreachable' };
|
||||
|
||||
if (resolved === founderPubkey.toLowerCase()) {
|
||||
return { status: 'verified', resolvedPubkey: resolved };
|
||||
}
|
||||
return { status: 'mismatch', resolvedPubkey: resolved };
|
||||
},
|
||||
enabled: !!nip05 && !!founderPubkey,
|
||||
staleTime: 10 * 60 * 1000, // 10 min
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
71
src/hooks/useCrewRole.ts
Normal file
71
src/hooks/useCrewRole.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { SkateCrew } from '@/lib/types';
|
||||
|
||||
export type CrewRole = 'founder' | 'admin' | 'member' | 'none';
|
||||
|
||||
export interface CrewRoleInfo {
|
||||
role: CrewRole;
|
||||
isFounder: boolean;
|
||||
/** Founder OR delegated admin — the set allowed elevated actions. */
|
||||
isAdmin: boolean;
|
||||
isMember: boolean;
|
||||
/** Can invite/vouch members & moderate the crew feed/chat. */
|
||||
canManage: boolean;
|
||||
/** Can edit the crew's core identity (founder only — addressable event owner). */
|
||||
canEditProfile: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the logged-in user's role within a crew.
|
||||
*
|
||||
* Governance model (see NIP.md):
|
||||
* - **founder**: authored the crew's addressable event → sole owner of the
|
||||
* crew identity. Only they can edit the profile (Nostr addressable events can
|
||||
* only be overwritten by their author's key).
|
||||
* - **admin**: a delegated, confirmed member the founder marked with an `admin`
|
||||
* role. Admins act with their OWN keys to invite members and moderate the
|
||||
* crew feed/chat, but cannot rewrite the profile.
|
||||
* - **member**: a claimed member.
|
||||
* - **none**: not part of the crew.
|
||||
*
|
||||
* Egalitarian shared-editing is intentionally unsupported: it would require a
|
||||
* shared private key (insecure) or a consensus layer (non-interoperable).
|
||||
*/
|
||||
export function useCrewRole(crew: SkateCrew | null | undefined): CrewRoleInfo {
|
||||
const { user } = useCurrentUser();
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
if (!crew || !pubkey) {
|
||||
return {
|
||||
role: 'none',
|
||||
isFounder: false,
|
||||
isAdmin: false,
|
||||
isMember: false,
|
||||
canManage: false,
|
||||
canEditProfile: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isFounder = pubkey === crew.pubkey;
|
||||
const isDelegatedAdmin = crew.admins.includes(pubkey);
|
||||
const isMember = crew.members.includes(pubkey);
|
||||
|
||||
const role: CrewRole = isFounder
|
||||
? 'founder'
|
||||
: isDelegatedAdmin
|
||||
? 'admin'
|
||||
: isMember
|
||||
? 'member'
|
||||
: 'none';
|
||||
|
||||
const isAdmin = isFounder || isDelegatedAdmin;
|
||||
|
||||
return {
|
||||
role,
|
||||
isFounder,
|
||||
isAdmin,
|
||||
isMember,
|
||||
canManage: isAdmin,
|
||||
canEditProfile: isFounder,
|
||||
};
|
||||
}
|
||||
|
|
@ -85,6 +85,27 @@ export function parseMemberPubkeys(event: NostrEvent): string[] {
|
|||
.map(([, value]) => value.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the admin pubkeys a collective delegates powers to.
|
||||
*
|
||||
* Admins are ordinary members whose `p` tag carries an `"admin"` role marker in
|
||||
* the 4th position: `["p", <pubkey>, <relay-hint?>, "admin"]`. Using a marker on
|
||||
* the standard `p` tag keeps admins visible to any client that reads members,
|
||||
* while letting SkateSpots grant them elevated *actions* (invite, moderate).
|
||||
*
|
||||
* Note: admins are delegated *powers*, not edit rights — only the founder (the
|
||||
* event author) can overwrite the addressable crew profile. Admins act with
|
||||
* their own keys.
|
||||
*/
|
||||
export function parseAdminPubkeys(event: NostrEvent): string[] {
|
||||
return event.tags
|
||||
.filter(
|
||||
([name, value, , marker]) =>
|
||||
name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin',
|
||||
)
|
||||
.map(([, value]) => value.toLowerCase());
|
||||
}
|
||||
|
||||
/** All Lab provider kinds (The Lab service profiles). */
|
||||
export const LAB_KINDS = [35001, 35002, 35003, 35004, 35005] as const;
|
||||
|
||||
|
|
@ -861,8 +882,10 @@ export interface SkateCrew {
|
|||
instagram?: string;
|
||||
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)
|
||||
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)
|
||||
spotRefs: string[]; // Home spots (kind 30818 refs)
|
||||
labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d)
|
||||
createdAt: number;
|
||||
|
|
@ -1387,7 +1410,10 @@ 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);
|
||||
const members = parseMemberPubkeys(event);
|
||||
// Admins must be a subset of claimed members.
|
||||
const admins = parseAdminPubkeys(event).filter((pk) => members.includes(pk));
|
||||
const spotRefs = event.tags
|
||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||
.map(([, value]) => value);
|
||||
|
|
@ -1408,8 +1434,10 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
|||
instagram,
|
||||
social,
|
||||
socials,
|
||||
nip05,
|
||||
noffer,
|
||||
members,
|
||||
admins,
|
||||
spotRefs,
|
||||
labRefs,
|
||||
createdAt: event.created_at,
|
||||
|
|
|
|||
|
|
@ -11,20 +11,21 @@ import { ZapButton } from '@/components/ZapButton';
|
|||
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
|
||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
|
||||
import { CrewChat } from '@/components/CrewChat';
|
||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
||||
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||
import { useCrewRole } from '@/hooks/useCrewRole';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { useCrew } from '@/hooks/useCrews';
|
||||
import { useCrewFeed } from '@/hooks/useCrewFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types';
|
||||
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import NotFound from './NotFound';
|
||||
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap } from 'lucide-react';
|
||||
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
function FeedItem({ event }: { event: NostrEvent }) {
|
||||
|
|
@ -59,8 +60,8 @@ function FeedItem({ event }: { event: NostrEvent }) {
|
|||
export default function CrewDetailPage() {
|
||||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey);
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(crew?.pubkey);
|
||||
const { isFounder, isAdmin, role } = useCrewRole(crew);
|
||||
|
||||
useSeoMeta({
|
||||
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
|
||||
|
|
@ -109,7 +110,6 @@ export default function CrewDetailPage() {
|
|||
|
||||
const npub = nip19.npubEncode(crew.pubkey);
|
||||
const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey);
|
||||
const isFounder = user?.pubkey === crew.pubkey;
|
||||
const hasMembers = crew.members.length > 0;
|
||||
|
||||
return (
|
||||
|
|
@ -153,6 +153,7 @@ export default function CrewDetailPage() {
|
|||
<Badge variant="outline" className="gap-1 text-teal-400 border-teal-500/40">
|
||||
<Users className="h-3 w-3" /> Crew
|
||||
</Badge>
|
||||
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
|
||||
{crew.location && (
|
||||
|
|
@ -167,13 +168,25 @@ export default function CrewDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isFounder && (
|
||||
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
||||
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
||||
<Edit className="h-3.5 w-3.5" /> Edit
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{isFounder && (
|
||||
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
||||
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
||||
<Edit className="h-3.5 w-3.5" /> Edit
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && !isFounder && (
|
||||
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
|
||||
<ShieldCheck className="h-3 w-3" /> You're an admin
|
||||
</Badge>
|
||||
)}
|
||||
{role === 'member' && (
|
||||
<Badge variant="outline" className="gap-1 border-teal-500/30 text-teal-400">
|
||||
<Users className="h-3 w-3" /> Member
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{crew.bio && (
|
||||
|
|
@ -309,6 +322,7 @@ export default function CrewDetailPage() {
|
|||
founderPubkey={crew.pubkey}
|
||||
identifier={crew.identifier}
|
||||
claimedMembers={crew.members}
|
||||
admins={crew.admins}
|
||||
accentText="text-teal-400"
|
||||
accentBorder="border-teal-500/40"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue