Admins can endorse join-requests; founder ratifies (kind 9087)

Closes the admin/founder asymmetry without breaking the trust model: only the
founder authors the crew event, so only they add members — but an admin can now
publicly *vouch* for a join-request, and the founder ratifies with one click.

Protocol (SkateSpots-native kind 9087, regular event):
- e-tags the join-request, a-tags the crew, p-tags the requester (role
  "requester") and the founder (so it hits the founder's bell).
- Clients keep only endorsements authored by a real admin of the endorsed crew
  and dedupe one-per-admin-per-request (no credibility inflation).
- New parseCrewEndorsement + CrewEndorsement type; documented in NIP.md.

Hooks:
- useEndorseJoinRequest (admin publishes an endorsement, admin-gated).
- useJoinRequestEndorsements: batch-fetch endorsements for all visible requests
  in ONE #e query, grouped per request, admin-verified per crew.
- useCrewNotifications attaches endorsements to each request entry.

UX (JoinRequestCard):
- Founder: primary becomes "Approve & add" when vouched, with a vouch strip
  (stacked admin avatars + "Vouched by Rider +1 more") as social proof.
- Admin: the old dead-end "Only the founder can add members" becomes a real
  "Vouch for them" action, then a "Vouched — waiting on the founder" state,
  plus a one-line explainer so the path isn't a mystery.
- Endorsements enrich the existing request card (no duplicate notification).

Build clean, no console errors. Not deployed.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 01:40:04 +07:00
parent 0f2215f46c
commit f4bca7b736
6 changed files with 323 additions and 26 deletions

42
NIP.md
View file

@ -2287,12 +2287,48 @@ admin. It carries nothing sensitive: just an optional note.
(founder only) / **Dismiss**.
3. **Approve.** Only the **founder** can add the requester's `p` tag to the crew
event (they author it); the requester then confirms with their own NIP-32
attestation, exactly as with an invite. Admins can surface and dismiss but
cannot add.
4. **Dismiss.** Because a moderator can't delete an event they didn't author,
attestation, exactly as with an invite.
4. **Endorse (admins).** An admin can't edit the roster, but can publicly
**vouch** for a request (kind `9087`, below). The founder sees the
endorsement(s) on the request card as social proof and ratifies with one
click — closing the admin/founder asymmetry without changing who owns the
crew identity.
5. **Dismiss.** Because a moderator can't delete an event they didn't author,
handled/ignored requests are cleared from *their* bell via the synced
dismissal set (below).
#### Admin endorsements (kind `9087`) — propose, founder confirms
Because the founder is the sole author of the crew event, an admin approving a
member directly is impossible by design. Instead an admin publishes a small
**endorsement** — a public "I vouch for this rider" signal that `e`-tags the
join-request, `a`-tags the crew, and `p`-tags both the requester (role
`requester`) and the founder (so it lands in the founder's bell).
```json
{
"kind": 9087,
"content": "Solid — skates the plaza with us most nights.",
"tags": [
["e", "<joinRequestEventId>"],
["a", "35006:<founder>:<crewDtag>"],
["p", "<requesterPubkey>", "", "requester"],
["p", "<founderPubkey>"],
["alt", "Crew endorsement: vouching for a new member of Barcelona Night Crew"]
]
}
```
| Tag | Description |
|-----|-------------|
| `e` | The join-request (kind `31969`) event id being endorsed. Queryable so a client fetches all endorsements for a set of requests in one `#e` filter. |
| `a` | The crew coordinate — used to verify the endorser is actually an **admin of that crew** (endorsements from non-admins are ignored, so nobody can inflate a request's credibility). |
| `p` (requester) | The rider being vouched for, marked with the `requester` role. |
| `p` (founder) | Surfaces the endorsement in the founder's notification bell. |
Clients **must** keep only endorsements whose author is a listed admin of the
endorsed crew, and dedupe to one endorsement per admin per request.
#### Dismissed notifications (kind `30078`) — private, cross-device
Neither an invitee nor a moderator authored the invite / join-request they're

View file

@ -5,11 +5,15 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useApproveJoinRequest, useDismissJoinRequest } from '@/hooks/useCrewInvites';
import {
useApproveJoinRequest,
useDismissJoinRequest,
useEndorseJoinRequest,
} from '@/hooks/useCrewInvites';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { parseAddrCoord, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
import { UserPlus, Check, X, Loader2, Hand } from 'lucide-react';
import { parseAddrCoord, type CrewEndorsement, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
import { UserPlus, Check, X, Loader2, Hand, ThumbsUp, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
function npubSafe(pubkey: string): string {
@ -20,20 +24,49 @@ function npubSafe(pubkey: string): string {
}
}
/** Tiny avatar for an endorsing admin (used in the vouch strip). */
function EndorserAvatar({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
return (
<Avatar className="h-5 w-5 ring-2 ring-gray-900">
<AvatarImage src={author.data?.metadata?.picture} alt={name} />
<AvatarFallback className="text-[8px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
/** Readable "Vouched by Rider, Mia +1" line for the endorsement strip. */
function EndorserNames({ pubkeys }: { pubkeys: string[] }) {
const first = useAuthor(pubkeys[0]);
const firstName = first.data?.metadata?.name ?? genUserName(pubkeys[0]);
const extra = pubkeys.length - 1;
return (
<span className="font-bold text-emerald-300">
{firstName}
{extra > 0 && <span className="font-normal text-emerald-400/70"> +{extra} more</span>}
</span>
);
}
/**
* A single incoming join-request card, shown to a crew's founder/admins. The
* FOUNDER gets a one-click "Add to crew" (publishes the updated crew member
* list); admins can only dismiss (they can't edit the founder's event). Either
* way the request can be cleared locally so it stops nagging.
* A single incoming join-request card for a crew's founder / admins.
*
* Trust model: only the founder authors the crew event, so only they can *add*
* a member. Admins therefore **endorse** ("vouch for") a request; the founder
* sees those endorsements as social proof and ratifies with one click. This
* closes the admin/founder asymmetry without breaking who-can-edit-the-roster.
*/
export function JoinRequestCard({
request,
crew,
endorsements = [],
onResolved,
compact,
}: {
request: CrewJoinRequest;
crew: SkateCrew;
endorsements?: CrewEndorsement[];
onResolved?: () => void;
compact?: boolean;
}) {
@ -44,11 +77,18 @@ export function JoinRequestCard({
const { toast } = useToast();
const { mutateAsync: approve, isPending: approving } = useApproveJoinRequest();
const { mutateAsync: dismiss, isPending: dismissing } = useDismissJoinRequest();
const [done, setDone] = useState<'approved' | 'dismissed' | null>(null);
const { mutateAsync: endorse, isPending: endorsing } = useEndorseJoinRequest();
const [done, setDone] = useState<'approved' | 'dismissed' | 'endorsed' | null>(null);
const coord = parseAddrCoord(request.crewCoord);
const crewHref = coord ? `/crew/${coord.pubkey}/${coord.identifier}` : '/crews';
const isFounder = !!user && user.pubkey.toLowerCase() === crew.pubkey.toLowerCase();
const meLc = user?.pubkey.toLowerCase();
const isFounder = !!meLc && meLc === crew.pubkey.toLowerCase();
const isAdmin = !!meLc && crew.admins.some((a) => a.toLowerCase() === meLc);
const endorserPubkeys = endorsements.map((e) => e.endorser);
const iEndorsed = !!meLc && endorserPubkeys.some((p) => p.toLowerCase() === meLc);
const hasEndorsements = endorserPubkeys.length > 0;
const handleApprove = async () => {
try {
@ -68,6 +108,24 @@ export function JoinRequestCard({
}
};
const handleEndorse = async () => {
try {
await endorse({ request, crew });
setDone('endorsed');
toast({
title: `You vouched for ${requesterName} 🤝`,
description: `The founder of ${crew.name} will see your endorsement and can add them.`,
});
onResolved?.();
} catch (err) {
toast({
title: 'Could not endorse',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
const handleDismiss = async () => {
try {
await dismiss({ request });
@ -86,6 +144,11 @@ export function JoinRequestCard({
<Check className="h-4 w-4 text-emerald-400" />
Added <span className="font-bold text-white">{requesterName}</span> to {crew.name}
</>
) : done === 'endorsed' ? (
<>
<ThumbsUp className="h-4 w-4 text-emerald-400" />
You vouched for <span className="font-bold text-white">{requesterName}</span> — founder will confirm
</>
) : (
<>
<X className="h-4 w-4 text-gray-500" />
@ -96,7 +159,7 @@ export function JoinRequestCard({
);
}
const busy = approving || dismissing;
const busy = approving || dismissing || endorsing;
return (
<div
@ -131,6 +194,21 @@ export function JoinRequestCard({
“{request.note}”
</p>
)}
{/* Vouch strip — admins' endorsements as social proof for the founder. */}
{hasEndorsements && (
<div className="mt-2 flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/[0.06] px-2.5 py-1.5">
<div className="flex -space-x-1.5">
{endorserPubkeys.slice(0, 3).map((pk) => (
<EndorserAvatar key={pk} pubkey={pk} />
))}
</div>
<p className="min-w-0 flex-1 truncate text-[11px] text-emerald-200/90">
Vouched by <EndorserNames pubkeys={endorserPubkeys} />
</p>
</div>
)}
<div className="mt-2.5 flex items-center gap-2">
{isFounder ? (
<Button
@ -140,27 +218,45 @@ export function JoinRequestCard({
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
>
{approving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <UserPlus className="h-3.5 w-3.5" />}
Add to crew
{hasEndorsements ? 'Approve & add' : 'Add to crew'}
</Button>
) : isAdmin ? (
iEndorsed ? (
<span className="flex flex-1 items-center gap-1.5 text-xs font-bold text-emerald-400/90">
<Clock className="h-3.5 w-3.5" /> Vouched — waiting on the founder
</span>
) : (
<Button
size="sm"
onClick={handleEndorse}
disabled={busy}
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
>
{endorsing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ThumbsUp className="h-3.5 w-3.5" />}
Vouch for them
</Button>
)
) : (
<span className="flex-1 text-xs text-gray-500">
Only the founder can add members.
</span>
<span className="flex-1 text-xs text-gray-500">Crew leadership only.</span>
)}
<Button
size="sm"
variant="outline"
onClick={handleDismiss}
disabled={busy}
className={cn(
'h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white',
!isFounder && 'flex-1',
)}
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Dismiss
</Button>
</div>
{/* Micro-help so the admin path isn't a mystery. */}
{isAdmin && !iEndorsed && (
<p className="mt-1.5 text-[11px] leading-snug text-gray-500">
Admins can’t add members directly — vouching tells the founder you back this rider.
</p>
)}
</div>
</div>
</div>

View file

@ -186,8 +186,14 @@ export function NotificationBell({ className }: { className?: string }) {
</div>
)}
{joinRequests.map(({ request, crew }) => (
<JoinRequestCard key={request.id} request={request} crew={crew} compact />
{joinRequests.map(({ request, crew, endorsements }) => (
<JoinRequestCard
key={request.id}
request={request}
crew={crew}
endorsements={endorsements}
compact
/>
))}
{invites.map((invite) => (

View file

@ -7,14 +7,17 @@ import { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
import {
CREW_INVITE_KIND,
CREW_JOIN_REQUEST_KIND,
CREW_ENDORSEMENT_KIND,
SKATE_CREW_KIND,
crewInviteD,
crewJoinRequestD,
parseCrewInvite,
parseCrewJoinRequest,
parseCrewEndorsement,
parseAddrCoord,
type CrewInvite,
type CrewJoinRequest,
type CrewEndorsement,
type SkateCrew,
type MemberVisibility,
} from '@/lib/types';
@ -350,3 +353,93 @@ export function useDismissJoinRequest() {
},
});
}
// ─── Admin endorsements (admin proposes → founder confirms) ────────────────────
// Only the founder can edit the roster, so an admin can't add a member. Instead
// an admin publicly *vouches* for a request (kind 9087, p-tagging the founder).
// The founder sees the endorsement(s) on the request card and ratifies with one
// click — closing the admin/founder asymmetry without breaking the trust model.
/** An admin endorses ("vouches for") a join-request so the founder can ratify. */
export function useEndorseJoinRequest() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
request,
crew,
note = '',
}: {
request: CrewJoinRequest;
crew: SkateCrew;
note?: string;
}) => {
if (!user) throw new Error('You must be logged in.');
const isAdmin = crew.admins.some((a) => a.toLowerCase() === user.pubkey.toLowerCase());
if (!isAdmin) throw new Error('Only a crew admin can endorse a request.');
const event = await user.signer.signEvent({
kind: CREW_ENDORSEMENT_KIND,
content: note.slice(0, 300),
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', request.id], // the request being endorsed
['a', request.crewCoord], // the crew
['p', request.requester, '', 'requester'], // who's being vouched for
['p', crew.pubkey], // surface to the founder's bell
['alt', `Crew endorsement: vouching for a new member of ${crew.name}`],
],
});
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
return event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-endorsements'] });
},
});
}
/**
* Fetch endorsements for a set of join-request ids in ONE query, grouped by
* request id. Only endorsements from actual crew admins are kept (an outsider
* can't inflate a request's credibility).
*/
export function useJoinRequestEndorsements(
requestIds: string[],
adminsByCrewCoord: Map<string, Set<string>>,
) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const ids = [...requestIds].sort();
return useQuery({
queryKey: ['crew-endorsements', ids, getQueryKeySuffix()],
enabled: ids.length > 0,
staleTime: 30_000,
queryFn: async (): Promise<Map<string, CrewEndorsement[]>> => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [CREW_ENDORSEMENT_KIND], '#e': ids, limit: 200 }],
{ signal },
);
const byRequest = new Map<string, CrewEndorsement[]>();
const seen = new Set<string>(); // dedupe one endorser per request
for (const ev of events) {
const end = parseCrewEndorsement(ev);
if (!end || !ids.includes(end.requestId)) continue;
// Endorser must be an admin of the endorsed crew.
const admins = adminsByCrewCoord.get(end.crewCoord);
if (!admins || !admins.has(end.endorser.toLowerCase())) continue;
const key = `${end.requestId}:${end.endorser.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
const arr = byRequest.get(end.requestId) ?? [];
arr.push(end);
byRequest.set(end.requestId, arr);
}
return byRequest;
},
});
}

View file

@ -1,10 +1,10 @@
import { useMemo } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMyCrewInvites, useIncomingJoinRequests } from '@/hooks/useCrewInvites';
import { useMyCrewInvites, useIncomingJoinRequests, useJoinRequestEndorsements } from '@/hooks/useCrewInvites';
import { useCrewDismissals } from '@/hooks/useCrewDismissals';
import { useCrews } from '@/hooks/useCrews';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { SKATE_CREW_KIND, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
import { SKATE_CREW_KIND, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
export interface CrewNotification {
/** Stable id for the notification (invite id or crew coord). */
@ -73,11 +73,36 @@ export function useCrewNotifications() {
);
}, [user, joinRequests, crews, dismissed]);
// Batch-fetch admin endorsements for the visible requests (one query), keyed
// per crew so only real admins of THAT crew can vouch.
const requestIds = useMemo(
() => requestsWithCrew.map(({ request }) => request.id),
[requestsWithCrew],
);
const adminsByCrewCoord = useMemo(() => {
const map = new Map<string, Set<string>>();
for (const { request, crew } of requestsWithCrew) {
map.set(request.crewCoord, new Set(crew.admins.map((a) => a.toLowerCase())));
}
return map;
}, [requestsWithCrew]);
const { data: endorsements } = useJoinRequestEndorsements(requestIds, adminsByCrewCoord);
// Attach each request's endorsements for the card to render.
const requestsWithEndorsements = useMemo(
() =>
requestsWithCrew.map((entry) => ({
...entry,
endorsements: (endorsements?.get(entry.request.id) ?? []) as CrewEndorsement[],
})),
[requestsWithCrew, endorsements],
);
return {
user,
invites: visibleInvites,
listedCrews,
joinRequests: requestsWithCrew,
joinRequests: requestsWithEndorsements,
isLoading: invitesLoading || joinLoading || crewsLoading,
};
}

View file

@ -67,6 +67,7 @@ export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membersh
export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native.
export const CREW_INVITE_KIND = 36229; // Addressable crew invitation — founder/admin invites a specific npub to a crew. SkateSpots-native.
export const CREW_JOIN_REQUEST_KIND = 31969; // Addressable crew join-request — a skater asks to join a crew (notifiable). SkateSpots-native.
export const CREW_ENDORSEMENT_KIND = 9087; // Regular event — an admin endorses ("vouches for") a join-request; the founder ratifies. SkateSpots-native.
/**
* Lab provider entity type.
@ -361,6 +362,46 @@ export function crewJoinRequestD(crewCoord: string, requesterPubkey: string): st
return `${crewCoord}:${requesterPubkey}`;
}
/**
* An admin's endorsement of a join-request (kind 9087). Since only the founder
* can edit the crew roster, an admin can't add a member directly — but they can
* publicly *vouch* for a request. The founder sees these endorsements on the
* request card and ratifies with one click. Public + `p`-tagged to the founder
* so it surfaces in their bell as a stronger signal.
*/
export interface CrewEndorsement {
id: string;
endorser: string; // admin pubkey who vouched
requester: string; // pubkey being vouched for
requestId: string; // the join-request event id being endorsed
crewCoord: string; // 35006:pubkey:d
note: string; // optional "why"
createdAt: number;
event: NostrEvent;
}
export function parseCrewEndorsement(event: NostrEvent): CrewEndorsement | null {
if (event.kind !== CREW_ENDORSEMENT_KIND) return null;
const requestId = event.tags.find(([n]) => n === 'e')?.[1];
const crewCoord = event.tags.find(([n]) => n === 'a')?.[1];
const requester = event.tags.find(([n, , , role]) => n === 'p' && role === 'requester')?.[1]
?? event.tags.find(([n]) => n === 'p')?.[1];
if (!requestId || !crewCoord || !requester) return null;
const c = parseAddrCoord(crewCoord);
if (!c || c.kind !== SKATE_CREW_KIND) return null;
if (!/^[0-9a-f]{64}$/i.test(requester)) return null;
return {
id: event.id,
endorser: event.pubkey,
requester: requester.toLowerCase(),
requestId,
crewCoord,
note: event.content.slice(0, 300),
createdAt: event.created_at,
event,
};
}
/**
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
* static payment code (see https://github.com/shocknet/clink, kind 21001).