diff --git a/.agents/skills/nip85-stats/SKILL.md b/.agents/skills/nip85-stats/SKILL.md new file mode 100644 index 00000000..7e01e18d --- /dev/null +++ b/.agents/skills/nip85-stats/SKILL.md @@ -0,0 +1,190 @@ +--- +name: nip85-stats +description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig. +--- + +# NIP-85 Trusted Assertion Stats + +[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider. + +This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers. + +## Kinds Used + +| Kind | Subject | `d` tag value | +| ----- | ---------------------------- | -------------------------- | +| 30382 | User | user pubkey (hex) | +| 30383 | Event (regular, kind 1 etc.) | event id (hex) | +| 30384 | Addressable event | `::` | + +The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it. + +## Files Provided by This Skill + +| Skill file | Copy to | +|---|---| +| `files/hooks/useNip85Stats.ts` | `src/hooks/useNip85Stats.ts` | + +## Setup Instructions + +### 1. Copy the Hooks File + +Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template. + +### 2. Add `nip85StatsPubkey` to `AppConfig` + +In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface: + +```typescript +export interface AppConfig { + // ...existing fields... + /** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */ + nip85StatsPubkey: string; +} +``` + +### 3. Update the Zod Schema in `AppProvider.tsx` + +In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`: + +```typescript +const AppConfigSchema = z.object({ + // ...existing fields... + nip85StatsPubkey: z.string().refine( + (val) => val.length === 0 || /^[0-9a-f]{64}$/i.test(val), + { message: 'Must be empty or a 64-character hex pubkey' }, + ), +}) satisfies z.ZodType; +``` + +### 4. Set the Default in `App.tsx` + +Pick a provider pubkey and add it to `defaultConfig`. The ditto.pub provider is a reasonable default: + +```typescript +const defaultConfig: AppConfig = { + // ...existing fields... + nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea', +}; +``` + +Set to `''` to ship with stats disabled. + +### 5. Update `TestApp.tsx` + +In `src/test/TestApp.tsx`, add the field to the test default config. Use an empty string so tests don't hit a live provider: + +```typescript +const defaultConfig: AppConfig = { + // ...existing fields... + nip85StatsPubkey: '', +}; +``` + +## Usage + +### User stats (kind 30382) + +```tsx +import { useNip85UserStats } from '@/hooks/useNip85Stats'; + +function FollowerCount({ pubkey }: { pubkey: string }) { + const { data: stats } = useNip85UserStats(pubkey); + if (!stats) return null; // no provider configured or no assertion yet + return {stats.followers.toLocaleString()} followers; +} +``` + +### Event stats (kind 30383) + +```tsx +import { useNip85EventStats } from '@/hooks/useNip85Stats'; + +function NoteStats({ eventId }: { eventId: string }) { + const { data: stats } = useNip85EventStats(eventId); + if (!stats) return null; + return ( +
+ {stats.reactionCount} reactions + {stats.repostCount} reposts + {stats.commentCount} comments + {stats.zapAmount} sats +
+ ); +} +``` + +### Addressable event stats (kind 30384) + +The `addr` argument is the full NIP-01 event address `::`: + +```tsx +import { useNip85AddrStats } from '@/hooks/useNip85Stats'; + +function ArticleStats({ kind, pubkey, identifier }: { kind: number; pubkey: string; identifier: string }) { + const { data: stats } = useNip85AddrStats(`${kind}:${pubkey}:${identifier}`); + if (!stats) return null; + return {stats.reactionCount} reactions; +} +``` + +## Behavior Notes + +- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth. +- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI. +- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically. +- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics. +- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts. + +## Extending the Stats + +The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`: + +```typescript +export interface Nip85UserStats { + followers: number; + postCount: number; + rank: number; // new + zapAmtReceived: number; // new +} + +// inside useNip85UserStats queryFn +return { + followers: getIntTag(tags, 'followers'), + postCount: getIntTag(tags, 'post_cnt'), + rank: getIntTag(tags, 'rank'), + zapAmtReceived: getIntTag(tags, 'zap_amt_recd'), +}; +``` + +See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md). + +## Exposing a Provider Picker (Optional) + +If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value: + +```tsx +import { useAppContext } from '@/hooks/useAppContext'; + +function StatsProviderInput() { + const { config, updateConfig } = useAppContext(); + return ( + { + const v = e.target.value.trim().toLowerCase(); + if (v === '' || /^[0-9a-f]{64}$/.test(v)) { + updateConfig(() => ({ nip85StatsPubkey: v })); + } + }} + placeholder="64-char hex pubkey (blank to disable)" + /> + ); +} +``` + +## Related NIPs + +- [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) — Trusted Assertions (this skill) +- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Addressable event addressing (`::`) +- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate) diff --git a/.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts b/.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts new file mode 100644 index 00000000..38f87993 --- /dev/null +++ b/.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts @@ -0,0 +1,156 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import { useAppContext } from '@/hooks/useAppContext'; + +/** Engagement counts exposed by NIP-85 kind 30383 (events) and 30384 (addressable events). */ +export interface Nip85EventStats { + commentCount: number; + repostCount: number; + reactionCount: number; + zapCount: number; + /** Zap amount in sats. */ + zapAmount: number; +} + +/** A subset of NIP-85 kind 30382 (user) stats — extend as needed. */ +export interface Nip85UserStats { + followers: number; + postCount: number; +} + +/** + * Read an integer tag value from a NIP-85 assertion event. Returns 0 when missing + * or unparseable, which mirrors the semantics of "no data" in NIP-85. + */ +function getIntTag(tags: string[][], tagName: string): number { + const tag = tags.find(([name]) => name === tagName); + if (!tag?.[1]) return 0; + const n = parseInt(tag[1], 10); + return Number.isFinite(n) ? n : 0; +} + +/** + * Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey. + * Returns `null` when no stats pubkey is configured or the provider has no + * assertion for this event. + */ +export function useNip85EventStats(eventId: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-event-stats', eventId, statsPubkey], + queryFn: async ({ signal }) => { + if (!eventId || !statsPubkey) return null; + + const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]); + + try { + const events = await nostr.query( + [{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }], + { signal: combined }, + ); + + if (events.length === 0) return null; + + const { tags } = events[0]; + return { + commentCount: getIntTag(tags, 'comment_cnt'), + repostCount: getIntTag(tags, 'repost_cnt'), + reactionCount: getIntTag(tags, 'reaction_cnt'), + zapCount: getIntTag(tags, 'zap_cnt'), + zapAmount: getIntTag(tags, 'zap_amount'), + }; + } catch { + return null; + } + }, + enabled: !!eventId && !!statsPubkey, + staleTime: 30 * 1000, + retry: false, + }); +} + +/** + * Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey. + * Returns `null` when no stats pubkey is configured or the provider has no + * assertion for this pubkey. + */ +export function useNip85UserStats(pubkey: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-user-stats', pubkey, statsPubkey], + queryFn: async ({ signal }) => { + if (!pubkey || !statsPubkey) return null; + + const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]); + + try { + const events = await nostr.query( + [{ kinds: [30382], authors: [statsPubkey], '#d': [pubkey], limit: 1 }], + { signal: combined }, + ); + + if (events.length === 0) return null; + + const { tags } = events[0]; + return { + followers: getIntTag(tags, 'followers'), + postCount: getIntTag(tags, 'post_cnt'), + }; + } catch { + return null; + } + }, + enabled: !!pubkey && !!statsPubkey, + staleTime: 60 * 1000, + retry: false, + }); +} + +/** + * Fetches NIP-85 addressable event stats (kind 30384) from the configured + * stats pubkey. The `addr` argument is the full NIP-01 event address string, + * e.g. `30023::`. + */ +export function useNip85AddrStats(addr: string | undefined) { + const { nostr } = useNostr(); + const { config } = useAppContext(); + const statsPubkey = config.nip85StatsPubkey; + + return useQuery({ + queryKey: ['nip85-addr-stats', addr, statsPubkey], + queryFn: async ({ signal }) => { + if (!addr || !statsPubkey) return null; + + const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]); + + try { + const events = await nostr.query( + [{ kinds: [30384], authors: [statsPubkey], '#d': [addr], limit: 1 }], + { signal: combined }, + ); + + if (events.length === 0) return null; + + const { tags } = events[0]; + return { + commentCount: getIntTag(tags, 'comment_cnt'), + repostCount: getIntTag(tags, 'repost_cnt'), + reactionCount: getIntTag(tags, 'reaction_cnt'), + zapCount: getIntTag(tags, 'zap_cnt'), + zapAmount: getIntTag(tags, 'zap_amount'), + }; + } catch { + return null; + } + }, + enabled: !!addr && !!statsPubkey, + staleTime: 30 * 1000, + retry: false, + }); +} diff --git a/.agents/skills/nostr-security/SKILL.md b/.agents/skills/nostr-security/SKILL.md new file mode 100644 index 00000000..70a4fbc1 --- /dev/null +++ b/.agents/skills/nostr-security/SKILL.md @@ -0,0 +1,140 @@ +--- +name: nostr-security +description: Threat model and defenses for Ditto as a web Nostr client — why XSS is catastrophic when nsec keys live in localStorage, CSP as defense-in-depth, URL and CSS sanitization for untrusted event data, and author filtering for trust-sensitive queries (admin actions, moderators, addressable events, NIP-72 communities). Load when building trust-boundary features, rendering user-controlled URLs or markup, interpolating event data into CSS, or reviewing the app's security posture. +--- + +# Nostr Security + +## Threat model + +**Nostr private keys (`nsec`) are stored in plaintext in `localStorage`.** Any JavaScript running on the origin can read them with `localStorage.getItem('nostr-login')`. A successful XSS = instant, silent, irreversible key theft — no rotation, no revocation, permanent impersonation across every Nostr client the user ever touches. External signers (NIP-07 extension, NIP-46 bunker) don't change this: an XSS can still ask the active signer to sign arbitrary events, drain funds via zaps, or scrape DMs as they decrypt. + +**Treat every piece of untrusted data as a script-injection vector** — event tags, `content`, metadata, URL params, relay responses. + +## Defense-in-depth + +**Content Security Policy.** `index.html` ships a restrictive CSP: `default-src 'none'`, `script-src 'self'` (no inline scripts, no `eval`), `base-uri 'self'`, `connect-src 'self' https: wss:`. The one intentional gap is `style-src 'unsafe-inline'` — required by Tailwind/shadcn — which means **CSS injection is not blocked by CSP; sanitization is on you**. When modifying CSP, only narrow it. Never add `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcard sources. + +**Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. React's JSX auto-escapes interpolated strings — the moment you bypass that, CSP alone won't save you. If you must render HTML from event data, pipe it through a strict allowlist sanitizer (DOMPurify, already installed) at the parse layer. + +**Sanitize URLs and CSS values** — see §1 and §2. + +## 1. URL sanitization + +Any URL from event tags, `content`, metadata fields (`picture`, `banner`, `website`, `nip05`, etc.), or relay hints is untrusted. Threats beyond `javascript:` XSS: `data:` resource exhaustion / phishing, `http://` IP leaks, relative paths triggering same-origin requests, malformed strings crashing downstream parsers. + +**Use the shipped helper at `src/lib/sanitizeUrl.ts`:** + +```ts +import { sanitizeUrl } from '@/lib/sanitizeUrl'; + +// Single URL — returns the normalised href, or undefined if not valid https +const url = sanitizeUrl(getTag(event.tags, 'url')); +if (url) { + // safe to use in any context +} + +// Array of URLs — filter out invalid entries +const links = getAllTags(event.tags, 'r') + .map(([, v]) => sanitizeUrl(v)) + .filter((v): v is string => !!v); +``` + +`sanitizeUrl` returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`. + +**Sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site. + +**When sanitization is NOT required:** URLs matched by a regex that constrains the protocol (e.g. `NoteContent`'s tokenizer matching `https?://...` — the regex *is* the sanitizer), hardcoded/app-generated URLs (relay configs, internal routes), and strings rendered as plain text that never land in an attribute, CSS value, or network request. + +## 2. CSS injection + +Event data interpolated into CSS (a `