diff --git a/SECURITY.md b/SECURITY.md index 5422f83..4b128cd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -213,6 +213,19 @@ intentionally *not* author-filtered. worst-case loss from a compromised connection. - Prefer wallets/flows that let you set a spending budget. +### Admin moderation / takedowns + +Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take +down any listing (spot, crew, tour, event, shop, …). A takedown publishes both +a NIP-09 kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation +label authored by the admin. The client-side layer (`useAdminModeration`) then +hides that content across the app, so takedowns are reliable even when a relay +won't honor a cross-author NIP-09 request. **Trust boundary:** only labels whose +`pubkey` is in the hardcoded admin list are honored — this is re-verified +client-side in `useAdminModeration` and is never derived from a `d` tag or any +attacker-controllable value. Auditors should confirm the admin list is the only +source of moderation authority. + ### Tooling - A custom ESLint rule (`eslint-rules/no-inline-script.js`) forbids inline @@ -251,6 +264,7 @@ oversights: | Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) | | Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` | | Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`, seed in `cashu-mnemonic` | `src/hooks/wallet/useCashu.ts`, `src/lib/platform.ts` | +| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts` | | CSP & security headers | `index.html` (`` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` | | Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) | | NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` | diff --git a/src/components/DeleteEntityDialog.tsx b/src/components/DeleteEntityDialog.tsx index 1e7a581..da5559a 100644 --- a/src/components/DeleteEntityDialog.tsx +++ b/src/components/DeleteEntityDialog.tsx @@ -13,6 +13,7 @@ import { import { useDeleteEntity } from '@/hooks/useDeleteEntity'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useToast } from '@/hooks/useToast'; +import { isAdminPubkey } from '@/lib/admins'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -53,15 +54,29 @@ export function DeleteEntityDialog({ const navigate = useNavigate(); const [open, setOpen] = useState(false); - // Only the owner can delete their own event. - if (!user || user.pubkey !== event.pubkey) return null; + if (!user) return null; + + const isOwner = user.pubkey === event.pubkey; + const isAdmin = isAdminPubkey(user.pubkey); + // The owner can delete their own event; a SkateSpots admin can take down any. + if (!isOwner && !isAdmin) return null; + + // An admin acting on someone else's content is a moderation takedown. + const adminTakedown = isAdmin && !isOwner; const handleDelete = async () => { try { - await deleteEntity({ event, reason: `Deleted ${entityLabel} "${entityName}"` }); + await deleteEntity({ + event, + reason: adminTakedown + ? `SkateSpots admin removed ${entityLabel} "${entityName}"` + : `Deleted ${entityLabel} "${entityName}"`, + }); toast({ - title: 'Deletion requested', - description: `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`, + title: adminTakedown ? 'Content removed' : 'Deletion requested', + description: adminTakedown + ? `This ${entityLabel} has been taken down and will be hidden across SkateSpots.` + : `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`, }); setOpen(false); if (redirectTo) navigate(redirectTo); @@ -89,7 +104,7 @@ export function DeleteEntityDialog({ className, )} > - {triggerText} + {adminTakedown ? `Remove (admin)` : triggerText} )} @@ -102,15 +117,24 @@ export function DeleteEntityDialog({
-

- This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d - have to create a new one. -

+ {adminTakedown ? ( +

+ As a SkateSpots admin you’re taking down this {entityLabel}. It will be hidden + across the app for everyone and a deletion request will be sent to relays. The + original author can’t restore it from here. +

+ ) : ( +

+ This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d + have to create a new one. +

+ )}

Nostr is decentralized, so deletion is best-effort: relays that honor NIP-09 will - drop it, but a relay that doesn’t may keep a copy. + drop it, but a relay that doesn’t may keep a copy. SkateSpots hides admin-removed + content in the app regardless.

