diff --git a/NIP.md b/NIP.md index 9c396dc..8fb6c72 100644 --- a/NIP.md +++ b/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::", "", ""], + ["a", "31923::"], ["e", ""], ["update-kind", "reschedule"], ["t", "skateboarding"], + ["admin", "true"], ["p", ""], ["p", ""], ["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::"] }`. +Clients query an event's updates with `{ kinds: [7103], "#a": ["31923::"] }`, then filter authors to `{host} ∪ {admins}`. ## Social Interaction diff --git a/src/components/EventUpdates.tsx b/src/components/EventUpdates.tsx index 16310c4..f85903a 100644 --- a/src/components/EventUpdates.tsx +++ b/src/components/EventUpdates.tsx @@ -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 }) {
{name} + {update.authorRole === 'admin' && ( + + Admin + + )} {update.updateKind !== 'update' && ( ('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 (
- {/* Composer — host only */} - {isHost && ( -
-
+ {/* Composer — host or SkateSpots admin */} + {canPost && ( +
+
- Post an update + {isHost ? 'Post an update' : 'Post an admin update'}
+ {!isHost && ( +

+ You're posting as a SkateSpots admin. It'll be clearly labelled as an admin + notice, not from the host. +

+ )}
{(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 ? : } Post @@ -190,7 +222,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) { ))}
) : ( - !isHost && ( + !canPost && (

No updates from the host yet. RSVP and you'll be notified if anything changes.

diff --git a/src/hooks/useActivityNotifications.ts b/src/hooks/useActivityNotifications.ts index bb58e53..c302c2d 100644 --- a/src/hooks/useActivityNotifications.ts +++ b/src/hooks/useActivityNotifications.ts @@ -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', diff --git a/src/hooks/useEventUpdates.ts b/src/hooks/useEventUpdates.ts index f2a4ad3..65955f9 100644 --- a/src/hooks/useEventUpdates.ts +++ b/src/hooks/useEventUpdates.ts @@ -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]), ]; diff --git a/src/lib/types.ts b/src/lib/types.ts index ddd97d4..a4f5f75 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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, };