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:
parent
25c57cbb2f
commit
854d18c322
5 changed files with 85 additions and 12 deletions
8
NIP.md
8
NIP.md
|
|
@ -389,10 +389,11 @@ Hosts broadcast changes ("rain — moved to Sunday", "meet at the back ledge") w
|
|||
"kind": 7103,
|
||||
"content": "Rain — moved to Sunday 2pm. Meet at the back ledge.",
|
||||
"tags": [
|
||||
["a", "31923:<event-author-pubkey>:<event-d>", "", ""],
|
||||
["a", "31923:<event-author-pubkey>:<event-d>"],
|
||||
["e", "<event-id>"],
|
||||
["update-kind", "reschedule"],
|
||||
["t", "skateboarding"],
|
||||
["admin", "true"],
|
||||
["p", "<rsvper-pubkey>"],
|
||||
["p", "<rsvper-pubkey>"],
|
||||
["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.
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { useEventUpdates, usePostEventUpdate } from '@/hooks/useEventUpdates';
|
||||
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
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="flex items-center gap-2">
|
||||
<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' && (
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -90,6 +96,9 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
const [kind, setKind] = useState<EventUpdateKind>('update');
|
||||
|
||||
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 text = message.trim();
|
||||
|
|
@ -121,13 +130,31 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Composer — host only */}
|
||||
{isHost && (
|
||||
<div className="rounded-xl border border-[#ccff00]/25 bg-[#ccff00]/[0.04] p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">
|
||||
{/* Composer — host or SkateSpots admin */}
|
||||
{canPost && (
|
||||
<div
|
||||
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" />
|
||||
Post an update
|
||||
{isHost ? 'Post an update' : 'Post an admin update'}
|
||||
</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">
|
||||
{(Object.keys(UPDATE_KIND_META) as EventUpdateKind[]).map((k) => {
|
||||
const m = UPDATE_KIND_META[k];
|
||||
|
|
@ -169,7 +196,12 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
size="sm"
|
||||
onClick={handlePost}
|
||||
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" />}
|
||||
Post
|
||||
|
|
@ -190,7 +222,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
!isHost && (
|
||||
!canPost && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No updates from the host yet. RSVP and you'll be notified if anything changes.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
|
||||
/**
|
||||
* General, app-wide activity notifications for the logged-in user.
|
||||
|
|
@ -193,6 +194,12 @@ export function useActivityNotifications() {
|
|||
});
|
||||
} else if (event.kind === EVENT_UPDATE_KIND) {
|
||||
// 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({
|
||||
id: event.id,
|
||||
type: 'event-update',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
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
|
||||
|
|
@ -61,8 +62,10 @@ export function usePostEventUpdate() {
|
|||
notifyPubkeys?: string[];
|
||||
}) => {
|
||||
if (!user) throw new Error('You must be logged in to post an update.');
|
||||
if (user.pubkey !== opts.event.pubkey) {
|
||||
throw new Error('Only the event host can post updates.');
|
||||
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);
|
||||
|
|
@ -78,6 +81,9 @@ export function usePostEventUpdate() {
|
|||
['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]),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import ngeohash from 'ngeohash';
|
||||
import { parseCrewVideo } from '@/lib/videoEmbed';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
|
||||
// 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
|
||||
|
|
@ -970,6 +971,14 @@ export interface EventUpdate {
|
|||
message: string;
|
||||
/** Category of update, for iconography / emphasis. */
|
||||
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;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
|
@ -977,6 +986,10 @@ export interface EventUpdate {
|
|||
/**
|
||||
* Parse a host Event Update (kind 7103). Requires an `a` coordinate to a
|
||||
* 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 {
|
||||
if (event.kind !== EVENT_UPDATE_KIND) return null;
|
||||
|
|
@ -993,6 +1006,18 @@ export function parseEventUpdate(event: NostrEvent): EventUpdate | null {
|
|||
? rawKind
|
||||
: '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 {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
|
|
@ -1000,6 +1025,7 @@ export function parseEventUpdate(event: NostrEvent): EventUpdate | null {
|
|||
eventId: event.tags.find(([name]) => name === 'e')?.[1],
|
||||
message: message.slice(0, MAX_DESCRIPTION_LEN),
|
||||
updateKind,
|
||||
authorRole,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue