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>
This commit is contained in:
shakespeare.diy 2026-09-03 14:40:17 +07:00
parent 25c57cbb2f
commit 854d18c322
5 changed files with 85 additions and 12 deletions

8
NIP.md
View file

@ -389,10 +389,11 @@ Hosts broadcast changes ("rain — moved to Sunday", "meet at the back ledge") w
"kind": 7103, "kind": 7103,
"content": "Rain — moved to Sunday 2pm. Meet at the back ledge.", "content": "Rain — moved to Sunday 2pm. Meet at the back ledge.",
"tags": [ "tags": [
["a", "31923:<event-author-pubkey>:<event-d>", "", ""], ["a", "31923:<event-author-pubkey>:<event-d>"],
["e", "<event-id>"], ["e", "<event-id>"],
["update-kind", "reschedule"], ["update-kind", "reschedule"],
["t", "skateboarding"], ["t", "skateboarding"],
["admin", "true"],
["p", "<rsvper-pubkey>"], ["p", "<rsvper-pubkey>"],
["p", "<rsvper-pubkey>"], ["p", "<rsvper-pubkey>"],
["alt", "Update to a skate event"] ["alt", "Update to a skate event"]
@ -401,10 +402,11 @@ Hosts broadcast changes ("rain — moved to Sunday", "meet at the back ledge") w
``` ```
- `update-kind` is one of `update` (default), `reschedule`, `relocation`, or `cancelled` — used for iconography/emphasis. - `update-kind` is one of `update` (default), `reschedule`, `relocation`, or `cancelled` — used for iconography/emphasis.
- Only the event's own author should post updates (clients enforce this). - **Trust rule (required):** an update is only honored if its author is **either** the event's own host (the pubkey embedded in the `a` coordinate) **or** a designated SkateSpots admin. Updates from any other pubkey MUST be discarded — otherwise a stranger could forge a fake "cancelled" notice. Clients enforce this both when rendering the update thread and when raising notifications.
- `["admin", "true"]` marks a moderation action posted by an admin on an event they don't own. Clients render such updates with an explicit "Admin" label so they are never mistaken for the host.
- When a host edits an event's **time or location**, the client auto-publishes a `reschedule`/`relocation` update to notify RSVPers. - When a host edits an event's **time or location**, the client auto-publishes a `reschedule`/`relocation` update to notify RSVPers.
Clients query an event's updates with `{ kinds: [7103], "#a": ["31923:<host>:<d>"] }`. Clients query an event's updates with `{ kinds: [7103], "#a": ["31923:<host>:<d>"] }`, then filter authors to `{host} ∪ {admins}`.
## Social Interaction ## Social Interaction

View file

@ -10,6 +10,7 @@ import { useToast } from '@/hooks/useToast';
import { useEventUpdates, usePostEventUpdate } from '@/hooks/useEventUpdates'; import { useEventUpdates, usePostEventUpdate } from '@/hooks/useEventUpdates';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { EventUpdate, EventUpdateKind, SkateEvent } from '@/lib/types'; import type { EventUpdate, EventUpdateKind, SkateEvent } from '@/lib/types';
@ -51,6 +52,11 @@ function UpdateItem({ update }: { update: EventUpdate }) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-bold text-white">{name}</span> <span className="text-sm font-bold text-white">{name}</span>
{update.authorRole === 'admin' && (
<span className="rounded-full border border-yellow-500/40 bg-yellow-500/15 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide text-yellow-400">
Admin
</span>
)}
{update.updateKind !== 'update' && ( {update.updateKind !== 'update' && (
<span <span
className={cn( className={cn(
@ -90,6 +96,9 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
const [kind, setKind] = useState<EventUpdateKind>('update'); const [kind, setKind] = useState<EventUpdateKind>('update');
const isHost = user?.pubkey === event.pubkey; const isHost = user?.pubkey === event.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
// Admins can moderate any event; the host runs their own.
const canPost = isHost || isAdmin;
const handlePost = async () => { const handlePost = async () => {
const text = message.trim(); const text = message.trim();
@ -121,13 +130,31 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Composer — host only */} {/* Composer — host or SkateSpots admin */}
{isHost && ( {canPost && (
<div className="rounded-xl border border-[#ccff00]/25 bg-[#ccff00]/[0.04] p-3"> <div
<div className="mb-2 flex items-center gap-2 text-sm font-black uppercase tracking-wide text-[#ccff00]"> className={cn(
'rounded-xl border p-3',
isHost
? 'border-[#ccff00]/25 bg-[#ccff00]/[0.04]'
: 'border-yellow-500/30 bg-yellow-500/[0.05]',
)}
>
<div
className={cn(
'mb-2 flex items-center gap-2 text-sm font-black uppercase tracking-wide',
isHost ? 'text-[#ccff00]' : 'text-yellow-400',
)}
>
<Megaphone className="h-4 w-4" /> <Megaphone className="h-4 w-4" />
Post an update {isHost ? 'Post an update' : 'Post an admin update'}
</div> </div>
{!isHost && (
<p className="mb-2 text-[11px] text-yellow-200/80">
You're posting as a SkateSpots admin. It'll be clearly labelled as an admin
notice, not from the host.
</p>
)}
<div className="mb-2 flex flex-wrap gap-1.5"> <div className="mb-2 flex flex-wrap gap-1.5">
{(Object.keys(UPDATE_KIND_META) as EventUpdateKind[]).map((k) => { {(Object.keys(UPDATE_KIND_META) as EventUpdateKind[]).map((k) => {
const m = UPDATE_KIND_META[k]; const m = UPDATE_KIND_META[k];
@ -169,7 +196,12 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
size="sm" size="sm"
onClick={handlePost} onClick={handlePost}
disabled={isPending || !message.trim()} disabled={isPending || !message.trim()}
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" className={cn(
'gap-1.5 font-bold text-black',
isHost
? 'bg-[#ccff00] hover:bg-[#ccff00]/90'
: 'bg-yellow-400 hover:bg-yellow-400/90',
)}
> >
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} {isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
Post Post
@ -190,7 +222,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
))} ))}
</div> </div>
) : ( ) : (
!isHost && ( !canPost && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No updates from the host yet. RSVP and you'll be notified if anything changes. No updates from the host yet. RSVP and you'll be notified if anything changes.
</p> </p>

View file

@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins';
/** /**
* General, app-wide activity notifications for the logged-in user. * General, app-wide activity notifications for the logged-in user.
@ -193,6 +194,12 @@ export function useActivityNotifications() {
}); });
} else if (event.kind === EVENT_UPDATE_KIND) { } else if (event.kind === EVENT_UPDATE_KIND) {
// A host posted an update to an event I RSVP'd to (I'm p-tagged). // A host posted an update to an event I RSVP'd to (I'm p-tagged).
// SECURITY: only trust it if the author is the event's host (from the
// `a` coordinate) or a SkateSpots admin — otherwise a stranger could
// spam fake "event cancelled" notifications by p-tagging RSVPers.
const updateCoord = event.tags.find(([t]) => t === 'a')?.[1];
const updateHost = updateCoord?.split(':')[1];
if (event.pubkey !== updateHost && !isAdminPubkey(event.pubkey)) continue;
out.push({ out.push({
id: event.id, id: event.id,
type: 'event-update', type: 'event-update',

View file

@ -10,6 +10,7 @@ import {
type SkateEvent, type SkateEvent,
} from '@/lib/types'; } from '@/lib/types';
import { eventCoord } from '@/hooks/useEventRsvps'; import { eventCoord } from '@/hooks/useEventRsvps';
import { isAdminPubkey } from '@/lib/admins';
/** /**
* Host "Event Updates" (kind 7103) — a public broadcast pinned to a calendar * Host "Event Updates" (kind 7103) — a public broadcast pinned to a calendar
@ -61,8 +62,10 @@ export function usePostEventUpdate() {
notifyPubkeys?: string[]; notifyPubkeys?: string[];
}) => { }) => {
if (!user) throw new Error('You must be logged in to post an update.'); if (!user) throw new Error('You must be logged in to post an update.');
if (user.pubkey !== opts.event.pubkey) { const isHost = user.pubkey === opts.event.pubkey;
throw new Error('Only the event host can post updates.'); 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 coord = eventCoord(opts.event);
@ -78,6 +81,9 @@ export function usePostEventUpdate() {
['update-kind', kind], ['update-kind', kind],
['t', 'skateboarding'], ['t', 'skateboarding'],
['alt', `Update to a skate event: ${opts.event.title}`], ['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]), ...notify.map((pk) => ['p', pk]),
]; ];

View file

@ -1,6 +1,7 @@
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import ngeohash from 'ngeohash'; import ngeohash from 'ngeohash';
import { parseCrewVideo } from '@/lib/videoEmbed'; import { parseCrewVideo } from '@/lib/videoEmbed';
import { isAdminPubkey } from '@/lib/admins';
// Community relay for all skate-specific content (spots, events, coaches, shops, etc.) // Community relay for all skate-specific content (spots, events, coaches, shops, etc.)
// This ensures all users see the same content regardless of their personal relay settings // This ensures all users see the same content regardless of their personal relay settings
@ -970,6 +971,14 @@ export interface EventUpdate {
message: string; message: string;
/** Category of update, for iconography / emphasis. */ /** Category of update, for iconography / emphasis. */
updateKind: EventUpdateKind; updateKind: EventUpdateKind;
/**
* Who this update is authoritatively from, relative to the event it targets:
* - 'host' → signed by the event's own author (coordinate pubkey)
* - 'admin' → signed by a SkateSpots admin acting as a moderator
* Any other author is untrusted and MUST be discarded by callers (a random
* npub could otherwise forge a fake "CANCELLED" update).
*/
authorRole: 'host' | 'admin';
createdAt: number; createdAt: number;
event: NostrEvent; event: NostrEvent;
} }
@ -977,6 +986,10 @@ export interface EventUpdate {
/** /**
* Parse a host Event Update (kind 7103). Requires an `a` coordinate to a * Parse a host Event Update (kind 7103). Requires an `a` coordinate to a
* calendar event and non-empty content. * calendar event and non-empty content.
*
* SECURITY: the update is only trusted if its author is either the event's own
* host (derived from the `a` coordinate) or a SkateSpots admin. Updates from any
* other pubkey return `null` so a stranger can't spoof a cancellation.
*/ */
export function parseEventUpdate(event: NostrEvent): EventUpdate | null { export function parseEventUpdate(event: NostrEvent): EventUpdate | null {
if (event.kind !== EVENT_UPDATE_KIND) return null; if (event.kind !== EVENT_UPDATE_KIND) return null;
@ -993,6 +1006,18 @@ export function parseEventUpdate(event: NostrEvent): EventUpdate | null {
? rawKind ? rawKind
: 'update'; : 'update';
// SECURITY: only trust updates from the event's host (the pubkey embedded in
// the `a` coordinate) or a SkateSpots admin. Anyone else is forging.
const hostPubkey = eventCoord.split(':')[1];
let authorRole: 'host' | 'admin';
if (event.pubkey === hostPubkey) {
authorRole = 'host';
} else if (isAdminPubkey(event.pubkey)) {
authorRole = 'admin';
} else {
return null;
}
return { return {
id: event.id, id: event.id,
pubkey: event.pubkey, pubkey: event.pubkey,
@ -1000,6 +1025,7 @@ export function parseEventUpdate(event: NostrEvent): EventUpdate | null {
eventId: event.tags.find(([name]) => name === 'e')?.[1], eventId: event.tags.find(([name]) => name === 'e')?.[1],
message: message.slice(0, MAX_DESCRIPTION_LEN), message: message.slice(0, MAX_DESCRIPTION_LEN),
updateKind, updateKind,
authorRole,
createdAt: event.created_at, createdAt: event.created_at,
event, event,
}; };