feat(spots): claim ownership of a private spot (NIP-05 + moderator confirm)

Private spots can't be domain-verified like shops, so ownership is a gated
two-step flow — never an instant takeover:
- A NIP-05-verified user files a claim (kind 1985 label in the separate
  org.skatespots.spotclaim namespace) on a spot marked access=private.
- A moderator approves by publishing claim-approved (moderation namespace,
  a=coord + p=claimant); surfaced in a pending-claims list atop /review.
- The claimant then finishes by re-publishing the spot with
  claim-reason=private-approved (existing claim mechanism).

Adds useSpotClaims/useApprovedSpotClaims/useAllSpotClaims/useSubmitSpotClaim,
useAdminApproveClaim, the private branch in SpotOwnership, i18n, and docs.
Non-private spots keep the abandoned-claim flow. Nothing is auto-transferred.
This commit is contained in:
SkateSpots 2026-10-02 11:06:58 +07:00
parent 391eeb9bf9
commit 411f6e219a
9 changed files with 625 additions and 53 deletions

View file

@ -717,6 +717,17 @@ session with **no other memory of this project**. So:
stays optional, per the earlier decision. And the flattering **"Conditions …
NN/100"** block is **hidden until the spot is confirmed** (community or staff);
on an unconfirmed pin it read as endorsement of the *spot*.
- **Private-spot ownership claims (2026-10-02):** a private spot (`access=private`)
can't be domain-verified like a shop, so ownership is **NIP-05 + moderator
confirm**, never an instant takeover. Claimant (must have a NIP-05) publishes a
kind-1985 label in the **separate** `org.skatespots.spotclaim` namespace;
an admin publishes `claim-approved` (moderation namespace, `a`=coord + `p`=claimant);
the claimant then re-publishes the spot with `claim-reason=private-approved` to
become the owner. `useSpotClaims.ts` (`useSpotClaims`/`useApprovedSpotClaims`/
`useAllSpotClaims`/`useSubmitSpotClaim`) + `useAdminApproveClaim`; UI in
`SpotOwnership.tsx` (private branch) and a pending-claims list atop `/review`.
⚠️ Proves key control + NIP-05, **not property ownership**. Non-private spots
keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims".
- **Photo is recommended, not required (2026-09-28):** the hard block in
`CreateSpotPage` was removed — it punished an honest mapper with no photo on
hand. Publishing with no photo is allowed; the spot stays **Unverified**

37
NIP.md
View file

@ -3419,6 +3419,43 @@ Full design, threat model and status: **`ESCROW.md`**. Core logic lives in
`src/lib/cashuEscrow.ts` (unit-tested). Escrow is **native-only** (the Cashu
wallet is Capacitor-gated).
## Private-spot ownership claims
A private spot (`kind 30818` with `access=private`) can't be verified against a
domain the way a shop can, so ownership is a **two-step, moderator-gated claim** —
a stranger can never be auto-granted someone's pin.
1. **Claim (the claimant).** A logged-in user **with a NIP-05** publishes a
NIP-32 kind 1985 label of their own:
```json
{ "kind": 1985, "content": "<their nip05 handle>", "tags": [
["L", "org.skatespots.spotclaim"],
["l", "claim", "org.skatespots.spotclaim"],
["a", "30818:<owner-pubkey>:<d>"],
["p", "<owner-pubkey>"],
["alt", "SkateSpots private-spot ownership claim"]
] }
```
The namespace is **separate** from `org.skatespots.moderation`, so
`useAdminModeration` never reads a non-admin label as a verdict.
2. **Approve (a moderator).** An admin publishes `claim-approved` in the standard
moderation namespace, tagged with the spot coordinate **and** the claimant:
`["L","org.skatespots.moderation"]` · `["l","claim-approved",…]` ·
`["a","30818:<owner>:<d>"]` · `["p","<claimant>"]`.
3. **Finish (the claimant).** On approval the claimant re-publishes the spot
under their own key with `["claim-from","<owner>"]` and
`["claim-reason","private-approved"]` — the existing claim mechanism. They are
now the author/owner.
⚠️ Honest limits: a valid claim proves **who controls a Nostr key and passed
NIP-05** — NOT that they own the property. Approval is a human judgement. The
original spot stays on Nostr (decentralized); nothing is deleted. Only
**private** spots use this; non-private spots keep the abandoned-claim flow.
## Future (from the NIP)
- `31873` **Attestor Recommendation** / `11871` **Proficiency** — trusted-trader

