campaigns: hardcode moderators, gate lists on a single curator
The home page used to serialize two single-relay round-trips before any campaign card could render: useCampaignModerators fetched the Team Soapbox follow pack (kind 39089), and useCampaignLists waited on it to apply an authors: gate. Each could stall up to an 8s EOSE timeout against the app relay. Both lookups are now eliminated from the critical path: - CAMPAIGN_MODERATORS in agoraDefaults.ts is a hardcoded snapshot of the pack's p tags. useCampaignModerators serves it synchronously (no queryFn network call), keeping its useQuery return shape so all ~15 consumers work unchanged. The roster changes rarely; update the array and re-cut a release when it does. - Lists are an editorial surface curated by one identity (MK Fain / Team Soapbox), not the whole moderator pack. useCampaignLists now pins authors: to LIST_CURATOR_PUBKEY and no longer depends on the moderator query at all. The multi-author allowlist remains for labels only (approve/hide), where any pack member is trusted. Regression-of: be1fadfc
This commit is contained in:
@@ -2,7 +2,6 @@ import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useCampaignModerators } from './useCampaignModerators';
|
||||
import {
|
||||
CAMPAIGN_LIST_KIND,
|
||||
CAMPAIGN_LIST_HASHTAG,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
type ParsedCampaignList,
|
||||
foldCampaignLists,
|
||||
} from '@/lib/campaignLists';
|
||||
import { LIST_CURATOR_PUBKEY } from '@/lib/agoraDefaults';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -22,27 +22,19 @@ interface UseCampaignListsResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads moderator-curated campaign lists (kind 30003 with the
|
||||
* Reads curator-authored campaign lists (kind 30003 with the
|
||||
* `agora.campaign-list` hashtag) plus the optional list-of-lists order
|
||||
* sentinel (`agora.campaign-lists.index`).
|
||||
*
|
||||
* **Trust model.** Only lists authored by a {@link useCampaignModerators}
|
||||
* allowlist member (Team Soapbox follow pack) are surfaced. Without that
|
||||
* gate, any pubkey could publish a kind 30003 with our hashtag and appear
|
||||
* in the strip — same self-appointment hole we avoid in
|
||||
* `useCampaignModeration`.
|
||||
* **Trust model.** Lists are an editorial surface curated by a single
|
||||
* pubkey ({@link LIST_CURATOR_PUBKEY}). The relay query pins `authors:`
|
||||
* to that pubkey, so a kind 30003 with our hashtag from anyone else —
|
||||
* including a label moderator — never appears. This is deliberately
|
||||
* narrower than label moderation (`useCampaignModerators`), where any
|
||||
* follow-pack member is trusted to sign approve / hide labels.
|
||||
*
|
||||
* **Flattened waterfall.** The list relay query no longer *waits* for the
|
||||
* moderator pack to resolve. Previously the query was `enabled`-gated on
|
||||
* the moderators and applied `authors: moderators` server-side, which
|
||||
* serialized two single-relay round-trips (each up to an 8s EOSE timeout)
|
||||
* on a cold session before any list could render. We now fire the list
|
||||
* query immediately on the hashtag filter and apply the moderator
|
||||
* allowlist **client-side** in {@link foldCampaignLists}. The two queries
|
||||
* run in parallel; the trust gate is identical (a list authored by a
|
||||
* non-moderator is dropped before it ever reaches the UI). The moderator
|
||||
* pack is cached for 10 minutes, so on warm sessions it's already present
|
||||
* and the filter applies with zero added latency.
|
||||
* Because the curator is a hardcoded constant, this query depends on no
|
||||
* other query — it fires on first paint with no waterfall.
|
||||
*
|
||||
* Lists *and* the index are pulled in a single filter via
|
||||
* `'#t': [LIST_HASHTAG, LIST_INDEX_HASHTAG]` so there's only one
|
||||
@@ -50,51 +42,32 @@ interface UseCampaignListsResult {
|
||||
*/
|
||||
export function useCampaignLists() {
|
||||
const { nostr } = useNostr();
|
||||
const { data: moderators, isLoading: moderatorsLoading } = useCampaignModerators();
|
||||
|
||||
// Raw lists query — fired independently of the moderator pack so the two
|
||||
// round-trips run in parallel. The moderator allowlist is applied at the
|
||||
// fold step below, not as a server `authors:` filter.
|
||||
const rawQuery = useQuery<NostrEvent[]>({
|
||||
queryKey: ['campaign-lists', 'raw'],
|
||||
const query = useQuery<UseCampaignListsResult>({
|
||||
queryKey: ['campaign-lists', LIST_CURATOR_PUBKEY],
|
||||
queryFn: async ({ signal }) => {
|
||||
// Query the canonical app relay directly. The same reasoning as
|
||||
// `useCampaignModerators` applies: a fast empty EOSE from a
|
||||
// less-populated relay should not race the moderation surface to
|
||||
// "no lists" while the curated relay still holds them.
|
||||
const relay = nostr.relay(DITTO_RELAY);
|
||||
return relay.query(
|
||||
const events = await relay.query(
|
||||
[
|
||||
{
|
||||
kinds: [CAMPAIGN_LIST_KIND],
|
||||
authors: [LIST_CURATOR_PUBKEY],
|
||||
'#t': [CAMPAIGN_LIST_HASHTAG, CAMPAIGN_LIST_INDEX_HASHTAG],
|
||||
limit: 500,
|
||||
},
|
||||
],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
||||
);
|
||||
return foldCampaignLists(events);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Fold + trust-gate client-side: drop any list/index event not authored
|
||||
// by a moderator before parsing. Recomputed when either the raw events
|
||||
// or the moderator allowlist changes.
|
||||
const data = useMemo<UseCampaignListsResult>(() => {
|
||||
const events = rawQuery.data;
|
||||
if (!events || !moderators || moderators.length === 0) {
|
||||
return { lists: [], indexEvent: undefined };
|
||||
}
|
||||
const allowed = new Set(moderators);
|
||||
const trusted = events.filter((e) => allowed.has(e.pubkey));
|
||||
return foldCampaignLists(trusted);
|
||||
}, [rawQuery.data, moderators]);
|
||||
|
||||
return {
|
||||
...rawQuery,
|
||||
data,
|
||||
isLoading: rawQuery.isLoading || moderatorsLoading,
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
/** Lookup a single list by slug from the cached collection. */
|
||||
|
||||
@@ -1,71 +1,36 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { TEAM_SOAPBOX } from '@/lib/agoraDefaults';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
|
||||
/** A 64-character lowercase hex string. */
|
||||
const HEX_64_RE = /^[0-9a-f]{64}$/;
|
||||
import { CAMPAIGN_MODERATORS } from '@/lib/agoraDefaults';
|
||||
|
||||
/**
|
||||
* Returns the hex pubkeys of campaign moderators — the `p` tags of the
|
||||
* Team Soapbox follow pack (kind 39089).
|
||||
* Returns the hex pubkeys of campaign moderators — the pubkeys allowed to
|
||||
* sign approve / hide labels in the `agora.moderation` namespace (see
|
||||
* NIP.md).
|
||||
*
|
||||
* A campaign appears on `/` and Discover only if a moderator has labeled it
|
||||
* `approved` (see {@link useCampaignModeration}). A moderator's `hidden`
|
||||
* label always wins over any approval. The pack itself is authored by a
|
||||
* single admin pubkey, so we pin `authors` to that pubkey to prevent anyone
|
||||
* else from publishing a same-`d` event and self-appointing.
|
||||
* label always wins over any approval.
|
||||
*
|
||||
* **Phase 1 tradeoff:** the pack is fetched live every cold session. We
|
||||
* accept the 1-round-trip latency in exchange for not shipping a release
|
||||
* every time the moderator roster changes. If perf matters, snapshot the
|
||||
* `p` tags into a hardcoded array and short-circuit this hook.
|
||||
* **Hardcoded snapshot.** This used to fetch the Team Soapbox follow pack
|
||||
* (kind 39089) live every cold session, which put a single-relay round-trip
|
||||
* — up to an 8s EOSE timeout — on the critical path of every
|
||||
* moderation-gated surface (home, Discover, profile campaigns, etc.). The
|
||||
* roster changes rarely, so the membership is now snapshotted in
|
||||
* {@link CAMPAIGN_MODERATORS} and served synchronously with zero network
|
||||
* cost. Update that array (and re-cut a release) when the pack changes.
|
||||
*
|
||||
* @see TEAM_SOAPBOX (src/lib/agoraDefaults.ts) for the pack coordinate.
|
||||
* The hook keeps its `useQuery` return shape so existing consumers
|
||||
* (`{ data, isLoading, ... }`) continue to work unchanged; the query is a
|
||||
* pure synchronous read with no `queryFn` network call.
|
||||
*
|
||||
* @see CAMPAIGN_MODERATORS (src/lib/agoraDefaults.ts) for the pubkey list.
|
||||
* @see NIP.md "Campaign moderation labels" for the namespace this powers.
|
||||
*/
|
||||
export function useCampaignModerators() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['campaign-moderators', TEAM_SOAPBOX.pubkey, TEAM_SOAPBOX.identifier],
|
||||
queryFn: async ({ signal }) => {
|
||||
// The home page gates campaign visibility on this pack. Query the
|
||||
// canonical app relay directly so a fast empty EOSE from another relay
|
||||
// cannot race the pack out and make the page render as empty.
|
||||
const relay = nostr.relay(DITTO_RELAY);
|
||||
const events = await relay.query(
|
||||
[
|
||||
{
|
||||
kinds: [TEAM_SOAPBOX.kind],
|
||||
// Pinning to the pack author is required: kind 39089 is
|
||||
// addressable, so without this anyone could publish a competing
|
||||
// event with the same `d` and force themselves into the moderator
|
||||
// list. (See AGENTS.md `nostr-security`.)
|
||||
authors: [TEAM_SOAPBOX.pubkey],
|
||||
'#d': [TEAM_SOAPBOX.identifier],
|
||||
limit: 1,
|
||||
},
|
||||
],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
||||
);
|
||||
|
||||
if (events.length === 0) return [] as string[];
|
||||
|
||||
// The pack is replaceable; relays may serve old revisions alongside the
|
||||
// current one. Keep the newest.
|
||||
const newest = events.reduce((latest, current) =>
|
||||
current.created_at > latest.created_at ? current : latest,
|
||||
);
|
||||
|
||||
// Filter malformed `p` tags so a typo doesn't blow up downstream
|
||||
// relay filters (which reject non-hex `authors:` entries).
|
||||
return newest.tags
|
||||
.filter(([name, value]) => name === 'p' && typeof value === 'string' && HEX_64_RE.test(value))
|
||||
.map(([, pubkey]) => pubkey);
|
||||
},
|
||||
staleTime: 10 * 60_000,
|
||||
gcTime: 60 * 60_000,
|
||||
queryKey: ['campaign-moderators', 'snapshot'],
|
||||
queryFn: () => CAMPAIGN_MODERATORS.slice(),
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,3 +60,56 @@ export const TEAM_SOAPBOX = {
|
||||
identifier: teamSoapboxDecoded.data.identifier,
|
||||
relays: teamSoapboxDecoded.data.relays,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The single pubkey allowed to author campaign **lists** (kind 30003 with
|
||||
* the `agora.campaign-list` hashtag) and the list-of-lists index sentinel.
|
||||
*
|
||||
* This is deliberately narrower than the moderator allowlist
|
||||
* ({@link CAMPAIGN_MODERATORS}). That allowlist governs **labels** —
|
||||
* approve / hide moderation in the `agora.moderation` namespace — where
|
||||
* any pack member is trusted to sign. Lists are an editorial surface (the
|
||||
* home hero row, the topic strip) curated by one person (MK Fain / Team
|
||||
* Soapbox), so a list authored by anyone else — including another
|
||||
* moderator — is dropped before it reaches the UI.
|
||||
*
|
||||
* It happens to equal the follow-pack author (`TEAM_SOAPBOX.pubkey`),
|
||||
* which is the same single admin identity, so we derive it from there
|
||||
* rather than duplicating the hex.
|
||||
*/
|
||||
export const LIST_CURATOR_PUBKEY = TEAM_SOAPBOX.pubkey;
|
||||
|
||||
/**
|
||||
* Hardcoded snapshot of the campaign-moderator pubkeys — the `p` tags of
|
||||
* the Team Soapbox follow pack ({@link TEAM_SOAPBOX}) as of the snapshot
|
||||
* date below.
|
||||
*
|
||||
* These pubkeys form the authoritative allowlist for **labels**: who may
|
||||
* sign approve / hide moderation in the `agora.moderation` namespace (see
|
||||
* NIP.md and `useCampaignModerators`). A campaign appears on `/` and
|
||||
* Discover only if one of these pubkeys labeled it `approved`; a `hidden`
|
||||
* label from any of them always wins.
|
||||
*
|
||||
* **Why hardcoded.** The pack used to be fetched live every cold session
|
||||
* (kind 39089), which put a single-relay round-trip — up to an 8s EOSE
|
||||
* timeout — on the critical path of every moderation-gated surface. The
|
||||
* roster changes rarely, so we snapshot it here and pay zero network cost.
|
||||
* When the pack membership changes, update this array (and re-cut a
|
||||
* release). Source of truth remains the on-relay pack; this is a copy.
|
||||
*
|
||||
* Snapshot taken from pack event `740838e6…fac76` (created_at 1779321391).
|
||||
*/
|
||||
export const CAMPAIGN_MODERATORS: readonly string[] = [
|
||||
'781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5',
|
||||
'0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd',
|
||||
'932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
'3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24',
|
||||
'86184109eae937d8d6f980b4a0b46da4ef0d983eade403ee1b4c0b6bde238b47',
|
||||
'47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4',
|
||||
'ce97367c75d7d91fb9bc3bc6ff5bb3bdb52c18941bfce2f368616dcbf0adfd2f',
|
||||
'0574536d3ef4d65faf95b42393610b8475d22f4c294649d46c50d5d36f75267c',
|
||||
'be7358c4fe50148cccafc02ea205d80145e253889aa3958daafa8637047c840e',
|
||||
'2093baa8621c5b255e8f4fc2c6fdfc10d8a5598a25517664efaba860735f1030',
|
||||
'8f53782e8693e88afb710b6d68182ad973973c8822caa237bb60288b125673ca',
|
||||
'c839bc85846f24fc6b777548fe654672377f4cc2a04cab19cddec75b2f8b4dbd',
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user