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.`}