diff --git a/SECURITY.md b/SECURITY.md index 4b128cd..1a72950 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -216,11 +216,14 @@ intentionally *not* author-filtered. ### 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 +down any listing — spots, crews, tours, events, shops, Lab profiles (coaches, +filmers, editors, photographers, builders, artists, graffiti, tattoo, trainers, +musicians), and gear/marketplace listings. 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`, wired into +every list/detail hook via React Query `select`) 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 @@ -264,7 +267,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` | +| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts`, `src/components/DeleteEntityDialog.tsx`, `src/components/GearSwapItemCard.tsx` | | 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/GearSwapItemCard.tsx b/src/components/GearSwapItemCard.tsx index 9ec8572..5852553 100644 --- a/src/components/GearSwapItemCard.tsx +++ b/src/components/GearSwapItemCard.tsx @@ -8,6 +8,9 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useDeleteEntity } from '@/hooks/useDeleteEntity'; +import { useToast } from '@/hooks/useToast'; +import { isAdminPubkey } from '@/lib/admins'; import { genUserName } from '@/lib/genUserName'; import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating'; import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, formatPrice, type MarketplaceItem } from '@/lib/types'; @@ -21,6 +24,7 @@ import { AlertTriangle, MoreVertical, Flag, + Trash2, } from 'lucide-react'; import { DropdownMenu, @@ -28,6 +32,16 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { ReportDialog } from '@/components/ReportDialog'; import { formatDistanceToNow } from 'date-fns'; @@ -42,13 +56,37 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }: const { user } = useCurrentUser(); const navigate = useNavigate(); const author = useAuthor(item.pubkey); + const { mutateAsync: deleteEntity, isPending: isRemoving } = useDeleteEntity(); + const { toast } = useToast(); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(item.pubkey); const avatar = metadata?.picture; const [showReportDialog, setShowReportDialog] = useState(false); + const [showRemoveDialog, setShowRemoveDialog] = useState(false); const npub = nip19.npubEncode(item.pubkey); const isOwnListingCheck = user?.pubkey === item.pubkey; + const isAdmin = isAdminPubkey(user?.pubkey); + + const handleAdminRemove = async () => { + try { + await deleteEntity({ + event: item.event, + reason: `SkateSpots admin removed listing "${item.title}"`, + }); + toast({ + title: 'Listing removed', + description: 'This listing has been taken down and will be hidden across SkateSpots.', + }); + setShowRemoveDialog(false); + } catch (error) { + toast({ + title: 'Remove failed', + description: error instanceof Error ? error.message : 'Something went wrong', + variant: 'destructive', + }); + } + }; // Format price display const priceDisplay = formatPrice(item.price, item.priceType); @@ -139,8 +177,9 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }: )} - {/* More options menu */} - {!isOwnListingCheck && ( + {/* More options menu — shown to non-owners (Report) and to admins + (admin takedown). Owners manage their own listing elsewhere. */} + {(!isOwnListingCheck || isAdmin) && (
@@ -153,17 +192,31 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }: - - { - e.stopPropagation(); - setShowReportDialog(true); - }} - className="text-red-500 focus:text-red-500" - > - - Report - + + {!isOwnListingCheck && ( + { + e.stopPropagation(); + setShowReportDialog(true); + }} + className="text-red-500 focus:text-red-500" + > + + Report + + )} + {isAdmin && ( + { + e.stopPropagation(); + setShowRemoveDialog(true); + }} + className="text-red-500 focus:text-red-500" + > + + Remove (admin) + + )}
@@ -291,6 +344,37 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }: targetEventId={item.id} targetTitle={item.title} /> + + {/* Admin takedown confirmation */} + + e.stopPropagation()} + > + + + + Remove this listing? (admin) + + + As a SkateSpots admin you're taking down "{item.title}". It will be hidden across the + app for everyone and a deletion request will be sent to relays. + + + + + Cancel + + + {isRemoving ? 'Removing…' : 'Remove Listing'} + + + + ); } diff --git a/src/hooks/useAdminModeration.ts b/src/hooks/useAdminModeration.ts index be2d3c2..4ff2b80 100644 --- a/src/hooks/useAdminModeration.ts +++ b/src/hooks/useAdminModeration.ts @@ -50,6 +50,29 @@ export function filterAdminHidden< ); } +/** + * Like `filterAdminHidden`, but for a mixed-kind list where each item carries + * its own source `event` (e.g. Lab profiles, which span 10 different kinds). + * The kind is read from `it.event.kind`. + */ +export function filterAdminHiddenMixed< + T extends { + id: string; + pubkey: string; + identifier: string; + event: { kind: number }; + }, +>(items: T[], hidden: AdminHiddenSet): T[] { + if (hidden.coords.size === 0 && hidden.ids.size === 0) return items; + return items.filter( + (it) => + !hidden.has({ + coord: `${it.event.kind}:${it.pubkey}:${it.identifier}`, + id: it.id, + }), + ); +} + export function useAdminModeration() { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); diff --git a/src/hooks/useCoaches.ts b/src/hooks/useCoaches.ts index 2b88071..8536c18 100644 --- a/src/hooks/useCoaches.ts +++ b/src/hooks/useCoaches.ts @@ -1,12 +1,15 @@ import { useQuery } from '@tanstack/react-query'; import { SKATE_COACH_KIND, parseSkateCoach, type SkateCoach } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; export function useCoaches(limit = 100) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['coaches', limit, getQueryKeySuffix()], + select: (coaches: SkateCoach[]) => filterAdminHidden(coaches, SKATE_COACH_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(10000); @@ -42,9 +45,14 @@ export function useCoaches(limit = 100) { export function useCoach(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['coach', identifier, pubkey, getQueryKeySuffix()], + select: (coach: SkateCoach | null) => + coach && hidden.has({ coord: `${SKATE_COACH_KIND}:${coach.pubkey}:${coach.identifier}`, id: coach.id }) + ? null + : coach, queryFn: async () => { const signal = AbortSignal.timeout(10000); diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index 634c52d..93eed36 100644 --- a/src/hooks/useLabProfiles.ts +++ b/src/hooks/useLabProfiles.ts @@ -10,6 +10,7 @@ import { } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration'; export type LabProfileType = | 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder' @@ -82,9 +83,11 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null { export function useLabProfiles(limit = 100) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['lab-profiles', limit, getQueryKeySuffix()], + select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden), queryFn: async () => { const signal = AbortSignal.timeout(15000); @@ -120,9 +123,17 @@ export function useLabProfiles(limit = 100) { export function useLabProfile(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['lab-profile', identifier, pubkey, getQueryKeySuffix()], + select: (profile: LabProfile | null) => + profile && hidden.has({ + coord: `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`, + id: profile.id, + }) + ? null + : profile, queryFn: async () => { const signal = AbortSignal.timeout(10000); diff --git a/src/hooks/useMarketplace.ts b/src/hooks/useMarketplace.ts index 9822b48..bc1aaa4 100644 --- a/src/hooks/useMarketplace.ts +++ b/src/hooks/useMarketplace.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { MARKETPLACE_ITEM_KIND, parseMarketplaceItem, type MarketplaceItem } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; const STALE_TIME = 5 * 60 * 1000; // 5 min const GC_TIME = 30 * 60 * 1000; // 30 min @@ -8,9 +9,11 @@ const TIMEOUT = 8000; export function useMarketplaceItems(limit = 100) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['marketplace-items', limit, getQueryKeySuffix()], + select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(TIMEOUT); @@ -41,9 +44,11 @@ export function useMarketplaceItems(limit = 100) { export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string, limit = 20) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); return useQuery({ queryKey: ['marketplace-at-spot', spotPubkey, spotIdentifier, getQueryKeySuffix()], + select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(TIMEOUT); diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index e5c88af..595f61b 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -18,6 +18,7 @@ import { useGigs } from '@/hooks/useGigs'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; +import { isAdminPubkey } from '@/lib/admins'; import { genUserName } from '@/lib/genUserName'; import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; @@ -575,17 +576,20 @@ export default function CoachDetailPage() { accentBg={`${accent.bg} hover:opacity-90`} /> - {/* Owner controls — delete your own Lab profile */} - {user && user.pubkey === profile.pubkey && ( + {/* Owner controls — delete your own Lab profile; admins can take + down any profile (DeleteEntityDialog gates owner-or-admin). */} + {user && (user.pubkey === profile.pubkey || isAdminPubkey(user.pubkey)) && ( - Manage + {user.pubkey === profile.pubkey ? 'Manage' : 'Admin'}

- Remove your {typeNoun} profile from The Lab. + {user.pubkey === profile.pubkey + ? `Remove your ${typeNoun} profile from The Lab.` + : `Take down this ${typeNoun} profile. It will be hidden across SkateSpots.`}