diff --git a/src/hooks/useAdminModeration.ts b/src/hooks/useAdminModeration.ts new file mode 100644 index 0000000..be2d3c2 --- /dev/null +++ b/src/hooks/useAdminModeration.ts @@ -0,0 +1,107 @@ +import { useQuery } from '@tanstack/react-query'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { ADMIN_PUBKEYS } from '@/lib/admins'; +import { + MODERATION_LABEL_NAMESPACE, + MODERATION_LABEL_HIDDEN, +} from '@/lib/types'; + +/** + * App-wide admin moderation layer. + * + * SkateSpots admins (see src/lib/admins.ts) can take down ANY listing (spot, + * crew, tour, event, shop, Lab profile, …). Because a NIP-09 deletion request + * signed by a non-author is not honored by most relays, admin takedowns also + * publish a NIP-32 kind:1985 `hidden` label referencing the target. This hook + * fetches those admin labels so the client can hide the content everywhere, + * reliably, regardless of relay NIP-09 behavior. + * + * Security: only labels authored by a hardcoded ADMIN pubkey are honored, so a + * random user cannot hide someone else's listing. The label authorship is the + * trust boundary (see .agents/skills/nostr-security §3). + */ +export interface AdminHiddenSet { + /** Hidden addressable coordinates `kind:pubkey:d`. */ + coords: Set; + /** Hidden concrete event ids. */ + ids: Set; + /** True if either set contains anything (cheap guard for callers). */ + has: (opts: { coord?: string; id?: string }) => boolean; +} + +const EMPTY: AdminHiddenSet = { + coords: new Set(), + ids: new Set(), + has: () => false, +}; + +/** + * Drop admin-hidden entities from a parsed list. Works for any entity that + * carries `{ id, pubkey, identifier }` (spots, crews, tours, shops, events, …). + * `kind` is the entity's Nostr kind, used to build the `a` coordinate. + */ +export function filterAdminHidden< + T extends { id: string; pubkey: string; identifier: string }, +>(items: T[], kind: number, hidden: AdminHiddenSet): T[] { + if (hidden.coords.size === 0 && hidden.ids.size === 0) return items; + return items.filter( + (it) => + !hidden.has({ coord: `${kind}:${it.pubkey}:${it.identifier}`, id: it.id }), + ); +} + +export function useAdminModeration() { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + + const query = useQuery({ + queryKey: ['admin-moderation', getQueryKeySuffix()], + queryFn: async (): Promise => { + const signal = AbortSignal.timeout(8000); + + const events = await querySkateContent( + [ + { + kinds: [1985], + authors: ADMIN_PUBKEYS, + '#l': [MODERATION_LABEL_HIDDEN], + limit: 1000, + }, + ], + { signal }, + ); + + const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase())); + const coords = new Set(); + const ids = new Set(); + + for (const ev of events) { + // Defense in depth: re-verify author + namespace + label client-side. + if (!adminSet.has(ev.pubkey.toLowerCase())) continue; + const hasNamespace = ev.tags.some( + ([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE, + ); + const hasLabel = ev.tags.some( + ([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN, + ); + if (!hasNamespace || !hasLabel) continue; + + for (const [n, v] of ev.tags) { + if (n === 'a' && v) coords.add(v); + if (n === 'e' && v) ids.add(v); + } + } + + return { + coords, + ids, + has: ({ coord, id }) => + (!!coord && coords.has(coord)) || (!!id && ids.has(id)), + }; + }, + staleTime: 60 * 1000, + gcTime: 10 * 60 * 1000, + refetchOnMount: true, + }); + + return query.data ?? EMPTY; +} diff --git a/src/hooks/useCrews.ts b/src/hooks/useCrews.ts index 295e478..ad64df8 100644 --- a/src/hooks/useCrews.ts +++ b/src/hooks/useCrews.ts @@ -8,12 +8,15 @@ import { type CrewFocus, } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; export function useCrews(limit = 200) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-crews', limit, getQueryKeySuffix()], + select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(10000); @@ -50,9 +53,11 @@ export function useCrews(limit = 200) { export function useCrewsByFocus(focus: Exclude, limit = 30) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const focusTag = CREW_FOCUS_TAG[focus]; + const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()], + select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(10000); @@ -84,9 +89,14 @@ export function useCrewsByFocus(focus: Exclude, limit = 30) export function useCrew(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-crew', identifier, pubkey, getQueryKeySuffix()], + select: (crew: SkateCrew | null) => + crew && hidden.has({ coord: `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`, id: crew.id }) + ? null + : crew, queryFn: async () => { const signal = AbortSignal.timeout(10000); diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts index 2a41cec..a2c8068 100644 --- a/src/hooks/useDeleteEntity.ts +++ b/src/hooks/useDeleteEntity.ts @@ -1,23 +1,34 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { isAdminPubkey } from '@/lib/admins'; +import { + MODERATION_LABEL_NAMESPACE, + MODERATION_LABEL_HIDDEN, +} from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; /** - * NIP-09 deletion request for an ADDRESSABLE event (kinds 30000–39999) such as - * a skate crew, a Lab profile, a spot, etc. + * Deletion / takedown for an ADDRESSABLE event (kinds 30000–39999) such as a + * skate crew, a Lab profile, a spot, a tour, an event, etc. * - * How deletion works on Nostr (important to set expectations): - * • We publish a kind:5 "deletion request" that references the target event by - * its `a` coordinate (kind:pubkey:d-tag) and its `e` id + `k` kind. - * • Relays that honor NIP-09 stop serving the referenced event and won't - * return it in future queries. This is how "delete" works in a - * decentralized network — there's no central database row to remove. - * • A relay that ignores NIP-09 may still serve a cached copy, and other - * users' relays may retain it. So deletion is best-effort, not a guarantee. + * Two callers, two mechanisms: * - * Only the ORIGINAL AUTHOR can delete their own event — a deletion request is - * only valid when signed by the same pubkey that created the target. + * 1. The ORIGINAL AUTHOR deleting their own listing → NIP-09 kind:5 deletion + * request. Relays that honor NIP-09 stop serving the event. A deletion + * request is only valid when signed by the pubkey that created the target, + * so an author's request is respected network-wide. + * + * 2. A SkateSpots ADMIN (see src/lib/admins.ts) taking down someone else's + * content → we ALSO publish a kind:5 (honored by the community relay for + * its own stored events) AND a NIP-32 kind:1985 `hidden` label referencing + * the target coordinate. The label is authored by the admin, so the app's + * client-side moderation layer (useAdminModeration) can hide the content + * everywhere regardless of whether a given relay honors a cross-author + * NIP-09 request. This makes admin takedowns reliable inside the app. + * + * In all cases deletion on a decentralized network is best-effort: a relay that + * ignores NIP-09 may keep a cached copy, and other users' relays may retain it. */ export function useDeleteEntity() { const { mutateAsync: publishEvent } = useNostrPublish(); @@ -35,7 +46,10 @@ export function useDeleteEntity() { reason?: string; }) => { if (!user) throw new Error('You must be logged in to delete.'); - if (user.pubkey !== event.pubkey) { + + const isOwner = user.pubkey === event.pubkey; + const isAdmin = isAdminPubkey(user.pubkey); + if (!isOwner && !isAdmin) { throw new Error('You can only delete your own listing.'); } @@ -44,17 +58,37 @@ export function useDeleteEntity() { // NIP-09: reference by both the addressable coordinate (`a`) and the // concrete event id (`e`), plus the kind (`k`), for maximum relay support. - const tags: string[][] = [ + const deletionTags: string[][] = [ ['a', coord], ['e', event.id], ['k', String(event.kind)], ]; - return publishEvent({ + await publishEvent({ kind: 5, content: reason?.trim() || '', - tags, + tags: deletionTags, }); + + // Admin takedown of someone else's content: also publish a client-side + // moderation label so the app hides it even if relays won't honor a + // cross-author NIP-09 deletion. + if (isAdmin && !isOwner) { + const labelTags: string[][] = [ + ['L', MODERATION_LABEL_NAMESPACE], + ['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE], + ['a', coord], + ['e', event.id], + ['k', String(event.kind)], + ['p', event.pubkey], + ['alt', `SkateSpots admin takedown${reason?.trim() ? `: ${reason.trim()}` : ''}`], + ]; + await publishEvent({ + kind: 1985, + content: reason?.trim() || 'Removed by SkateSpots admin', + tags: labelTags, + }); + } }, onSuccess: () => { // Nudge every list/detail query to refetch so the deleted entity drops out. diff --git a/src/hooks/useSkateEvents.ts b/src/hooks/useSkateEvents.ts index df39abd..7df88a4 100644 --- a/src/hooks/useSkateEvents.ts +++ b/src/hooks/useSkateEvents.ts @@ -1,12 +1,15 @@ import { useQuery } from '@tanstack/react-query'; import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; export function useSkateEvents(limit = 50) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-events', limit, getQueryKeySuffix()], + select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(15000); @@ -41,10 +44,12 @@ export function useSkateEvents(limit = 50) { export function useUpcomingSkateEvents(limit = 20) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); const now = Math.floor(Date.now() / 1000); return useQuery({ queryKey: ['upcoming-skate-events', limit, getQueryKeySuffix()], + select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(15000); @@ -84,9 +89,14 @@ export function useUpcomingSkateEvents(limit = 20) { export function useSkateEvent(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-event', identifier, pubkey, getQueryKeySuffix()], + select: (evt: SkateEvent | null) => + evt && hidden.has({ coord: `${CALENDAR_EVENT_KIND}:${evt.pubkey}:${evt.identifier}`, id: evt.id }) + ? null + : evt, queryFn: async () => { const signal = AbortSignal.timeout(10000); diff --git a/src/hooks/useSkateShops.ts b/src/hooks/useSkateShops.ts index 09b2d66..bb6e849 100644 --- a/src/hooks/useSkateShops.ts +++ b/src/hooks/useSkateShops.ts @@ -1,15 +1,18 @@ import { useQuery } from '@tanstack/react-query'; import { SKATE_SHOP_KIND, parseSkateShop, type SkateShop } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; /** * Hook to fetch skate shops using the skate relay logic */ export function useSkateShops(limit: number = 100) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); - return useQuery({ + return useQuery({ queryKey: ['skate-shops', limit, getQueryKeySuffix()], + select: (shops: SkateShop[]) => filterAdminHidden(shops, SKATE_SHOP_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(10000); @@ -41,9 +44,14 @@ export function useSkateShops(limit: number = 100) { */ export function useSkateShop(pubkey: string | undefined, identifier: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); - return useQuery({ + return useQuery({ queryKey: ['skate-shop', pubkey, identifier, getQueryKeySuffix()], + select: (shop: SkateShop | null) => + shop && hidden.has({ coord: `${SKATE_SHOP_KIND}:${shop.pubkey}:${shop.identifier}`, id: shop.id }) + ? null + : shop, queryFn: async () => { if (!pubkey || !identifier) return null; diff --git a/src/hooks/useSkateSpots.ts b/src/hooks/useSkateSpots.ts index 2654fed..0454841 100644 --- a/src/hooks/useSkateSpots.ts +++ b/src/hooks/useSkateSpots.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache'; // Spots change infrequently — cache for 5 minutes, keep for 30 minutes @@ -10,9 +11,11 @@ const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs mo export function useSkateSpots(limit = 100) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-spots', limit, getQueryKeySuffix()], + select: (spots: SkateSpot[]) => filterAdminHidden(spots, SKATE_SPOT_KIND, hidden), queryFn: async () => { // If offline, return cached spots if (isOffline()) { @@ -60,9 +63,14 @@ export function useSkateSpots(limit = 100) { export function useSkateSpot(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()], + select: (spot: SkateSpot | null) => + spot && hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id }) + ? null + : spot, queryFn: async () => { // If offline, try to get from cache first if (isOffline() && pubkey) { diff --git a/src/hooks/useTours.ts b/src/hooks/useTours.ts index c6c7ab9..78dfbb6 100644 --- a/src/hooks/useTours.ts +++ b/src/hooks/useTours.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; /** Sort featured (Pro) tours first, then newest. */ function sortTours(a: SkateTour, b: SkateTour): number { @@ -16,9 +17,11 @@ const TIMEOUT = 8000; export function useTours(limit = 100) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-tours', limit, getQueryKeySuffix()], + select: (tours: SkateTour[]) => filterAdminHidden(tours, SKATE_TOUR_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(TIMEOUT); @@ -49,9 +52,14 @@ export function useTours(limit = 100) { export function useTour(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-tour', identifier, pubkey, getQueryKeySuffix()], + select: (tour: SkateTour | null) => + tour && hidden.has({ coord: `${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`, id: tour.id }) + ? null + : tour, queryFn: async () => { const signal = AbortSignal.timeout(TIMEOUT); diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index f66c7cc..ab906e7 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { ZapButton } from '@/components/ZapButton'; import { CommentsSection } from '@/components/comments/CommentsSection'; +import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { useSkateEvent } from '@/hooks/useSkateEvents'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -194,14 +195,22 @@ export default function EventDetailPage() { return (
- {/* Back link */} - - - Back to Events - + {/* Back link + owner/admin controls */} +
+ + + Back to Events + + +
{/* Main Content */} diff --git a/src/pages/ShopDetailPage.tsx b/src/pages/ShopDetailPage.tsx index 17d522d..52cdd02 100644 --- a/src/pages/ShopDetailPage.tsx +++ b/src/pages/ShopDetailPage.tsx @@ -17,7 +17,9 @@ import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/saniti import { OfficialBadge } from '@/components/OfficialBadge'; import { useShop } from '@/hooks/useShops'; import { useAuthor } from '@/hooks/useAuthor'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useDeleteEntity } from '@/hooks/useDeleteEntity'; +import { isAdminPubkey } from '@/lib/admins'; +import { cn } from '@/lib/utils'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import NotFound from './NotFound'; @@ -78,13 +80,17 @@ export default function ShopDetailPage() { const { data: shop, isLoading, error } = useShop(identifier || '', pubkey); const author = useAuthor(shop?.pubkey); const { user } = useCurrentUser(); - const { mutateAsync: publishEvent } = useNostrPublish(); + const { mutateAsync: deleteEntity } = useDeleteEntity(); const { toast } = useToast(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const isOwner = user && shop && user.pubkey === shop.pubkey; + const isAdmin = isAdminPubkey(user?.pubkey); + // Owner can delete their own shop; a SkateSpots admin can take down any shop. + const canDelete = !!user && !!shop && (isOwner || isAdmin); + const adminTakedown = isAdmin && !isOwner; const hasNip05 = user?.metadata?.nip05; useSeoMeta({ @@ -144,19 +150,19 @@ export default function ShopDetailPage() { if (!shop || !user) return; try { - await publishEvent({ - kind: 5, - content: 'Deleted shop', - tags: [ - ['e', shop.id], - ['a', `${shop.event.kind}:${shop.pubkey}:${shop.identifier}`], - ], + // Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09 + // delete + a moderation label so the shop is hidden across the app. + await deleteEntity({ + event: shop.event, + reason: adminTakedown ? `SkateSpots admin removed shop "${shop.name}"` : 'Deleted shop', }); setShowDeleteDialog(false); toast({ - title: 'Shop deleted', - description: 'Deletion request sent to relays. Most relays will remove this content.', + title: adminTakedown ? 'Shop removed' : 'Shop deleted', + description: adminTakedown + ? 'This shop has been taken down and will be hidden across SkateSpots.' + : 'Deletion request sent to relays. Most relays will remove this content.', }); queryClient.invalidateQueries({ queryKey: ['skate-shops'] }); @@ -257,21 +263,25 @@ export default function ShopDetailPage() {
{isOwner && ( - <> - - - - - + + )} + {canDelete && ( + )}
{isOwner && ( - <> - - - - - + + )} + {canDelete && ( + )} {user && !isShop && ( <> @@ -971,10 +981,12 @@ export default function SpotDetailPage() { - Delete Spot? + {adminTakedown ? 'Remove this spot? (admin)' : 'Delete Spot?'} - This will permanently delete "{spot.title}" from all relays. This action cannot be undone. + {adminTakedown + ? `As a SkateSpots admin you're taking down "${spot.title}". It will be hidden across the app for everyone and a deletion request will be sent to relays.` + : `This will permanently delete "${spot.title}" from all relays. This action cannot be undone.`} @@ -983,7 +995,7 @@ export default function SpotDetailPage() { onClick={handleDelete} className="bg-red-500 hover:bg-red-600 text-white" > - Delete Spot + {adminTakedown ? 'Remove Spot' : 'Delete Spot'} diff --git a/src/pages/TourDetailPage.tsx b/src/pages/TourDetailPage.tsx index 0f48eee..ab5558f 100644 --- a/src/pages/TourDetailPage.tsx +++ b/src/pages/TourDetailPage.tsx @@ -13,6 +13,7 @@ import { genUserName } from '@/lib/genUserName'; import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, SKATE_TOUR_KIND } from '@/lib/types'; import { ZapButton } from '@/components/ZapButton'; import { ProBadge } from '@/components/ProBadge'; +import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { ArrowLeft, Route, @@ -124,11 +125,19 @@ export default function TourDetailPage() { return (
- {/* Back link */} - - - Back to Map - + {/* Back link + owner/admin controls */} +
+ + + Back to Map + + +
{/* Main card */}