`) unless the kind's content is actually free-form user prose. Quote-type content (highlights, snippets, citations) contains URLs and hashtags from the *source*, not the event author — tokenizing them is misleading.
+- Render plaintext with `whitespace-pre-wrap break-words` inside a `` instead.
+- Route any event-sourced URLs (`r` tags, media URLs, source links) through `sanitizeUrl()` from `@/lib/sanitizeUrl` before using them in `href`/`src`.
+- Support an `expanded` prop if the card looks different on the detail page than in the feed.
+
+### 2. Feed card dispatch (`src/components/NoteCard.tsx`)
+
+Three edits in this file:
+
+1. **Flag block** (around lines 384–435): add `const isMyKind = event.kind === XXXX;`.
+2. **`isTextNote` negation list** (around lines 440–475): add `&& !isMyKind`. Without this, unknown kinds fall through to `UnknownKindContent` (showing only the `alt` tag).
+3. **Content dispatch ternary** (around lines 578–692): add `) : isMyKind ? (`.
+4. **`KIND_HEADER_MAP`** (around lines 1710+): add an entry so the feed shows "Alice shared a *noun*" or similar. Pattern:
+ ```ts
+ 9802: {
+ icon: Highlighter,
+ action: "shared a",
+ noun: "highlight",
+ nounRoute: "/highlights", // omit if no dedicated page
+ },
+ ```
+5. Import the card component and any new lucide icons.
+
+### 3. Detail page dispatch (`src/pages/PostDetailPage.tsx`)
+
+Mirror the three NoteCard edits:
+
+1. **Flag block** (around lines 1021–1098): `const isMyKind = event.kind === XXXX;`.
+2. **`isTextNote` negation list**: add `&& !isMyKind`.
+3. **Content dispatch ternary** (around lines 2147–2251): add `) : isMyKind ? (`.
+
+The loading-state title uses `shellTitleForKind()`, which falls through to `KIND_LABELS` — no override needed unless the kind belongs to a group ("Music Details") or needs composite grammar.
+
+### 4. Central kind label (`src/lib/kindLabels.ts`)
+
+Add a **capitalized noun phrase, no articles** to the `KIND_LABELS` map:
+
+```ts
+9802: 'Highlight',
+```
+
+This is consumed by the detail-page loading title, nsite permission prompt, signer nudge toasts, and addressable-event preview headers. Ignoring this gives "Kind 9802" everywhere it appears.
+
+### 5. Context-specific label and icon maps
+
+Each of these maps exists because the surrounding UI needs a different grammatical form. They are **not derived** from `KIND_LABELS` and must be updated manually.
+
+- **`src/components/CommentContext.tsx`** — `KIND_LABELS` (uses articles: `'a highlight'`, `'an article'`) and `KIND_ICONS` (lucide component reference). Rendered as "Commenting on {label}". Without an entry you get "an unsupported event".
+- **`src/pages/NotificationsPage.tsx`** — `NOTIFICATION_KIND_NOUNS` (bare lowercase nouns: `'highlight'`, `'article'`). Rendered as "reacted to your {noun}". Without an entry you get "post" as a fallback.
+- **`src/components/NoteCard.tsx`** — `KIND_HEADER_MAP` (already covered in step 2).
+
+### 6. Embedded previews (`src/components/EmbeddedNote.tsx`)
+
+The quote-embed dispatcher in `EmbeddedNote` (around lines 65–110) routes kinds to dedicated compact cards. **Without a branch here, non-content kinds fall through to `EmbeddedNoteCard`, which either:**
+
+- Shows only the NIP-31 `alt` tag (if present), or
+- Tombstones as "This event kind is not supported", or
+- **Feeds the event's `content` through the kind-1 tokenizer** if the kind is mistakenly treated as a content-kind — auto-linkifying URLs and hashtags that weren't authored by the event creator. This is a security/UX bug.
+
+For any kind whose `content` isn't freeform user prose, add an explicit dispatch branch even if it just renders a minimal compact card. Pattern:
+
+```tsx
+if (event.kind === 9802) {
+ return ;
+}
+```
+
+Then define the compact card using `EmbeddedCardShell` for the author row + navigation, and render the kind-specific body inside. See `EmbeddedHighlightCard` and `EmbeddedBadgeAwardCard` for reference.
+
+`src/components/EmbeddedNaddr.tsx` works similarly for addressable kinds — add a branch there if your kind is addressable.
+
+### 7. Feed/sidebar registration (`src/lib/extraKinds.ts`)
+
+Only needed if you decided on "feed-toggle + dedicated page" above. Add an `ExtraKindDef`:
+
+```ts
+{
+ kind: 9802,
+ id: 'highlights',
+ showKey: 'showHighlights',
+ feedKey: 'feedIncludeHighlights',
+ label: 'Highlights',
+ description: 'Noteworthy excerpts from articles, posts, and the web (NIP-84)',
+ route: 'highlights', // omit for feed-only registration
+ addressable: false,
+ section: 'social', // feed | media | social | development | whimsy
+ blurb: 'Longer marketing copy shown in the info modal.',
+},
+```
+
+Then:
+
+- **Sidebar icon** (`src/lib/sidebarItems.tsx`) — add `{ id: "highlights", label: "Highlights", path: "/highlights", icon: Highlighter }` to `SIDEBAR_ITEMS`, and import the icon at the top. `CONTENT_KIND_ICONS` picks up the icon automatically from the sidebar definition.
+- **Route** (`src/AppRouter.tsx`) — add `const highlightsDef = getExtraKindDef("highlights")!;` at the top of the file and a `} />` above the catch-all `*` route.
+
+### 8. `AppConfig` triple (required if you added feed/sidebar toggle keys in step 7)
+
+Three files must stay in sync, or the build fails or the setting silently no-ops:
+
+1. **`src/contexts/AppContext.ts`** — add the fields to the `FeedSettings` interface with JSDoc comments.
+2. **`src/lib/schemas.ts`** — add the same fields to `FeedSettingsSchema` as `z.boolean().optional()`. `DittoConfigSchema` is derived from `AppConfigSchema` with `.strict()` mode, so any `ditto.json` field missing from Zod is a build error.
+3. **`src/App.tsx`** — add the default value in the initial `feedSettings` block.
+4. **`src/test/TestApp.tsx`** — mirror the default in test config so component tests work.
+
+Convention: `show*` toggles default to `true` (sidebar entries visible), `feedInclude*` toggles default to `false` for niche content, `true` for core feed content.
+
+### 9. Notification integration (if applicable)
+
+Load this step when the kind represents an interaction with the user's content (reactions, reposts, highlights, awards, etc.) — i.e. when an event author "does something with" another user's content via an `e`/`a`/`p` tag.
+
+**Six files** to update:
+
+1. **`src/hooks/useEncryptedSettings.ts`** — add `highlights?: boolean` (or equivalent) to the `notificationPreferences` object.
+2. **`src/lib/notificationKinds.ts`** — add the kind to `ALL_NOTIFICATION_KINDS` and add a `if (p.X !== false) kinds.push(XXXX);` line in `getEnabledNotificationKinds`.
+3. **`src/lib/notificationTemplates.ts`** — add a `NOTIFICATION_TEMPLATES` entry with a title and body for nostr-push server-side notifications.
+4. **`src/pages/NotificationSettings.tsx`** — extend `NotificationPrefKey` union, add a row to `NOTIFICATION_TYPES` with icon/label/description/kinds.
+5. **`src/hooks/useNotifications.ts`** — extend `groupKey` (decide if events of this kind group by referenced event or stand alone), and if it's a "did something to your content" kind, add it to the author-ownership filter so users only get notified for interactions with their own content.
+6. **`src/pages/NotificationsPage.tsx`** — add a case to `GroupedNotificationView`'s switch; write `MyKindNotification` + `MyKindNotificationGroup` components modeled on `RepostNotification` / `LikeNotification`.
+
+### 10. Spam guards (`src/lib/feedUtils.ts`)
+
+If the kind has required tags (NIP-spec-mandated references, minimum content, etc.), add a check in `shouldHideFeedEvent` to hide events that don't meet the minimum bar. This pre-filters events before `NoteCard` mounts them, avoiding layout shifts from components that would return `null`.
+
+Example:
+
+```ts
+// NIP-84 highlights with no excerpt AND no source reference.
+if (event.kind === 9802) {
+ const hasContent = event.content.trim().length > 0;
+ const hasSource = event.tags.some(([n]) => n === 'a' || n === 'e' || n === 'r');
+ if (!hasContent && !hasSource) return true;
+}
+```
+
+### 11. `NIP.md` (custom kinds only)
+
+If the kind is a Ditto-custom kind or a Ditto-specific extension of an existing NIP, document it in `NIP.md` — see the **`nostr-kind-design`** skill for the format. Standard NIPs (like NIP-84, NIP-23) do not go in `NIP.md`.
+
+## Validation
+
+After making changes, run `npm run test` — it runs `tsc --noEmit`, `eslint`, `vitest`, and `vite build` in sequence. All must pass. Additions to the `AppConfig` triple in particular frequently break the build if one of the four files is missed.
+
+## Why so many locations?
+
+These are genuinely different UI contexts (feed cards, detail pages, embedded previews, comment-context labels, notifications, sidebar routes) with different rendering requirements and grammar needs. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common "what to call this kind" case, but feed headers, comment-context text, and notification verbs each need their own grammar, and notification integration involves a whole independent subsystem.
+
+## Bugs that signal a missed step
+
+- **"Kind 12345" shown as a label** → step 4 (`KIND_LABELS`).
+- **"an unsupported event" in CommentContext** → step 5 (`CommentContext` maps).
+- **"reacted to your **post**"** when it should say "highlight" → step 5 (`NOTIFICATION_KIND_NOUNS`).
+- **No action header above a feed card** → step 2.4 (`KIND_HEADER_MAP`).
+- **Blank / `alt`-only card in quote embeds** → step 6 (`EmbeddedNote` dispatcher).
+- **URLs/hashtags in quoted text auto-linkified** → step 6 (embedded dispatcher forgot to bypass the kind-1 tokenizer).
+- **Kind doesn't appear in the home feed even with the toggle on** → step 7 (`ExtraKindDef` missing `feedKey`).
+- **Build error mentioning a missing `FeedSettings` field** → step 8 (one of the three files out of sync).
+- **Users not notified when their content is interacted with** → step 9 (notification stack).
diff --git a/.agents/skills/nostr-publishing/SKILL.md b/.agents/skills/nostr-publishing/SKILL.md
new file mode 100644
index 00000000..cccb99e3
--- /dev/null
+++ b/.agents/skills/nostr-publishing/SKILL.md
@@ -0,0 +1,115 @@
+---
+name: nostr-publishing
+description: Publish Nostr events with useNostrPublish. Covers the basic publishing pattern, safely mutating replaceable and addressable events (read-modify-write via fetchFreshEvent + prev), published_at preservation, and d-tag collision prevention for new addressable content.
+---
+
+# Publishing Nostr Events
+
+Use this skill when a feature needs to publish events — notes, reactions, list updates, profile edits, addressable content, etc. Covers the `useNostrPublish` hook, the correct read-modify-write pattern for replaceable/addressable lists, and d-tag collision prevention.
+
+## The `useNostrPublish` Hook
+
+`useNostrPublish` publishes an event through the app's connection pool and auto-adds a `client` tag. Always guard calls with `useCurrentUser` — publishing requires a signer.
+
+```tsx
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useNostrPublish } from '@/hooks/useNostrPublish';
+
+export function PostForm() {
+ const { user } = useCurrentUser();
+ const { mutate: createEvent } = useNostrPublish();
+
+ if (!user) return You must be logged in to post.;
+
+ return (
+
+ );
+}
+```
+
+Prefer `mutateAsync` over `mutate` when the caller needs to `await` the published event (e.g. to navigate to the new event's page, or to chain another publish).
+
+## Mutating Replaceable and Addressable Events (CRITICAL)
+
+Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a **read-modify-write** cycle: fetch the current event, modify its tags, publish a new version. **Never read from the TanStack Query cache before mutating** — the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
+
+Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
+
+```typescript
+import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
+
+// Inside a mutation function:
+const prev = await fetchFreshEvent(nostr, {
+ kinds: [10003],
+ authors: [user.pubkey],
+});
+const currentTags = prev?.tags ?? [];
+// ...modify tags...
+await publishEvent({
+ kind: 10003,
+ content: prev?.content ?? '',
+ tags: newTags,
+ prev: prev ?? undefined,
+});
+```
+
+This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
+
+### The `prev` Property on Event Templates
+
+`useNostrPublish` accepts an optional `prev` property on the event template — the **previous version** of the event being replaced. The hook uses it to manage the `published_at` tag (NIP-24) automatically:
+
+- **First publish (no `prev`)** — `published_at` is set equal to `created_at`.
+- **Update (`prev` provided)** — `published_at` is preserved from the old event.
+- **Old event lacks `published_at`** — nothing is fabricated.
+- **Caller already set `published_at` in tags** — left alone.
+
+**Convention**: name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
+
+```typescript
+const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
+// ...
+await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
+```
+
+`prev` is stripped from the template before signing — it never appears in the published Nostr event.
+
+## D-Tag Collision Prevention for Addressable Events
+
+Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
+
+### When to check for collisions
+
+- **Must check** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.).
+- **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with an embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
+
+### Implementation pattern
+
+Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
+
+```typescript
+// Before publishing a new addressable event:
+const slug = slugify(title, { lower: true, strict: true });
+
+const existing = await nostr.query([
+ { kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
+]);
+
+if (existing.length > 0) {
+ toast({
+ title: 'Slug already in use',
+ description: 'Change the slug or edit the existing item.',
+ variant: 'destructive',
+ });
+ return;
+}
+
+// Safe to publish
+publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
+```
+
+**Skip the check in edit mode** — when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
+
+Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
diff --git a/.agents/skills/nostr-queries/SKILL.md b/.agents/skills/nostr-queries/SKILL.md
new file mode 100644
index 00000000..0ac7bf16
--- /dev/null
+++ b/.agents/skills/nostr-queries/SKILL.md
@@ -0,0 +1,117 @@
+---
+name: nostr-queries
+description: Query Nostr events efficiently with useNostr + TanStack Query. Covers the standard useQuery pattern, combining related kinds into a single request to avoid rate limiting, and validating events with required tags or strict schemas.
+---
+
+# Querying Nostr Events
+
+Use this skill when building a hook that fetches Nostr events. Covers the standard `useNostr` + `useQuery` pattern, efficient query design (combining kinds to avoid relay round-trips), and event validation for kinds with required tags.
+
+## The Standard Pattern
+
+Combine `useNostr` with TanStack Query in a custom hook. Pass the abort signal from `c.signal` into `nostr.query` so cancelled queries free relay resources:
+
+```typescript
+import { useNostr } from '@nostrify/react';
+import { useQuery } from '@tanstack/react-query';
+
+function usePosts() {
+ const { nostr } = useNostr();
+
+ return useQuery({
+ queryKey: ['posts'],
+ queryFn: async (c) => {
+ const events = await nostr.query(
+ [{ kinds: [1], limit: 20 }],
+ { signal: c.signal },
+ );
+ return events;
+ },
+ });
+}
+```
+
+Transform events into a domain model inside the `queryFn` if needed — callers should rarely see raw `NostrEvent`s. Multiple calls to `nostr.query()` inside one `queryFn` are fine for compound queries that can't be expressed as a single filter.
+
+## Efficient Query Design
+
+**Always minimize the number of separate round-trips** to relays. Each query consumes relay capacity and may count against rate limits.
+
+**✅ Efficient — single query with multiple kinds:**
+
+```typescript
+// Query repost variants in one request
+const events = await nostr.query([{
+ kinds: [1, 6, 16],
+ '#e': [eventId],
+ limit: 150,
+}]);
+
+// Separate by kind in JavaScript
+const notes = events.filter((e) => e.kind === 1);
+const reposts = events.filter((e) => e.kind === 6);
+const genericReposts = events.filter((e) => e.kind === 16);
+```
+
+**❌ Inefficient — three separate round-trips:**
+
+```typescript
+const [notes, reposts, genericReposts] = await Promise.all([
+ nostr.query([{ kinds: [1], '#e': [eventId] }]),
+ nostr.query([{ kinds: [6], '#e': [eventId] }]),
+ nostr.query([{ kinds: [16], '#e': [eventId] }]),
+]);
+```
+
+### Optimization rules
+
+1. **Combine kinds** into one filter: `kinds: [1, 6, 16]`.
+2. **Use multiple filter objects** in a single `nostr.query()` call when different tag filters are needed simultaneously.
+3. **Raise the `limit`** when combining kinds so you still receive enough of each type.
+4. **Split by kind in JavaScript**, not by making separate requests.
+5. **Respect relay capacity** — heavy parallel queries can trigger rate limits even when each individually would be fine.
+
+## Event Validation
+
+For kinds with required tags or strict schemas (most custom kinds, anything beyond kind 1), filter query results through a validator before returning them. Loose kinds (kind 1 text notes) rarely need validation — all tags are optional and `content` is freeform.
+
+```typescript
+import type { NostrEvent } from '@nostrify/nostrify';
+
+// Example validator for NIP-52 calendar events
+function validateCalendarEvent(event: NostrEvent): boolean {
+ if (![31922, 31923].includes(event.kind)) return false;
+
+ const d = event.tags.find(([n]) => n === 'd')?.[1];
+ const title = event.tags.find(([n]) => n === 'title')?.[1];
+ const start = event.tags.find(([n]) => n === 'start')?.[1];
+ if (!d || !title || !start) return false;
+
+ // Date-based events require YYYY-MM-DD
+ if (event.kind === 31922 && !/^\d{4}-\d{2}-\d{2}$/.test(start)) return false;
+
+ // Time-based events require a unix timestamp
+ if (event.kind === 31923) {
+ const ts = parseInt(start);
+ if (isNaN(ts) || ts <= 0) return false;
+ }
+
+ return true;
+}
+
+function useCalendarEvents() {
+ const { nostr } = useNostr();
+ return useQuery({
+ queryKey: ['calendar-events'],
+ queryFn: async (c) => {
+ const events = await nostr.query(
+ [{ kinds: [31922, 31923], limit: 20 }],
+ { signal: c.signal },
+ );
+ return events.filter(validateCalendarEvent);
+ },
+ });
+}
+```
+
+Validation is a correctness layer, not a security layer. For trust-sensitive queries (admin actions, addressable events, moderator approvals), also constrain `authors` — see the `nostr-security` skill.
diff --git a/.agents/skills/nostr-relay-pools/SKILL.md b/.agents/skills/nostr-relay-pools/SKILL.md
new file mode 100644
index 00000000..960902f8
--- /dev/null
+++ b/.agents/skills/nostr-relay-pools/SKILL.md
@@ -0,0 +1,92 @@
+---
+name: nostr-relay-pools
+description: Query or publish to specific Nostr relays or curated relay groups using nostr.relay() and nostr.group(), instead of the default connection pool. Useful for debugging, testing, specialized relays, or geographically-targeted publishing.
+---
+
+# Targeted Nostr Relay Connections
+
+By default, the `nostr` object returned from `useNostr` uses the app's connection pool: it reads from one of the configured relays and publishes to all of them. For most features this is exactly what you want.
+
+Use this skill when you need **more granular control** — talking to a single relay, a curated group of relays, or debugging a specific relay's behavior.
+
+## Single Relay: `nostr.relay(url)`
+
+```ts
+import { useNostr } from '@nostrify/react';
+
+function useSpecificRelay() {
+ const { nostr } = useNostr();
+
+ // Connect to a specific relay
+ const relay = nostr.relay('wss://relay.damus.io');
+
+ // Query from this relay only
+ const events = await relay.query([{ kinds: [1], limit: 15 }]);
+
+ // Publish to this relay only
+ await relay.event({ kind: 1, content: 'Hello from a specific relay!' });
+}
+```
+
+**Good fits:**
+
+- Testing a relay's behavior in isolation
+- Debugging connectivity or rate-limiting issues
+- Querying content that only lives on a specialized relay (paid relays, private relays, niche communities)
+- Health checks / admin tooling
+
+## Relay Group: `nostr.group(urls)`
+
+```ts
+import { useNostr } from '@nostrify/react';
+
+function useRelayGroup() {
+ const { nostr } = useNostr();
+
+ // Create a group of specific relays
+ const relayGroup = nostr.group([
+ 'wss://relay.damus.io',
+ 'wss://relay.primal.net',
+ 'wss://nos.lol',
+ ]);
+
+ // Query from all relays in the group (deduplicated)
+ const events = await relayGroup.query([{ kinds: [1], limit: 15 }]);
+
+ // Publish to all relays in the group
+ await relayGroup.event({ kind: 1, content: 'Hello from a relay group!' });
+}
+```
+
+**Good fits:**
+
+- Publishing to a curated set of trusted relays for a specific feature
+- Community-scoped queries (e.g. a set of relays known to host a particular topic)
+- Geographic/region-targeted delivery
+- Load-balancing reads across a known-good subset
+
+## API Consistency
+
+Both the `relay` object and the `group` object expose the **same interface** as the top-level `nostr` object:
+
+- `.query(filters, opts?)` — request events matching filters
+- `.req(filters, opts?)` — open a streaming subscription
+- `.event(event)` — publish a signed event
+- All other Nostrify methods
+
+This means you can drop them into any existing hook or helper that expects a `nostr`-shaped object.
+
+## Choosing Between Pool, Group, and Single Relay
+
+| Scenario | Use |
+|----------------------------------------------------|---------------------|
+| Default app queries, best reach for publishing | `nostr` (pool) |
+| Trusted subset, community-specific publishing | `nostr.group([…])` |
+| Single-relay debugging or specialized relay access | `nostr.relay(url)` |
+
+## Tips
+
+- **Don't hard-code user-facing relay lists.** If a feature should publish to "the user's write relays", read from `AppContext.config.relayMetadata` (NIP-65) instead of hard-coding URLs.
+- **Compose with TanStack Query.** Wrap `relay.query(...)` / `group.query(...)` inside a `useQuery` hook exactly as you would with the default `nostr` object; the caching layer is identical.
+- **Handle unreachable relays.** Specific relays can be offline, rate-limited, or slow. Always wrap calls in `try/catch` and respect the abort signal from the query function (`c.signal`).
+- **Avoid leaking subscriptions.** When using `.req(...)` for streaming, always close the subscription on unmount (`controller.abort()` or the returned disposer).
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 `