SkateSpots/src/hooks/useCollectiveJoinRequests.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

188 lines
7.2 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 { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
import {
COLLECTIVE_JOIN_REQUEST_KIND,
collectiveJoinRequestD,
parseCollectiveJoinRequest,
type CollectiveJoinRequest,
} from '@/lib/types';
import type { LabProfile } from '@/hooks/useLabProfiles';
/**
* Lab-collective join-requests — mirror of the crew join-request flow.
*
* Someone asks to join a Lab collective (studio / build team). The request is
* addressable (keyed by collective + requester, so re-requesting replaces) and
* `p`-tagged to the collective owner so it surfaces in their notification bell.
* The owner adds the member through their normal Lab-profile editor — only they
* can edit their own event, so there's no forged-membership risk.
*/
/** Publish (or replace) a join-request to a Lab collective's owner. */
export function useSendCollectiveJoinRequest() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ profile, note = '' }: { profile: LabProfile; note?: string }) => {
if (!user) throw new Error('You must be logged in to request to join.');
const coord = `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`;
const event = await user.signer.signEvent({
kind: COLLECTIVE_JOIN_REQUEST_KIND,
content: note.slice(0, 500),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', collectiveJoinRequestD(coord, user.pubkey.toLowerCase())],
['a', coord],
['collective_name', profile.title],
['p', profile.pubkey],
['alt', `Collective join request: ${profile.title} on SkateSpots`],
],
});
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
return event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collective-join-requests'] });
},
});
}
/** Incoming collective join-requests addressed to the given owner. */
export function useIncomingCollectiveJoinRequests(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['collective-join-requests', 'incoming', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 30_000,
refetchOnMount: 'always',
queryFn: async (): Promise<CollectiveJoinRequest[]> => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [COLLECTIVE_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }],
{ signal },
);
const seen = new Map<string, CollectiveJoinRequest>();
for (const ev of events) {
const req = parseCollectiveJoinRequest(ev);
if (!req) continue;
if (!req.recipients.includes(pubkey.toLowerCase())) continue;
// Dedup by (collective + requester) — newest wins.
const key = `${req.collectiveCoord}:${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);
},
});
}
/**
* One-click approval: the collective OWNER adds the requester to their own Lab
* profile (`p` tag) and dismisses the request. Only the owner authors the
* profile event, so this is the sole trusted path — mirrors the crew founder's
* "Add to crew". Re-signing preserves every other tag on the profile.
*/
export function useApproveCollectiveJoinRequest() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({
mutationFn: async ({
request,
profile,
}: {
request: CollectiveJoinRequest;
profile: LabProfile;
}) => {
if (!user) throw new Error('You must be logged in.');
if (user.pubkey.toLowerCase() !== profile.pubkey.toLowerCase()) {
throw new Error('Only the collective owner can add members.');
}
// Re-fetch the freshest profile before rewriting its roster, so approving
// multiple requests back to back can't rebuild from a stale snapshot and
// drop an already-added member (the whole addressable event is rewritten
// on every add). Fall back to the passed-in snapshot on a relay hiccup.
let baseEvent = profile.event;
try {
const fresh = await nostr.query(
[{
kinds: [profile.event.kind],
authors: [profile.pubkey],
'#d': [profile.identifier],
limit: 1,
}],
{ signal: AbortSignal.timeout(8000) },
);
let latest = baseEvent;
for (const ev of fresh) {
if (ev.created_at > latest.created_at) latest = ev;
}
baseEvent = latest;
} catch {
// keep snapshot
}
const already = baseEvent.tags.some(
([n, v]) => n === 'p' && v?.toLowerCase() === request.requester.toLowerCase(),
);
if (!already) {
// Clone the profile event, drop any stale p for this pubkey, append it.
const tags = baseEvent.tags.filter(
([n, v]) => !(n === 'p' && v?.toLowerCase() === request.requester.toLowerCase()),
);
tags.push(['p', request.requester]);
const signed = await user.signer.signEvent({
kind: baseEvent.kind,
content: baseEvent.content,
created_at: Math.floor(Date.now() / 1000),
tags,
});
await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
}
// Clear it from the owner's bell (they can't delete the requester's event).
await dismiss(request.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collective-join-requests'] });
queryClient.invalidateQueries({ queryKey: ['lab-profile'] });
queryClient.invalidateQueries({ queryKey: ['lab-profiles'] });
queryClient.invalidateQueries({ queryKey: ['collective-members'] });
},
});
}
/**
* Whether the logged-in user already has a pending join-request to a given
* collective coordinate (so the button can show "Requested").
*/
export function useMyCollectiveJoinRequest(collectiveCoord: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const { user } = useCurrentUser();
return useQuery({
queryKey: ['collective-join-requests', 'mine', collectiveCoord, user?.pubkey, getQueryKeySuffix()],
enabled: !!collectiveCoord && !!user?.pubkey,
staleTime: 30_000,
queryFn: async (): Promise<boolean> => {
if (!collectiveCoord || !user?.pubkey) return false;
const signal = AbortSignal.timeout(8000);
const d = collectiveJoinRequestD(collectiveCoord, user.pubkey.toLowerCase());
const events = await querySkateContent(
[{ kinds: [COLLECTIVE_JOIN_REQUEST_KIND], authors: [user.pubkey], '#d': [d], limit: 1 }],
{ signal },
);
return events.length > 0;
},
});
}