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 type { NostrEvent } from '@nostrify/nostrify';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration';
|
import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration';
|
||||||
|
import { verifiedFeaturedCoords } from '@/lib/featuredVerification';
|
||||||
|
|
||||||
export type LabProfileType =
|
export type LabProfileType =
|
||||||
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
|
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
|
||||||
|
|
@ -39,6 +40,15 @@ const LAB_SELF_TAGS = [
|
||||||
'skatebarber',
|
'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. */
|
/** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */
|
||||||
export function parseLabProfile(event: NostrEvent): LabProfile | null {
|
export function parseLabProfile(event: NostrEvent): LabProfile | null {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
|
|
@ -129,10 +139,18 @@ export function useLabProfiles(limit = 100, options: { includeHidden?: boolean }
|
||||||
if (parsed) profiles.push(parsed);
|
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) => {
|
return profiles.sort((a, b) => {
|
||||||
const aPro = isFeaturedActive(a.featuredUntil) ? 1 : 0;
|
const aPro = isPro(a) ? 1 : 0;
|
||||||
const bPro = isFeaturedActive(b.featuredUntil) ? 1 : 0;
|
const bPro = isPro(b) ? 1 : 0;
|
||||||
if (aPro !== bPro) return bPro - aPro;
|
if (aPro !== bPro) return bPro - aPro;
|
||||||
return b.createdAt - a.createdAt;
|
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 { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent } from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
|
import { verifiedFeaturedCoords } from '@/lib/featuredVerification';
|
||||||
|
|
||||||
export function useSkateEvents(limit = 50, options: { includeHidden?: boolean } = {}) {
|
export function useSkateEvents(limit = 50, options: { includeHidden?: boolean } = {}) {
|
||||||
const { includeHidden = false } = options;
|
const { includeHidden = false } = options;
|
||||||
|
|
@ -33,10 +34,16 @@ export function useSkateEvents(limit = 50, options: { includeHidden?: boolean }
|
||||||
if (skateEvent) skateEvents.push(skateEvent);
|
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) => {
|
return skateEvents.sort((a, b) => {
|
||||||
const aF = isFeaturedActive(a.featuredUntil) ? 1 : 0;
|
const aF = isPro(a) ? 1 : 0;
|
||||||
const bF = isFeaturedActive(b.featuredUntil) ? 1 : 0;
|
const bF = isPro(b) ? 1 : 0;
|
||||||
if (aF !== bF) return bF - aF;
|
if (aF !== bF) return bF - aF;
|
||||||
return a.start - b.start;
|
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
|
return skateEvents
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aF = isFeaturedActive(a.featuredUntil) ? 1 : 0;
|
const aF = isPro(a) ? 1 : 0;
|
||||||
const bF = isFeaturedActive(b.featuredUntil) ? 1 : 0;
|
const bF = isPro(b) ? 1 : 0;
|
||||||
if (aF !== bF) return bF - aF;
|
if (aF !== bF) return bF - aF;
|
||||||
return a.start - b.start;
|
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 { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
|
import { verifiedFeaturedCoords } from '@/lib/featuredVerification';
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STALE_TIME = 5 * 60 * 1000; // 5 min
|
const STALE_TIME = 5 * 60 * 1000; // 5 min
|
||||||
const GC_TIME = 30 * 60 * 1000; // 30 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);
|
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,
|
staleTime: STALE_TIME,
|
||||||
gcTime: GC_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