SkateSpots/src/hooks/useCrewInvites.ts
shakespeare.diy b6b3931ecc Crew audit: fix member-clobber on rapid roster writes (1.2.37)
Audited the whole crew surface. The trust model is genuinely solid:
membership is double-sided and unforgeable (founder p-tags + the
member's own NIP-32 kind-1985 attestation, cross-referenced in
useCollectiveMembers), roles are founder-controlled via the crew's
addressable event (useCrewRole), moderation only honours labels authored
by listed moderators, and the invite/join/endorse flow is symmetric with
proper author validation and batched, deduped queries throughout.

One real bug found — a stale-snapshot member clobber. The crew roster is
a single addressable event rewritten wholesale on every member add, but
three write sites rebuilt it from the CACHED crew/profile snapshot
without re-fetching first:
  • useApproveJoinRequest (founder "Add to crew")
  • useSendCrewInvite (founder pre-list on invite)
  • useApproveCollectiveJoinRequest (Lab collective owner add)

So approving two join-requests back to back rebuilt the second write from
the pre-first-write roster and silently DROPPED the member added by the
first (addressable events replace by coordinate). This is exactly the
fetch-before-write discipline already applied to spots/events/shops/
contacts/stash — the crew/collective roster writes were the ones missed.

Fix: added fetchLatestCrewEvent() (and an inline equivalent for Lab
profiles) that re-queries the freshest roster event by coordinate and
merges the new member into THAT, never regressing to an older revision,
falling back to the passed-in snapshot only on a relay hiccup so an add
still goes through. Founder-only / owner-only authorization and the
membership-attestation trust model are unchanged.

Member removal / admin promotion go through the founder's own profile
editor (a deliberate full-roster overwrite by the sole owner), so they
aren't concurrent-merge scenarios and are left as-is.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-08 00:02:30 +07:00

