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>
501 lines
19 KiB
TypeScript
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;
|
|
},
|
|
});
|
|
}
|