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,
|
"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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue