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:
Chad Curtis
2026-06-01 15:35:17 -05:00
committed by filemon
parent 670ef9a3e9
commit 0895b763c6
3 changed files with 90 additions and 99 deletions
+16 -43
View File
@@ -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. */
+21 -56
View File
@@ -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,
});
}
+53
View File
@@ -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;