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>
188 lines
7.2 KiB
TypeScript
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;
|
|
},
|
|
});
|
|
}
|