501 lines
19 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
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';
import type { NostrEvent } from '@nostrify/nostrify';
// ─── Dismissals ───────────────────────────────────────────────────────────────
// The invitee/moderator can't delete an event they didn't author, so handled
// invites & join-requests are cleared from *their* notifications via a
// dismissal set. That set is a private, cross-device-synced NIP-78 list —
// see `useCrewDismissals`. The public event still exists on relays; it just
// stops nagging them, everywhere they log in.
// ─── Sending an invite ──────────────────────────────────────────────────────
interface SendInviteParams {
crew: SkateCrew;
/** Invitee pubkey (hex). */
invitee: string;
role?: 'member' | 'admin';
note?: string;
/** Whether the current user is the crew founder (can also add the p-tag). */
isFounder: boolean;
}
/**
* Send a structured crew invite to a specific pubkey.
*
* The invite is a public, addressable event (kind 36229) that `p`-tags the
* invitee so they can query "invites addressed to me". It carries nothing
* sensitive — real membership stays unforgeable (founder `p` tag + invitee
* attestation).
*
* When the sender is the FOUNDER, we optimistically add the invitee to the
* crew's member (`p`) list at invite time, so the moment they accept (attest)
* they show as a confirmed member with no extra founder step. When an ADMIN
* invites, they can't edit the founder's event, so the invite alone is sent and
* the founder confirms the add on accept.
*/
export function useSendCrewInvite() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ crew, invitee, role = 'member', note = '', isFounder }: SendInviteParams) => {
if (!user) throw new Error('You must be logged in to invite.');
const crewCoord = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
const inviteePk = invitee.toLowerCase();
if (inviteePk === user.pubkey.toLowerCase()) {
throw new Error("You can't invite yourself.");
}
// 1. Publish the invite event.
const inviteEvent = await user.signer.signEvent({
kind: CREW_INVITE_KIND,
content: note.slice(0, 500),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', crewInviteD(crewCoord, inviteePk)],
['p', inviteePk],
['a', crewCoord],
['crew_name', crew.name],
['role', role],
['alt', `Crew invite: join ${crew.name} on SkateSpots`],
],
});
await nostr.event(inviteEvent, { signal: AbortSignal.timeout(15000) });
// 2. Founder optimistically pre-lists the invitee so accept = instant confirm.
if (isFounder && user.pubkey.toLowerCase() === crew.pubkey.toLowerCase()) {
// Re-fetch the freshest roster so a rapid second invite/approval can't
// rebuild from a stale snapshot and drop a just-added member.
const latest = await fetchLatestCrewEvent(nostr, crew);
const already = latest.tags.some(
([n, v]) => n === 'p' && v?.toLowerCase() === inviteePk,
);
if (!already) {
const updated = republishCrewWithMember(latest, inviteePk, role);
const signed = await user.signer.signEvent(updated);
await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
}
}
return inviteEvent;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
},
});
}
/**
* Rebuild a crew event's tags with an added member `p` tag, preserving all
* other tags. Returns an unsigned event template ready to sign.
*/
function republishCrewWithMember(
crewEvent: NostrEvent,
memberPubkey: string,
role: 'member' | 'admin',
): { kind: number; content: string; created_at: number; tags: string[][] } {
const tags = crewEvent.tags.filter(
([n, v]) => !(n === 'p' && v?.toLowerCase() === memberPubkey.toLowerCase()),
);
if (role === 'admin') tags.push(['p', memberPubkey, '', 'admin']);
else tags.push(['p', memberPubkey]);
return {
kind: crewEvent.kind,
content: crewEvent.content,
created_at: Math.floor(Date.now() / 1000),
tags,
};
}
/**
* Fetch the FRESHEST crew event for a coordinate right before a roster write.
*
* The crew roster is a single addressable event that we rewrite wholesale on
* every member add. Basing that rewrite on a cached `crew.event` snapshot is
* unsafe: approving two join-requests (or pre-listing two invitees) back to
* back would rebuild the second write from the pre-first-write snapshot and
* silently DROP the member added by the first — the same stale-snapshot clobber
* we guard against in spots/events/shops. So we always re-fetch + merge first.
*
* Falls back to the provided snapshot if the relay returns nothing (offline /
* hiccup) so an add still goes through rather than throwing.
*/
async function fetchLatestCrewEvent(
nostr: ReturnType<typeof useNostr>['nostr'],
crew: SkateCrew,
): Promise<NostrEvent> {
try {
const events = await nostr.query(
[{
kinds: [SKATE_CREW_KIND],
authors: [crew.pubkey],
'#d': [crew.identifier],
limit: 1,
}],
{ signal: AbortSignal.timeout(8000) },
);
// Newest wins (relays may hold multiple revisions).
let latest: NostrEvent | undefined;
for (const ev of events) {
if (!latest || ev.created_at > latest.created_at) latest = ev;
}
// Only trust a fresher-or-equal event; never regress to an older revision.
if (latest && latest.created_at >= crew.event.created_at) return latest;
} catch {
// fall through to snapshot
}
return crew.event;
}
// ─── Reading invites addressed to me ─────────────────────────────────────────
/**
* Live crew invites where the given pubkey is the invitee. Dismissal filtering
* is applied downstream in `useCrewNotifications` (via the synced dismissal
* set), so this returns every live invite addressed to the user.
*/
export function useMyCrewInvites(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['crew-invites', 'incoming', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 30_000,
refetchOnMount: 'always',
queryFn: async (): Promise<CrewInvite[]> => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [CREW_INVITE_KIND], '#p': [pubkey], limit: 100 }],
{ signal },
);
const seen = new Map<string, CrewInvite>();
for (const ev of events) {
const inv = parseCrewInvite(ev);
if (!inv || inv.invitee.toLowerCase() !== pubkey.toLowerCase()) continue;
// Dedup by crew coord (addressable — newest wins).
const prev = seen.get(inv.crewCoord);
if (!prev || inv.createdAt > prev.createdAt) seen.set(inv.crewCoord, inv);
}
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
// ─── Accept / decline ─────────────────────────────────────────────────────────
/**
* Accept a crew invite: publish the invitee's own NIP-32 membership attestation
* (kind 1985). Once the founder has listed them (founders pre-list on invite),
* they show as a confirmed member. The invite is dismissed locally.
*/
export function useAcceptCrewInvite() {
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: confirm } = useConfirmMembership();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({
mutationFn: async ({
invite,
visibility = 'public',
}: {
invite: CrewInvite;
visibility?: MemberVisibility;
}) => {
if (!user) throw new Error('You must be logged in.');
const coord = parseAddrCoord(invite.crewCoord);
if (!coord) throw new Error('Invalid crew reference.');
// The ONE required action: publish the membership attestation. This is the
// single signature the UI waits on.
await confirm({
kind: coord.kind,
founderPubkey: coord.pubkey,
identifier: coord.identifier,
collectiveName: invite.crewName,
visibility,
});
// Dismiss is optimistic/local-first and best-effort for cross-device sync;
// fire it WITHOUT blocking accept so a second signer round-trip can never
// leave the Accept button stuck on "loading".
void dismiss(invite.id).catch(() => {
/* local dismissal already applied; sync is best-effort */
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
queryClient.invalidateQueries({ queryKey: ['collective-members'] });
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
},
});
}
/** Decline a crew invite — dismiss it so it clears from notifications (synced). */
export function useDeclineCrewInvite() {
const queryClient = useQueryClient();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({
mutationFn: async ({ invite }: { invite: CrewInvite }) => {
await dismiss(invite.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
},
});
}
// ─── Join requests (notifiable) ───────────────────────────────────────────────
// A skater asks to join a crew. We publish a structured, addressable event
// (kind 31969) p-tagged to the founder + admins so it shows in THEIR notification
// bell with a one-click "Add to crew" (founder) — parallel to invites, so the
// two directions are symmetric. Handled requests are cleared via the shared,
// cross-device-synced dismissal set (`useCrewDismissals`); filtering happens
// downstream in `useCrewNotifications`.
/** Publish a structured join-request to a crew's founder + admins. */
export function useSendJoinRequest() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
crew,
note = '',
}: {
crew: SkateCrew;
note?: string;
}) => {
if (!user) throw new Error('You must be logged in to request to join.');
const crewCoord = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
const recipients = Array.from(new Set([crew.pubkey, ...crew.admins])).filter(
(pk) => pk.toLowerCase() !== user.pubkey.toLowerCase(),
);
const event = await user.signer.signEvent({
kind: CREW_JOIN_REQUEST_KIND,
content: note.slice(0, 500),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', crewJoinRequestD(crewCoord, user.pubkey.toLowerCase())],
['a', crewCoord],
['crew_name', crew.name],
...recipients.map((pk) => ['p', pk]),
['alt', `Crew join request: ${crew.name} on SkateSpots`],
],
});
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
return event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
},
});
}
/** Incoming join-requests addressed to the given moderator (founder/admin). */
export function useIncomingJoinRequests(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['crew-join-requests', 'incoming', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 30_000,
refetchOnMount: 'always',
queryFn: async (): Promise<CrewJoinRequest[]> => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [CREW_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }],
{ signal },
);
const seen = new Map<string, CrewJoinRequest>();
for (const ev of events) {
const req = parseCrewJoinRequest(ev);
if (!req) continue;
if (!req.recipients.includes(pubkey.toLowerCase())) continue;
// Dedup by (crew + requester) — newest wins.
const key = `${req.crewCoord}:${req.requester}`;
const prev = seen.get(key);
if (!prev || req.createdAt > prev.createdAt) seen.set(key, req);
}
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/**
* Approve a join-request: the FOUNDER adds the requester to the crew's member
* (`p`) list. The requester then confirms with their own key (standard
* attestation). Only the founder can do this (they author the crew event); an
* admin dismisses/handles out-of-band.
*/
export function useApproveJoinRequest() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({
mutationFn: async ({ request, crew }: { request: CrewJoinRequest; crew: SkateCrew }) => {
if (!user) throw new Error('You must be logged in.');
if (user.pubkey.toLowerCase() !== crew.pubkey.toLowerCase()) {
throw new Error('Only the crew founder can add members.');
}
// Re-fetch the freshest roster before rewriting it, so approving several
// requests in quick succession can't clobber earlier additions (each
// approval rewrites the whole addressable roster event).
const latest = await fetchLatestCrewEvent(nostr, crew);
const alreadyMember = latest.tags.some(
([n, v]) => n === 'p' && v?.toLowerCase() === request.requester.toLowerCase(),
);
if (!alreadyMember) {
const updated = republishCrewWithMember(latest, request.requester, 'member');
const signed = await user.signer.signEvent(updated);
await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
}
await dismiss(request.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
},
});
}
/** Dismiss a join-request (moderator handled/ignored it) — synced across devices. */
export function useDismissJoinRequest() {
const queryClient = useQueryClient();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({
mutationFn: async ({ request }: { request: CrewJoinRequest }) => {
await dismiss(request.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
},
});
}
// ─── 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;
},
});
}