Extend admin takedown to Lab profiles and gear/marketplace listings

Completes the admin moderation coverage from the previous commit.

Lab profiles (coaches, filmers, editors, photographers, builders, artists,
graffiti, tattoo, trainers, musicians):
- useLabProfiles / useLabProfile and useCoaches / useCoach now filter out
  admin-hidden entries (list) and 404 hidden ones (detail) via the shared
  moderation layer. Added filterAdminHiddenMixed() for the mixed-kind Lab
  union (kind read from each item's event).
- CoachDetailPage (the shared Lab detail page) now shows the delete/takedown
  card to admins too, not just the owner, with admin-specific copy.
  DeleteEntityDialog already handles the owner-or-admin logic.

Gear / marketplace listings (NIP-99, kind 30402):
- useMarketplaceItems / useMarketplaceAtSpot filter out admin-hidden listings.
- GearSwapItemCard: admins get a "Remove (admin)" action in the card menu with
  a confirmation dialog that routes through useDeleteEntity (NIP-09 delete +
  hidden label). Report stays for non-owners.

SECURITY.md: updated the admin-moderation coverage list and auditor file map.
Build + lint pass, no console errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-18 19:17:16 +07:00
parent 13eece3a1e
commit 82a74e01e0
7 changed files with 161 additions and 23 deletions

View file

@ -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` (`<meta>` — 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` |

View file

@ -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 }:
</Badge>
)}
{/* 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) && (
<div className="absolute bottom-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -153,17 +192,31 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowReportDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
</DropdownMenuItem>
<DropdownMenuContent align="end" className="w-44">
{!isOwnListingCheck && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowReportDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowRemoveDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove (admin)
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -291,6 +344,37 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
targetEventId={item.id}
targetTitle={item.title}
/>
{/* Admin takedown confirmation */}
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent
className="bg-black border-2 border-red-500/50"
onClick={(e) => e.stopPropagation()}
>
<AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2">
<Trash2 className="h-5 w-5 text-red-500" />
Remove this listing? (admin)
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-gray-600" disabled={isRemoving}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleAdminRemove}
disabled={isRemoving}
className="bg-red-500 hover:bg-red-600 text-white"
>
{isRemoving ? 'Removing…' : 'Remove Listing'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}

View file

@ -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();

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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)) && (
<Card className="bg-black/90 border-2 border-red-500/30">
<CardHeader>
<CardTitle className="text-lg text-red-400 uppercase font-black">
Manage
{user.pubkey === profile.pubkey ? 'Manage' : 'Admin'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-gray-400">
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.`}
</p>
<DeleteEntityDialog
event={profile.event}