fix(trust): C5b — featured ranking requires a verified provider receipt
A ['featured', expiry] tag is self-asserted, so anyone could mint one and buy top placement (the badge verified it, ordering didn't). - New src/lib/featuredVerification.ts resolves the provider pubkey (cached) and batch-checks provider-authored kind-9735 zap receipts for the featured coords. - useTours / useSkateEvents / useUpcomingSkateEvents / useLabProfiles now rank only receipts-backed featured items first. - Availability fallback: if the provider can't be resolved or the query fails, the self-asserted ordering is kept, so a provider outage never blanks paid placement.
This commit is contained in:
parent
75c9cc4010
commit
a745bdbbcf
4 changed files with 108 additions and 18 deletions
|
|
@ -14,6 +14,7 @@ import {
|
|||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration';
|
||||
import { verifiedFeaturedCoords } from '@/lib/featuredVerification';
|
||||
|
||||
export type LabProfileType =
|
||||
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
|
||||
|
|
@ -39,6 +40,15 @@ const LAB_SELF_TAGS = [
|
|||
'skatebarber',
|
||||
];
|
||||
|
||||
/** profileType → addressable kind, for building a coordinate. */
|
||||
const KIND_BY_TYPE: Record<LabProfileType, number> = {
|
||||
coach: SKATE_COACH_KIND, filmer: SKATE_FILMER_KIND, editor: SKATE_EDITOR_KIND,
|
||||
photographer: SKATE_PHOTOGRAPHER_KIND, builder: SKATE_BUILDER_KIND, artist: SKATE_ARTIST_KIND,
|
||||
graffiti: SKATE_GRAFFITI_KIND, tattoo: SKATE_TATTOO_KIND, trainer: SKATE_TRAINER_KIND,
|
||||
musician: SKATE_MUSICIAN_KIND, judge: SKATE_JUDGE_KIND, organizer: SKATE_ORGANIZER_KIND,
|
||||
barber: SKATE_BARBER_KIND,
|
||||
};
|
||||
|
||||
/** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */
|
||||
export function parseLabProfile(event: NostrEvent): LabProfile | null {
|
||||
switch (event.kind) {
|
||||
|
|
@ -129,10 +139,18 @@ export function useLabProfiles(limit = 100, options: { includeHidden?: boolean }
|
|||
if (parsed) profiles.push(parsed);
|
||||
}
|
||||
|
||||
// Featured (Pro) profiles first, then newest.
|
||||
// Featured (Pro) profiles first — but only when backed by a provider
|
||||
// receipt (a self-asserted tag must not buy placement). Fallback to the
|
||||
// tag when verification is unavailable.
|
||||
const coordOf = (p: LabProfile) => `${KIND_BY_TYPE[p.profileType]}:${p.pubkey}:${p.identifier}`;
|
||||
const featuredCoords = profiles.filter((p) => isFeaturedActive(p.featuredUntil)).map(coordOf);
|
||||
const verified = await verifiedFeaturedCoords(featuredCoords, (f) => querySkateContent([f], { signal }));
|
||||
const isPro = (p: LabProfile) =>
|
||||
isFeaturedActive(p.featuredUntil) && (verified === null || verified.has(coordOf(p)));
|
||||
|
||||
return profiles.sort((a, b) => {
|
||||
const aPro = isFeaturedActive(a.featuredUntil) ? 1 : 0;
|
||||
const bPro = isFeaturedActive(b.featuredUntil) ? 1 : 0;
|
||||
const aPro = isPro(a) ? 1 : 0;
|
||||
const bPro = isPro(b) ? 1 : 0;
|
||||
if (aPro !== bPro) return bPro - aPro;
|
||||
return b.createdAt - a.createdAt;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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';
|
||||
import { verifiedFeaturedCoords } from '@/lib/featuredVerification';
|
||||
|
||||
export function useSkateEvents(limit = 50, options: { includeHidden?: boolean } = {}) {
|
||||
const { includeHidden = false } = options;
|
||||
|
|
@ -33,10 +34,16 @@ export function useSkateEvents(limit = 50, options: { includeHidden?: boolean }
|
|||
if (skateEvent) skateEvents.push(skateEvent);
|
||||
}
|
||||
|
||||
// Featured (paid) events first, then by soonest start time.
|
||||
// Verified/paid featured first, then soonest start time.
|
||||
const coordOf = (ev: SkateEvent) => `${CALENDAR_EVENT_KIND}:${ev.pubkey}:${ev.identifier}`;
|
||||
const featuredCoords = skateEvents.filter((ev) => isFeaturedActive(ev.featuredUntil)).map(coordOf);
|
||||
const verified = await verifiedFeaturedCoords(featuredCoords, (f) => querySkateContent([f], { signal }));
|
||||
const isPro = (ev: SkateEvent) =>
|
||||
isFeaturedActive(ev.featuredUntil) && (verified === null || verified.has(coordOf(ev)));
|
||||
|
||||
return skateEvents.sort((a, b) => {
|
||||
const aF = isFeaturedActive(a.featuredUntil) ? 1 : 0;
|
||||
const bF = isFeaturedActive(b.featuredUntil) ? 1 : 0;
|
||||
const aF = isPro(a) ? 1 : 0;
|
||||
const bF = isPro(b) ? 1 : 0;
|
||||
if (aF !== bF) return bF - aF;
|
||||
return a.start - b.start;
|
||||
});
|
||||
|
|
@ -76,11 +83,17 @@ export function useUpcomingSkateEvents(limit = 20) {
|
|||
}
|
||||
}
|
||||
|
||||
// Featured (paid) events first, then soonest start time.
|
||||
// Verified/paid featured first, then soonest start time.
|
||||
const coordOf = (ev: SkateEvent) => `${CALENDAR_EVENT_KIND}:${ev.pubkey}:${ev.identifier}`;
|
||||
const featuredCoords = skateEvents.filter((ev) => isFeaturedActive(ev.featuredUntil)).map(coordOf);
|
||||
const verified = await verifiedFeaturedCoords(featuredCoords, (f) => querySkateContent([f], { signal }));
|
||||
const isPro = (ev: SkateEvent) =>
|
||||
isFeaturedActive(ev.featuredUntil) && (verified === null || verified.has(coordOf(ev)));
|
||||
|
||||
return skateEvents
|
||||
.sort((a, b) => {
|
||||
const aF = isFeaturedActive(a.featuredUntil) ? 1 : 0;
|
||||
const bF = isFeaturedActive(b.featuredUntil) ? 1 : 0;
|
||||
const aF = isPro(a) ? 1 : 0;
|
||||
const bF = isPro(b) ? 1 : 0;
|
||||
if (aF !== bF) return bF - aF;
|
||||
return a.start - b.start;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,14 +2,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 {
|
||||
const af = isFeaturedActive(a.featuredUntil) ? 1 : 0;
|
||||
const bf = isFeaturedActive(b.featuredUntil) ? 1 : 0;
|
||||
if (af !== bf) return bf - af;
|
||||
return b.createdAt - a.createdAt;
|
||||
}
|
||||
import { verifiedFeaturedCoords } from '@/lib/featuredVerification';
|
||||
|
||||
const STALE_TIME = 5 * 60 * 1000; // 5 min
|
||||
const GC_TIME = 30 * 60 * 1000; // 30 min
|
||||
|
|
@ -45,7 +38,19 @@ export function useTours(limit = 100, options: { includeHidden?: boolean } = {})
|
|||
if (tour) tours.push(tour);
|
||||
}
|
||||
|
||||
return tours.sort(sortTours);
|
||||
// Rank featured (Pro) first — but only when backed by a provider receipt.
|
||||
const coordOf = (tr: SkateTour) => `${SKATE_TOUR_KIND}:${tr.pubkey}:${tr.identifier}`;
|
||||
const featuredCoords = tours.filter((tr) => isFeaturedActive(tr.featuredUntil)).map(coordOf);
|
||||
const verified = await verifiedFeaturedCoords(featuredCoords, (f) => querySkateContent([f], { signal }));
|
||||
const isPro = (tr: SkateTour) =>
|
||||
isFeaturedActive(tr.featuredUntil) && (verified === null || verified.has(coordOf(tr)));
|
||||
|
||||
return tours.sort((a, b) => {
|
||||
const af = isPro(a) ? 1 : 0;
|
||||
const bf = isPro(b) ? 1 : 0;
|
||||
if (af !== bf) return bf - af;
|
||||
return b.createdAt - a.createdAt;
|
||||
});
|
||||
},
|
||||
staleTime: STALE_TIME,
|
||||
gcTime: GC_TIME,
|
||||
|
|
|
|||
54
src/lib/featuredVerification.ts
Normal file
54
src/lib/featuredVerification.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Featured ("Pro") ranking verification.
|
||||
*
|
||||
* SECURITY: a `["featured", <expiry>]` tag is SELF-ASSERTED — anyone can add it
|
||||
* and get top placement, and `useFeaturedVerification` only downgrades the badge,
|
||||
* never the ordering. This helper resolves which featured coordinates are backed
|
||||
* by a **provider-authored NIP-57 zap receipt** so list hooks can rank only those.
|
||||
*
|
||||
* Availability over strictness: if the provider pubkey can't be resolved or the
|
||||
* receipt query fails, it returns `null` and the caller keeps the self-asserted
|
||||
* ordering (so a provider outage never blanks paid placement).
|
||||
*/
|
||||
import { PRO_LIGHTNING_ADDRESS } from '@/lib/sasquatchPro';
|
||||
import { getZapProviderPubkey } from '@/lib/lightningAddress';
|
||||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
let providerPromise: Promise<string | null> | null = null;
|
||||
|
||||
function cachedProvider(): Promise<string | null> {
|
||||
if (!providerPromise) {
|
||||
providerPromise = getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null);
|
||||
}
|
||||
return providerPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the set of featured coords with a verified provider receipt, or
|
||||
* `null` when verification is unavailable (caller should not demote).
|
||||
*/
|
||||
export async function verifiedFeaturedCoords(
|
||||
coords: string[],
|
||||
query: (filter: NostrFilter) => Promise<NostrEvent[]>,
|
||||
): Promise<Set<string> | null> {
|
||||
if (coords.length === 0) return null;
|
||||
const provider = await cachedProvider();
|
||||
if (!provider) return null;
|
||||
try {
|
||||
const receipts = await query({
|
||||
kinds: [9735],
|
||||
authors: [provider],
|
||||
'#a': coords,
|
||||
'#p': PLATFORM_OWNER_PUBKEYS,
|
||||
limit: coords.length * 4,
|
||||
});
|
||||
const set = new Set<string>();
|
||||
for (const r of receipts) {
|
||||
for (const [n, v] of r.tags) if (n === 'a' && v) set.add(v);
|
||||
}
|
||||
return set;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue