SkateSpots/src/hooks/useEventUpdates.ts
shakespeare.diy 854d18c322 Give admins moderator control over events + fix a spoofing hole
Admins (the two pubkeys in src/lib/admins.ts) can now moderate any event:
- Post authoritative Event Updates (incl. cancel/reschedule) on events they don't own,
  clearly labelled "Admin" and marked with an ["admin","true"] tag
- Take down any event (already wired via DeleteEntityDialog + isAdminPubkey)

SECURITY FIX: kind-7103 Event Updates are now only trusted from the event's host
(pubkey in the `a` coordinate) or a SkateSpots admin. Previously ANY npub could
publish a fake "CANCELLED" update — spoofing the detail-page banner and spamming
RSVPers' notification bells. Enforced in both parseEventUpdate() and
useActivityNotifications.

Note: admins intentionally CANNOT open the edit form on others' events — an
addressable event (31923) can only be replaced by the key that signed it; editing
under a different key would fork it, not edit it. Moderation is done via
authoritative updates + takedown instead.

Docs: NIP.md updated with the host/admin trust rule and the admin marker tag.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 14:40:17 +07:00

103 lines
3.5 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import {
EVENT_UPDATE_KIND,
parseEventUpdate,
type EventUpdate,
type EventUpdateKind,
type SkateEvent,
} from '@/lib/types';
import { eventCoord } from '@/hooks/useEventRsvps';
import { isAdminPubkey } from '@/lib/admins';
/**
* Host "Event Updates" (kind 7103) — a public broadcast pinned to a calendar
* event. Hosts use it to say "rain, moved to Sunday" / "meet at the back ledge".
* Everyone who RSVP'd is p-tagged so they get a notification; anyone browsing
* the event page also sees the update thread.
*/
export function useEventUpdates(coord: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['event-updates', coord, getQueryKeySuffix()],
enabled: !!coord,
staleTime: 30_000,
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
const events = await querySkateContent(
[{ kinds: [EVENT_UPDATE_KIND], '#a': [coord!], limit: 100 }],
{ signal },
);
const updates: EventUpdate[] = [];
for (const ev of events) {
const u = parseEventUpdate(ev);
if (u && u.eventCoord === coord) updates.push(u);
}
// Newest first.
return updates.sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/**
* Publish a host update. Only the event's own author should call this (the UI
* gates it). All `notifyPubkeys` (RSVPers) are p-tagged so they get pinged.
*/
export function usePostEventUpdate() {
const { mutateAsync: publish, isPending } = useNostrPublish();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (opts: {
event: SkateEvent;
message: string;
updateKind?: EventUpdateKind;
/** RSVPer pubkeys to notify (deduped, minus the host). */
notifyPubkeys?: string[];
}) => {
if (!user) throw new Error('You must be logged in to post an update.');
const isHost = user.pubkey === opts.event.pubkey;
const isAdmin = isAdminPubkey(user.pubkey);
if (!isHost && !isAdmin) {
throw new Error('Only the event host or a SkateSpots admin can post updates.');
}
const coord = eventCoord(opts.event);
const kind = opts.updateKind ?? 'update';
const notify = Array.from(
new Set((opts.notifyPubkeys ?? []).filter((pk) => pk && pk !== user.pubkey)),
);
const tags: string[][] = [
['a', coord],
['e', opts.event.id],
['update-kind', kind],
['t', 'skateboarding'],
['alt', `Update to a skate event: ${opts.event.title}`],
// Mark moderator actions so clients can show "Admin" instead of "Host".
// (An admin posting on an event they don't own is a moderation action.)
...(!isHost && isAdmin ? [['admin', 'true']] : []),
...notify.map((pk) => ['p', pk]),
];
return publish({
kind: EVENT_UPDATE_KIND,
content: opts.message.trim(),
tags,
});
},
onSuccess: (_data, variables) => {
const coord = eventCoord(variables.event);
queryClient.invalidateQueries({ queryKey: ['event-updates', coord] });
},
});
return { ...mutation, isPending: isPending || mutation.isPending };
}