View file

@ -1,5 +1,6 @@
import { useState, useMemo } from 'react';
import { UserPlus, AlertTriangle, Loader2, Crown, Clock } from 'lucide-react';
import { Link } from 'react-router-dom';
import { UserPlus, AlertTriangle, Loader2, Crown, Clock, ShieldCheck, Hourglass, BadgeCheck, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
@ -22,10 +23,14 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useSpotClaims, useApprovedSpotClaims, useSubmitSpotClaim } from '@/hooks/useSpotClaims';
import { useAdminApproveClaim } from '@/hooks/useAdminHide';
import { useQueryClient } from '@tanstack/react-query';
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
import { isAdminPubkey } from '@/lib/admins';
import { LoginArea } from '@/components/auth/LoginArea';
import { useTranslation } from '@/contexts/language';
import { nip19 } from 'nostr-tools';
@ -38,9 +43,12 @@ interface SpotOwnershipProps {
}
/**
* Component that handles:
* 1. Transfer ownership (owner can transfer to another user)
* 2. Claim abandoned spot (any user can claim if owner inactive 6+ months)
* Ownership controls for a spot:
* 1. Transfer — the owner hands it to someone else.
* 2. Claim an abandoned spot (owner inactive 6+ months) — any user.
* 3. Claim a PRIVATE spot — a signed claim (NIP-05 required) that a SkateSpots
* moderator must approve before the claimant finishes the transfer. We can't
* verify a property deed, so control is never auto-transferred to a stranger.
*/
export function SpotOwnership({ spot }: SpotOwnershipProps) {
const { t } = useTranslation();
@ -48,19 +56,34 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
const [transferOpen, setTransferOpen] = useState(false);
const [claimOpen, setClaimOpen] = useState(false);
const [privateClaimOpen, setPrivateClaimOpen] = useState(false);
const [recipientNpub, setRecipientNpub] = useState('');
const isOwner = user && user.pubkey === spot.pubkey;
const isOwner = !!user && user.pubkey === spot.pubkey;
const isPrivate = spot.accessType === 'private';
const isAdmin = isAdminPubkey(user?.pubkey);
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
const { data: claims = [] } = useSpotClaims(isPrivate ? coord : undefined);
const { data: approvals } = useApprovedSpotClaims();
const { submit: submitClaim, isPending: submittingClaim } = useSubmitSpotClaim();
const { approveClaim, isPending: approvingClaim } = useAdminApproveClaim();
const myAuthor = useAuthor(user?.pubkey ?? '');
const myNip05 = myAuthor.data?.metadata?.nip05;
const myClaim = user ? claims.find((c) => c.claimant === user.pubkey) : undefined;
const amApproved = !!user && !!approvals?.get(coord)?.has(user.pubkey.toLowerCase());
// Check if spot is abandoned (owner hasn't updated in 6+ months)
const isAbandoned = useMemo(() => {
const now = Math.floor(Date.now() / 1000);
return (now - spot.createdAt) > ABANDONED_THRESHOLD;
}, [spot.createdAt]);
// Calculate how long ago the spot was last updated
const lastUpdatedText = useMemo(() => {
const now = Math.floor(Date.now() / 1000);
@ -68,12 +91,12 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
const days = Math.floor(diff / (24 * 60 * 60));
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
if (years > 0) return years > 1 ? t('spotUi.yearsAgoPlural', { count: years }) : t('spotUi.yearsAgo', { count: years });
if (months > 0) return months > 1 ? t('spotUi.monthsAgoPlural', { count: months }) : t('spotUi.monthsAgo', { count: months });
return days > 1 ? t('spotUi.daysAgoPlural', { count: days }) : t('spotUi.daysAgo', { count: days });
}, [spot.createdAt, t]);
const handleTransfer = async () => {
if (!recipientNpub.trim()) {
toast({
@ -83,7 +106,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
});
return;
}
// Decode npub to get hex pubkey
let recipientPubkey: string;
try {
@ -100,7 +123,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
});
return;
}
if (recipientPubkey === spot.pubkey) {
toast({
title: t('spotUi.sameOwner'),
@ -109,25 +132,25 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
});
return;
}
try {
// Publish a transfer event (kind 1111 comment with transfer marker)
await publishEvent({
kind: 1111,
content: t('spotUi.transferRequestContent', { title: spot.title }),
tags: [
['a', `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`],
['a', coord],
['K', String(SKATE_SPOT_KIND)],
['t', 'spot-transfer'],
['p', recipientPubkey, '', 'new-owner'],
],
});
toast({
title: t('spotUi.transferRequested'),
description: t('spotUi.transferRequestedDesc'),
});
setTransferOpen(false);
setRecipientNpub('');
queryClient.invalidateQueries({ queryKey: ['spot-transfers', spot.pubkey, spot.identifier] });
@ -139,15 +162,14 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
});
}
};
const handleClaim = async () => {
/** Become the owner by re-publishing the spot under our own key. */
const handleClaim = async (reason: 'abandoned' | 'transfer' | 'private-approved') => {
if (!user) return;
try {
// Re-publish the spot under the new owner's pubkey
// This creates a new addressable event with the same d-tag but new owner
const eventTags: string[][] = [];
// Copy all original tags EXCEPT provenance markers we're about to (re)set.
// Without this filter, claiming a spot that was itself a prior claim would
// stack multiple claim-from/claim-reason pairs on the event.
@ -155,22 +177,21 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
if (tag[0] === 'claim-from' || tag[0] === 'claim-reason') continue;
eventTags.push([...tag]);
}
// Add a single, fresh claim reference pointing at the immediate prior owner.
eventTags.push(['claim-from', spot.pubkey]);
eventTags.push(['claim-reason', isAbandoned ? 'abandoned' : 'transfer']);
eventTags.push(['claim-reason', reason]);
await publishEvent({
kind: SKATE_SPOT_KIND,
content: spot.description,
tags: eventTags,
});
toast({
title: t('spotUi.spotClaimed'),
description: t('spotUi.spotClaimedDesc'),
});
setClaimOpen(false);
queryClient.invalidateQueries({ queryKey: ['skate-spots'] });
queryClient.invalidateQueries({ queryKey: ['skate-spot', spot.identifier] });
@ -182,12 +203,40 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
});
}
};
const handleSubmitPrivateClaim = async () => {
if (!myNip05) return;
try {
await submitClaim({ coord, ownerPubkey: spot.pubkey, nip05: myNip05 });
toast({ title: t('spotUi.claim.submitted'), description: t('spotUi.claim.submittedDesc') });
setPrivateClaimOpen(false);
} catch (error) {
toast({
title: t('spotUi.claim.submitFailed'),
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
variant: 'destructive',
});
}
};
const handleApproveClaim = async (claimant: string) => {
try {
await approveClaim({ coord, subject: claimant, reason: 'private-spot-claim' });
toast({ title: t('spotUi.claim.adminApproved'), description: t('spotUi.claim.adminApprovedDesc') });
} catch (error) {
toast({
title: t('spotUi.claim.adminApproveFailed'),
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
variant: 'destructive',
});
}
};
// Don't show anything for shops
if (spot.types.includes('shop')) {
return null;
}
return (
<>
{/* Owner: Transfer button */}
@ -209,7 +258,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
{t('spotUi.transferDesc', { title: spot.title })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('spotUi.newOwnerNpub')}</label>
@ -222,7 +271,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
{t('spotUi.newOwnerNpubHint')}
</p>
</div>
<Card className="bg-yellow-500/10 border-yellow-500/30">
<CardContent className="p-3">
<div className="flex gap-2 text-yellow-500">
@ -236,7 +285,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
</div>
</CardContent>
</Card>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setTransferOpen(false)}>
{t('common.cancel')}
@ -260,9 +309,146 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
</DialogContent>
</Dialog>
)}
{/* Non-owner: Claim abandoned spot */}
{!isOwner && isAbandoned && user && (
{/* ── PRIVATE SPOT — ownership claim (NIP-05 + moderator confirm) ────── */}
{!isOwner && isPrivate && (
<>
{/* Claim approved → finish */}
{user && amApproved && (
<Card className="bg-green-500/10 border-2 border-green-500/40">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<BadgeCheck className="h-6 w-6 text-green-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-green-400 uppercase text-sm mb-1">{t('spotUi.claim.approvedTitle')}</h3>
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.approvedDesc')}</p>
<Button
size="sm"
className="gap-2 bg-green-500 hover:bg-green-600 text-black font-bold"
onClick={() => handleClaim('private-approved')}
disabled={isPublishing}
>
{isPublishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Crown className="h-4 w-4" />}
{isPublishing ? t('spotUi.claiming') : t('spotUi.claim.complete')}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Claim pending review */}
{user && !amApproved && myClaim && (
<Card className="bg-blue-500/10 border-2 border-blue-500/40">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Hourglass className="h-6 w-6 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-blue-300 uppercase text-sm mb-1">{t('spotUi.claim.pendingTitle')}</h3>
<p className="text-xs text-gray-400">{t('spotUi.claim.pendingDesc')}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Can claim (NIP-05 present) */}
{user && !amApproved && !myClaim && myNip05 && (
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/40">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Lock className="h-6 w-6 text-[#ccff00] flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-[#ccff00] uppercase text-sm mb-1">{t('spotUi.claim.areYouOwner')}</h3>
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.privateDesc')}</p>
<Button
size="sm"
className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase"
onClick={() => setPrivateClaimOpen(true)}
>
<ShieldCheck className="h-4 w-4" />
{t('spotUi.claim.claimSpot')}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Logged in but no NIP-05 → nudge */}
{user && !amApproved && !myClaim && !myNip05 && (
<Card className="bg-yellow-500/10 border-2 border-yellow-500/30">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-6 w-6 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-yellow-400 uppercase text-sm mb-1">{t('spotUi.claim.areYouOwner')}</h3>
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.nip05Required')}</p>
<Link to="/settings?section=profile">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
{t('spotUi.claim.addNip05')}
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
)}
{/* Not logged in */}
{!user && (
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/40">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Lock className="h-6 w-6 text-[#ccff00] flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-[#ccff00] uppercase text-sm mb-1">{t('spotUi.claim.areYouOwner')}</h3>
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.loginRequired')}</p>
<LoginArea className="justify-start" />
</div>
</div>
</CardContent>
</Card>
)}
</>
)}
{/* ── Admin: pending private-spot claims ─────────────────────────────── */}
{isAdmin && isPrivate && claims.length > 0 && (
<Card className="bg-purple-500/10 border-2 border-purple-500/30">
<CardContent className="p-4">
<h3 className="font-bold text-purple-300 uppercase text-sm mb-3">{t('spotUi.claim.adminTitle')}</h3>
<div className="space-y-2">
{claims.map((c) => {
const approved = !!approvals?.get(coord)?.has(c.claimant.toLowerCase());
return (
<div key={c.claimant} className="flex items-center justify-between gap-2 text-xs">
<span className="text-gray-300 truncate font-mono">
{c.nip05 || `${nip19.npubEncode(c.claimant).slice(0, 12)}…`}
</span>
{approved ? (
<span className="text-green-400 font-bold uppercase shrink-0">{t('spotUi.claim.adminApproved')}</span>
) : (
<Button
size="sm"
className="h-7 gap-1.5 bg-purple-500 hover:bg-purple-600 text-white"
onClick={() => handleApproveClaim(c.claimant)}
disabled={approvingClaim}
>
{approvingClaim ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
{t('spotUi.claim.adminApprove')}
</Button>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Non-owner: Claim abandoned spot (non-private spots only) */}
{!isOwner && !isPrivate && isAbandoned && user && (
<Card className="bg-orange-500/10 border-2 border-orange-500/30">
<CardContent className="p-4">
<div className="flex items-start gap-3">
@ -287,9 +473,9 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
</CardContent>
</Card>
)}
{/* Not logged in: Show login for abandoned spots */}
{!isOwner && isAbandoned && !user && (
{/* Not logged in: abandoned non-private spots */}
{!isOwner && !isPrivate && isAbandoned && !user && (
<Card className="bg-orange-500/10 border-2 border-orange-500/30">
<CardContent className="p-4">
<div className="flex items-start gap-3">
@ -307,8 +493,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
</CardContent>
</Card>
)}
{/* Claim confirmation dialog */}
{/* Claim confirmation dialog (abandoned) */}
<AlertDialog open={claimOpen} onOpenChange={setClaimOpen}>
<AlertDialogContent>
<AlertDialogHeader>
@ -335,7 +521,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleClaim}
onClick={() => handleClaim(isAbandoned ? 'abandoned' : 'transfer')}
disabled={isPublishing}
className="bg-orange-500 hover:bg-orange-600"
>
@ -351,6 +537,47 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Claim confirmation dialog (private) */}
<AlertDialog open={privateClaimOpen} onOpenChange={setPrivateClaimOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-[#ccff00]" />
{t('spotUi.claim.confirmTitle', { title: spot.title })}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>{t('spotUi.claim.confirmIntro')}</p>
<div className="bg-muted p-3 rounded-lg text-sm">
<ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>{t('spotUi.claim.confirmBullet1')}</li>
<li>{t('spotUi.claim.confirmBullet2')}</li>
<li>{t('spotUi.claim.confirmBullet3')}</li>
</ul>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmitPrivateClaim}
disabled={submittingClaim}
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90"
>
{submittingClaim ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('spotUi.claim.submitting')}
</>
) : (
t('spotUi.claim.submit')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -8,6 +8,7 @@ import {
MODERATION_LABEL_HIDDEN,
MODERATION_LABEL_VERIFIED,
MODERATION_LABEL_CLEARED,
MODERATION_LABEL_CLAIM_APPROVED,
} from '@/lib/types';
/**
@ -39,6 +40,7 @@ function usePublishModerationLabel(label: string) {
coord,
id,
reason,
subject,
}: {
/** Addressable coordinate, e.g. `30818:<pubkey>:<d>`. */
coord?: string;
@ -46,6 +48,8 @@ function usePublishModerationLabel(label: string) {
id?: string;
/** Why — stored in the label's content for transparency. */
reason?: string;
/** Optional subject pubkey (e.g. the claimant a claim-approval grants). */
subject?: string;
}) => {
if (!isAdminPubkey(user?.pubkey)) throw new Error('Admins only.');
if (!coord && !id) throw new Error('Need a coordinate or an event id.');
@ -57,18 +61,18 @@ function usePublishModerationLabel(label: string) {
];
if (coord) tags.push(['a', coord]);
if (id) tags.push(['e', id]);
if (subject) tags.push(['p', subject]);
await publish({ kind: 1985, content: reason ?? '', tags });
// Optimistic, this device — newest verdict wins on merge.
const verdict =
label === MODERATION_LABEL_HIDDEN
? 'hidden'
: label === MODERATION_LABEL_VERIFIED
? 'verified'
: 'cleared';
setLocalVerdict({ coord, id }, verdict);
// Optimistic, this device — newest verdict wins on merge. `claim-approved`
// is NOT a hide/verify state, so it writes no local verdict (it must not
// clobber a real hidden/verified verdict on the same coord).
if (label === MODERATION_LABEL_HIDDEN) setLocalVerdict({ coord, id }, 'hidden');
else if (label === MODERATION_LABEL_VERIFIED) setLocalVerdict({ coord, id }, 'verified');
else if (label === MODERATION_LABEL_CLEARED) setLocalVerdict({ coord, id }, 'cleared');
queryClient.invalidateQueries({ queryKey: ['admin-moderation'] });
queryClient.invalidateQueries({ queryKey: ['spot-claim-approvals'] });
},
});
@ -96,3 +100,13 @@ export function useAdminClear() {
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_CLEARED);
return { clearVerdict: run, isPending };
}
/**
* APPROVE a private-spot ownership claim. Publishes `claim-approved` tagged
* with the spot coordinate and the claimant's pubkey; the claimant then finishes
* the claim themselves (only they can author their own version of the spot).
*/
export function useAdminApproveClaim() {
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_CLAIM_APPROVED);
return { approveClaim: run, isPending };
}

155
src/hooks/useSpotClaims.ts Normal file
View file

@ -0,0 +1,155 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { ADMIN_PUBKEYS } from '@/lib/admins';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_CLAIM_APPROVED,
SPOT_CLAIM_NAMESPACE,
SPOT_CLAIM_LABEL,
} from '@/lib/types';
/**
* Private-spot ownership claims.
*
* A private spot (a backyard ramp, a gated park) can't be verified against a
* domain the way a shop can. Instead the owner FILES A CLAIM — a signed NIP-32
* kind 1985 label in `org.skatespots.spotclaim` — and a SkateSpots moderator
* confirms it by publishing a `claim-approved` label in the moderation
* namespace. Only then does the claimant finish (re-publish the spot under their
* own key). Nothing is ever auto-transferred, so a stranger can't grief a pin.
*/
export interface SpotClaim {
/** Claimant hex pubkey (the label's author). */
claimant: string;
createdAt: number;
/** The NIP-05 handle they claimed with (label content). */
nip05: string;
}
/** Ownership claims filed against a spot coordinate (public, signed). */
export function useSpotClaims(coord: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<SpotClaim[]>({
queryKey: ['spot-claims', coord, getQueryKeySuffix()],
enabled: !!coord,
staleTime: 60_000,
queryFn: async () => {
if (!coord) return [];
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[{ kinds: [1985], '#a': [coord], limit: 200 }],
{ signal },
);
const byClaimant = new Map<string, SpotClaim>();
for (const ev of events) {
const inNamespace = ev.tags.some(([n, v]) => n === 'L' && v === SPOT_CLAIM_NAMESPACE);
const isClaim = ev.tags.some(([n, v]) => n === 'l' && v === SPOT_CLAIM_LABEL);
if (!inNamespace || !isClaim) continue;
const current = byClaimant.get(ev.pubkey);
if (!current || ev.created_at > current.createdAt) {
byClaimant.set(ev.pubkey, { claimant: ev.pubkey, createdAt: ev.created_at, nip05: ev.content });
}
}
return [...byClaimant.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/** `coord` → set of claimant pubkeys a moderator has approved. */
export function useApprovedSpotClaims() {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<Map<string, Set<string>>>({
queryKey: ['spot-claim-approvals', getQueryKeySuffix()],
staleTime: 60_000,
queryFn: async () => {
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[{ kinds: [1985], authors: ADMIN_PUBKEYS, '#l': [MODERATION_LABEL_CLAIM_APPROVED], limit: 500 }],
{ signal },
);
const map = new Map<string, Set<string>>();
for (const ev of events) {
const inNamespace = ev.tags.some(([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE);
if (!inNamespace) continue;
const coord = ev.tags.find(([n]) => n === 'a')?.[1];
const claimant = ev.tags.find(([n]) => n === 'p')?.[1];
if (!coord || !claimant) continue;
if (!map.has(coord)) map.set(coord, new Set());
map.get(coord)!.add(claimant.toLowerCase());
}
return map;
},
});
}
export interface AllSpotClaim {
coord: string;
ownerPubkey: string;
identifier: string;
claimant: string;
nip05: string;
createdAt: number;
}
/**
* EVERY private-spot ownership claim on the relays (admins use this to find
* work). Small volume — claims are rare and manual.
*/
export function useAllSpotClaims() {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<AllSpotClaim[]>({
queryKey: ['spot-claims-all', getQueryKeySuffix()],
staleTime: 60_000,
queryFn: async () => {
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[{ kinds: [1985], '#L': [SPOT_CLAIM_NAMESPACE], limit: 500 }],
{ signal },
);
const seen = new Map<string, AllSpotClaim>();
for (const ev of events) {
const isClaim = ev.tags.some(([n, v]) => n === 'l' && v === SPOT_CLAIM_LABEL);
if (!isClaim) continue;
const coord = ev.tags.find(([n]) => n === 'a')?.[1];
if (!coord) continue;
const [, ownerPubkey, identifier] = coord.split(':');
const key = `${coord}:${ev.pubkey}`;
const current = seen.get(key);
if (!current || ev.created_at > current.createdAt) {
seen.set(key, { coord, ownerPubkey, identifier, claimant: ev.pubkey, nip05: ev.content, createdAt: ev.created_at });
}
}
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/** File (or refresh) an ownership claim for a private spot. */export function useSubmitSpotClaim() {
const { user } = useCurrentUser();
const { mutateAsync: publish, isPending } = useNostrPublish();
const queryClient = useQueryClient();
return {
isPending,
submit: async ({ coord, ownerPubkey, nip05 }: { coord: string; ownerPubkey: string; nip05: string }) => {
if (!user) throw new Error('Log in first.');
await publish({
kind: 1985,
content: nip05,
tags: [
['L', SPOT_CLAIM_NAMESPACE],
['l', SPOT_CLAIM_LABEL, SPOT_CLAIM_NAMESPACE],
['a', coord],
['p', ownerPubkey],
['alt', 'SkateSpots private-spot ownership claim'],
],
});
queryClient.invalidateQueries({ queryKey: ['spot-claims', coord] });
},
};
}

View file

@ -965,6 +965,34 @@ mapUi: {
},
spotUi: {
claim: {
areYouOwner: 'Is this your spot?',
privateDesc: 'This spot is marked private. If you own or manage it, claim it — a SkateSpots moderator confirms ownership, then you take over the listing.',
claimSpot: 'Claim this spot',
nip05Required: 'You need a verified NIP-05 handle to claim a private spot (proof you control a Nostr identity). Add one in your profile, then come back.',
addNip05: 'Add NIP-05 in Settings',
pendingTitle: 'Claim pending review',
pendingDesc: 'Your claim has been submitted. A SkateSpots moderator will review it — you can finish claiming the spot once it\'s approved.',
approvedTitle: 'Claim approved',
approvedDesc: 'A moderator approved your claim. Finish to become the owner of this spot.',
complete: 'Finish claiming',
submitted: 'Claim submitted',
submittedDesc: 'A moderator will review your claim. You\'ll take over the spot once it\'s approved.',
submitFailed: 'Could not submit the claim',
submit: 'Submit claim',
submitting: 'Submitting…',
confirmTitle: 'Claim "{{title}}"?',
confirmIntro: 'You are asking to become the owner of this private spot.',
confirmBullet1: 'A SkateSpots moderator reviews the request (we can\'t verify property, only Nostr keys)',
confirmBullet2: 'If approved, you finish the claim to become the owner and can edit the spot',
confirmBullet3: 'The original spot stays on Nostr — the network is decentralized',
adminTitle: 'Pending ownership claims',
adminApprove: 'Approve',
adminApproved: 'Approved',
adminApprovedDesc: 'They can now finish claiming the spot.',
adminApproveFailed: 'Could not approve the claim',
loginRequired: 'Log in to claim this private spot, or to request ownership.',
},
abandonedDesc: 'Last updated {{time}}. If the original owner is inactive, you can claim this spot to maintain and improve it.',
abandonedLoginDesc: 'Last updated {{time}}. Log in to claim and maintain this spot.',
accessDeniedDesc: 'You can only edit spots that you created.',

View file

@ -965,6 +965,34 @@ mapUi: {
},
spotUi: {
claim: {
areYouOwner: '¿Es este tu spot?',
privateDesc: 'Este spot está marcado como privado. Si es tuyo o lo gestionas, reclámalo: un moderador de SkateSpots confirma la propiedad y después asumes el anuncio.',
claimSpot: 'Reclamar este spot',
nip05Required: 'Necesitas un identificador NIP-05 verificado para reclamar un spot privado (prueba de que controlas una identidad Nostr). Añádelo en tu perfil y vuelve.',
addNip05: 'Añadir NIP-05 en Ajustes',
pendingTitle: 'Reclamación pendiente de revisión',
pendingDesc: 'Tu reclamación se ha enviado. Un moderador de SkateSpots la revisará; podrás completarla cuando se apruebe.',
approvedTitle: 'Reclamación aprobada',
approvedDesc: 'Un moderador aprobó tu reclamación. Complétala para convertirte en el dueño de este spot.',
complete: 'Completar reclamación',
submitted: 'Reclamación enviada',
submittedDesc: 'Un moderador revisará tu reclamación. Asumirás el spot cuando se apruebe.',
submitFailed: 'No se pudo enviar la reclamación',
submit: 'Enviar reclamación',
submitting: 'Enviando…',
confirmTitle: '¿Reclamar "{{title}}"?',
confirmIntro: 'Estás pidiendo convertirte en el dueño de este spot privado.',
confirmBullet1: 'Un moderador de SkateSpots revisa la solicitud (no podemos verificar la propiedad, solo claves Nostr)',
confirmBullet2: 'Si se aprueba, completas la reclamación y puedes editar el spot',
confirmBullet3: 'El spot original permanece en Nostr: la red es descentralizada',
adminTitle: 'Reclamaciones pendientes',
adminApprove: 'Aprobar',
adminApproved: 'Aprobada',
adminApprovedDesc: 'Ya puede completar la reclamación del spot.',
adminApproveFailed: 'No se pudo aprobar la reclamación',
loginRequired: 'Inicia sesión para reclamar este spot privado o solicitar su propiedad.',
},
abandonedDesc: 'Actualizado por última vez {{time}}. Si el dueño original está inactivo, puedes reclamar este spot para mantenerlo y mejorarlo.',
abandonedLoginDesc: 'Actualizado por última vez {{time}}. Inicia sesión para reclamar y mantener este spot.',
accessDeniedDesc: 'Solo puedes editar los spots que has creado.',

View file

@ -237,6 +237,21 @@ export const MODERATION_LABEL_VERIFIED = 'verified';
* earlier `hidden`/`verified` label.
*/
export const MODERATION_LABEL_CLEARED = 'cleared';
/**
* A moderator APPROVED a private-spot ownership claim. Tagged `["a", coord]` +
* `["p", claimantPubkey]`; the claimant then finishes the claim (re-publishes
* the spot under their key). Distinct from `verified` (which vouches for the
* spot itself, not who owns it).
*/
export const MODERATION_LABEL_CLAIM_APPROVED = 'claim-approved';
/**
* A claimant's own signed ownership claim (NIP-32 kind 1985). Kept in a SEPARATE
* namespace from moderation so `useAdminModeration` never reads non-admin labels
* as verdicts.
*/
export const SPOT_CLAIM_NAMESPACE = 'org.skatespots.spotclaim';
export const SPOT_CLAIM_LABEL = 'claim';
/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */
export function parseMemberPubkeys(event: NostrEvent): string[] {

View file

@ -41,7 +41,8 @@ import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
import { useSpotReports } from '@/hooks/useSpotReports';
import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide';
import { useAdminHide, useAdminVerify, useAdminClear, useAdminApproveClaim } from '@/hooks/useAdminHide';
import { useAllSpotClaims, useApprovedSpotClaims } from '@/hooks/useSpotClaims';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -654,8 +655,62 @@ function LabQueue() {
);
}
export default function AdminReviewPage() {
/**
* Private-spot ownership claims awaiting a moderator. Approving publishes the
* `claim-approved` label tagged with the claimant, who then finishes the claim.
*/
function SpotClaimsQueue() {
const { t } = useTranslation();
const { data: claims = [] } = useAllSpotClaims();
const { data: approvals } = useApprovedSpotClaims();
const { approveClaim, isPending } = useAdminApproveClaim();
const { toast } = useToast();
const pending = claims.filter((c) => !approvals?.get(c.coord)?.has(c.claimant.toLowerCase()));
if (pending.length === 0) return null;
const approve = async (coord: string, claimant: string) => {
try {
await approveClaim({ coord, subject: claimant, reason: 'private-spot-claim' });
toast({ title: t('spotUi.claim.adminApproved') });
} catch {
toast({ title: t('spotUi.claim.adminApproveFailed'), variant: 'destructive' });
}
};
return (
<Card className="mb-4 bg-purple-500/10 border-2 border-purple-500/30">
<CardContent className="p-4">
<h3 className="mb-3 text-sm font-bold uppercase text-purple-300">
{t('spotUi.claim.adminTitle')} ({pending.length})
</h3>
<div className="space-y-3">
{pending.map((c) => (
<div key={`${c.coord}:${c.claimant}`} className="flex items-center justify-between gap-3 text-xs">
<div className="min-w-0">
<Link to={`/spot/${c.ownerPubkey}/${c.identifier}`} className="block truncate text-[#ccff00] hover:underline">
{c.identifier}
</Link>
<span className="font-mono text-gray-500">{c.nip05 || `${c.claimant.slice(0, 12)}…`}</span>
</div>
<Button
size="sm"
className="h-7 shrink-0 gap-1.5 bg-purple-500 text-white hover:bg-purple-600"
disabled={isPending}
onClick={() => approve(c.coord, c.claimant)}
>
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
{t('spotUi.claim.adminApprove')}
</Button>
</div>
))}
</div>
</CardContent>
</Card>
);
}
export default function AdminReviewPage() { const { t } = useTranslation();
const { user } = useCurrentUser();
const [kind, setKind] = useState<QueueKind>('reports');
@ -673,6 +728,8 @@ export default function AdminReviewPage() {
subtitle={t('walletUi.admin.subtitle')}
/>
<SpotClaimsQueue />
<div className="mb-4 flex flex-wrap gap-1.5">
{TABS.map((tab) => (
<button