Port nostr-security, testing, and nip85-stats skills from mkstack
Adds three new skills extracted from mkstack's restructured AGENTS.md and trims the corresponding AGENTS.md sections to match. - nostr-security: XSS threat model, URL and CSS sanitization patterns, author filtering for trust-sensitive queries, NIP-72 moderation walkthrough, and a pre-merge checklist. The skill's references to sanitizeUrl and sanitizeCssString are pointed at Ditto's existing helpers in src/lib/sanitizeUrl.ts and src/lib/fontLoader.ts. - testing: Vitest + TestApp conventions, mocked browser APIs, and the project policy on when (not) to create new test files. - nip85-stats: reference documentation for NIP-85 Trusted Assertion stats (kinds 30382, 30383, 30384) including a ready-to-copy useNip85Stats hook for future use; not currently wired into Ditto. AGENTS.md changes: - Shrink the Nostr Security Model section from a verbose kinds-and-URLs walkthrough into a compact rule list plus a spoof-vs-authors example, with a pointer to the new skill. - Trim the Writing Tests section to the policy + skill pointer, moving the TestApp example and browser-API mocks into the skill. - Demote Loading States / Empty States from a top-level section to a subsection under CRITICAL Design Standards so the document's top-level headings describe domains, not presentation details. Net: AGENTS.md 1654 -> 1480 lines (~10%).
This commit is contained in:
@@ -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 | `<kind>:<pubkey>:<d-tag>` |
|
||||
|
||||
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<AppConfig>;
|
||||
```
|
||||
|
||||
### 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 <span>{stats.followers.toLocaleString()} followers</span>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<div className="flex gap-3 text-sm text-muted-foreground">
|
||||
<span>{stats.reactionCount} reactions</span>
|
||||
<span>{stats.repostCount} reposts</span>
|
||||
<span>{stats.commentCount} comments</span>
|
||||
<span>{stats.zapAmount} sats</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Addressable event stats (kind 30384)
|
||||
|
||||
The `addr` argument is the full NIP-01 event address `<kind>:<pubkey>:<d-tag>`:
|
||||
|
||||
```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 <span>{stats.reactionCount} reactions</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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 (
|
||||
<input
|
||||
value={config.nip85StatsPubkey}
|
||||
onChange={(e) => {
|
||||
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 (`<kind>:<pubkey>:<d-tag>`)
|
||||
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate)
|
||||
@@ -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<Nip85EventStats | null>({
|
||||
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<Nip85UserStats | null>({
|
||||
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:<pubkey>:<d-tag>`.
|
||||
*/
|
||||
export function useNip85AddrStats(addr: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85EventStats | null>({
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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 `<style>` element, `style=""`, or an injected stylesheet) is a CSS injection vector. A `"`, `)`, `}`, or `;` in the value can break out of the string context and inject rules — overlay phishing, hide UI, exfiltrate via `background-image: url()` requests.
|
||||
|
||||
Common surfaces: `background-image: url("${url}")`, `font-family: "${family}"`, `@font-face { src: url("${url}") }`.
|
||||
|
||||
**Mitigation:**
|
||||
|
||||
- **URLs in `url()`** — use `sanitizeUrl()`. The `URL` constructor percent-encodes `"`, `)`, `\` and rejects non-`https:`. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
|
||||
- **Non-URL strings** (font-family, animation names) — use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which allowlists Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, and periods:
|
||||
|
||||
```ts
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
|
||||
// ❌ UNSAFE
|
||||
style.textContent = `body { background-image: url("${rawUrl}"); font-family: "${rawFamily}"; }`;
|
||||
|
||||
// ✅ SAFE — validate URLs, allowlist identifiers
|
||||
const bgUrl = sanitizeUrl(rawUrl);
|
||||
const family = sanitizeCssString(rawFamily ?? '');
|
||||
if (bgUrl && family) {
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); font-family: "${family}"; }`;
|
||||
}
|
||||
```
|
||||
|
||||
If you can't justify the exact characters you're allowing, the policy is wrong.
|
||||
|
||||
## 3. Author filtering for trust-sensitive queries
|
||||
|
||||
Even with perfect XSS defenses, an attacker can publish forged events your UI will trust unless queries constrain `authors`. Relays are dumb pipes — any matching event comes back.
|
||||
|
||||
**Filter by `authors` when:**
|
||||
|
||||
- Querying admin/moderator/owner events — use a hardcoded trusted-pubkey list (e.g. `ADMIN_PUBKEYS` from `src/lib/admins`).
|
||||
- Querying addressable events (kinds 30000–39999) — the `d` tag alone is not a trust boundary; the `(kind, pubkey, d)` triple is.
|
||||
- Querying user-owned replaceable events (profile metadata, relay lists, mute lists) — `authors: [userPubkey]`.
|
||||
|
||||
**Do NOT filter by `authors`** for public UGC (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design.
|
||||
|
||||
```ts
|
||||
// ❌ Anyone can publish kind 30078 with this d-tag and self-appoint
|
||||
nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
|
||||
// ✅ Only trust the admin list
|
||||
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
```
|
||||
|
||||
**Routes for addressable/replaceable events must include the author** — otherwise the route handler can't construct a secure filter:
|
||||
|
||||
```tsx
|
||||
// ❌ Any pubkey can squat the slug
|
||||
<Route path="/article/:slug" element={<Article />} />
|
||||
// ✅ Filter can include authors
|
||||
<Route path="/article/:npub/:slug" element={<Article />} />
|
||||
```
|
||||
|
||||
### NIP-72 community moderation
|
||||
|
||||
Kind 4550 approvals are only trustworthy if signed by a moderator from the community definition (kind 34550). Two-step query:
|
||||
|
||||
```ts
|
||||
// 1. Fetch community definition — author-filter by the owner.
|
||||
const [community] = await nostr.query([{
|
||||
kinds: [34550], authors: [communityOwnerPubkey], '#d': [communityId], limit: 1,
|
||||
}]);
|
||||
if (!community) return [];
|
||||
|
||||
// 2. Extract moderator pubkeys from `p` tags with role "moderator".
|
||||
const moderators = community.tags
|
||||
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
|
||||
.map(([, pubkey]) => pubkey);
|
||||
|
||||
// 3. Query approvals — only from moderators.
|
||||
const approvals = await nostr.query([{
|
||||
kinds: [4550],
|
||||
authors: moderators,
|
||||
'#a': [`34550:${communityOwnerPubkey}:${communityId}`],
|
||||
limit: 100,
|
||||
}]);
|
||||
```
|
||||
|
||||
Without step 3's `authors` filter, anyone can publish a kind 4550 "approval".
|
||||
|
||||
## Pre-merge checklist
|
||||
|
||||
- [ ] No `dangerouslySetInnerHTML` / `innerHTML` / `document.write` with untrusted data.
|
||||
- [ ] CSP unchanged or narrowed; no new `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcards.
|
||||
- [ ] Every event-sourced URL passes `sanitizeUrl()` before reaching `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS.
|
||||
- [ ] Every event-sourced string in CSS passes `sanitizeUrl()` (URLs) or `sanitizeCssString()` (identifiers).
|
||||
- [ ] Every trust-sensitive query includes `authors`.
|
||||
- [ ] Routes for addressable/replaceable events carry the author in the URL.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: testing
|
||||
description: Write Vitest unit tests for React components and hooks using the project's `TestApp` wrapper, jsdom environment, and pre-mocked browser APIs (localStorage, matchMedia, scrollTo, IntersectionObserver, ResizeObserver). Also covers the project policy on when to create new test files.
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
Load this skill when the user asks you to write a test, diagnose a bug with a test, or add coverage for a component/hook. Running the existing test script is a standing requirement (see `AGENTS.md` → *Validating Your Changes*) and doesn't require this skill.
|
||||
|
||||
## Policy: when to create new test files
|
||||
|
||||
**Do not create new test files unless one of these applies:**
|
||||
|
||||
1. The user explicitly asks for tests.
|
||||
2. The user describes a specific bug and asks for tests to diagnose it.
|
||||
3. The user says a problem persists after you tried to fix it.
|
||||
|
||||
Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature. The request must come from the user.
|
||||
|
||||
If none of the above apply, stop — don't create a test file. Keep running the existing test script as usual.
|
||||
|
||||
## Test setup
|
||||
|
||||
The project uses **Vitest + jsdom** with **React Testing Library** and **jest-dom** matchers. Global setup lives in `src/test/setup.ts` and mocks these browser APIs that jsdom doesn't provide (or that Node's built-ins conflict with):
|
||||
|
||||
- `localStorage` — a Map-backed mock, because Node 22's built-in `localStorage` lacks the Web Storage API surface jsdom expects
|
||||
- `window.matchMedia`
|
||||
- `window.scrollTo`
|
||||
- `IntersectionObserver`
|
||||
- `ResizeObserver`
|
||||
|
||||
If your component needs another browser API, extend `src/test/setup.ts` rather than mocking per-file.
|
||||
|
||||
## Writing a component test
|
||||
|
||||
Wrap rendered components in `TestApp` (`src/test/TestApp.tsx`) so all context providers — `UnheadProvider`, `AppProvider`, `QueryClientProvider`, `NostrLoginProvider`, `NostrProvider`, `BrowserRouter`, etc. — are available. Without it, hooks like `useQuery`, `useNostr`, `useAppContext`, or `useNavigate` will throw.
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import { MyComponent } from './MyComponent';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<TestApp><MyComponent /></TestApp>);
|
||||
expect(screen.getByText('Expected text')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Writing a hook test
|
||||
|
||||
Use `renderHook` from `@testing-library/react` and pass `TestApp` as the `wrapper`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import { useMyHook } from './useMyHook';
|
||||
|
||||
describe('useMyHook', () => {
|
||||
it('returns expected data', async () => {
|
||||
const { result } = renderHook(() => useMyHook(), { wrapper: TestApp });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Files placed next to the code under test with the `.test.ts` / `.test.tsx` suffix are picked up automatically. For reference, see `src/test/ErrorBoundary.test.tsx`.
|
||||
|
||||
## Running tests
|
||||
|
||||
The `npm test` script runs `tsc --noEmit`, `eslint`, `vitest run`, and `vite build` in sequence. Always run it after changes — a passing test file alone doesn't mean your task is done.
|
||||
|
||||
For fast iteration, run just Vitest:
|
||||
|
||||
```bash
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Or in watch mode while editing:
|
||||
|
||||
```bash
|
||||
npx vitest
|
||||
```
|
||||
@@ -313,169 +313,26 @@ Whenever new kinds are generated, the `NIP.md` file in the project must be creat
|
||||
|
||||
### Nostr Security Model
|
||||
|
||||
**CRITICAL**: Nostr is permissionless - **anyone can publish any event**. When implementing admin/moderation systems or any feature that should only trust specific users, you MUST filter queries by the `authors` field. Without author filtering, anyone can publish events claiming to be admin actions, moderator decisions, or trusted content.
|
||||
Nostr is permissionless — **anyone can publish any event**, and `nsec` keys sit in plaintext `localStorage`, so an XSS is an instant key-theft. Core rules:
|
||||
|
||||
#### Using the `authors` Filter
|
||||
- **Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. If HTML must come from event data, run it through DOMPurify at the parse layer.
|
||||
- **Sanitize every event-sourced URL** with `sanitizeUrl()` from `@/lib/sanitizeUrl` before it lands in `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS `url()`. It returns `undefined` for anything that isn't a well-formed `https:` URL. Prefer sanitizing at the parse layer, not the render site.
|
||||
- **Sanitize event-sourced strings interpolated into CSS** with `sanitizeCssString()` from `@/lib/fontLoader` (allowlists Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, periods). URLs in CSS `url()` still go through `sanitizeUrl()`.
|
||||
- **Filter trust-sensitive queries by `authors`**. Without it, any event matching your kind/d-tag comes back — an attacker publishes a fake admin action and your UI trusts it.
|
||||
- **Routes for addressable/replaceable events must carry the author in the path** (e.g. `/article/:npub/:slug`), so the route handler can include `authors` in its filter.
|
||||
- **Don't filter by `authors` for public UGC** (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design.
|
||||
|
||||
**Always filter by authors when querying:**
|
||||
- **Admin/moderator actions** - MUST filter by trusted admin pubkeys
|
||||
- **Addressable events (kinds 30000-39999)** - MUST include author to prevent anyone from publishing events with the same d-tag
|
||||
- **Any privileged operations** - Filter by trusted pubkeys only
|
||||
|
||||
**✅ Secure - Filtering by trusted authors:**
|
||||
```typescript
|
||||
import { ADMIN_PUBKEYS } from '@/lib/admins';
|
||||
|
||||
// Query organizer appointments - ONLY accept events from admins
|
||||
const events = await nostr.query([{
|
||||
kinds: [30078],
|
||||
authors: ADMIN_PUBKEYS, // CRITICAL: Only trust admin authors
|
||||
'#d': ['pathos-organizers'],
|
||||
limit: 1
|
||||
}]);
|
||||
// ❌ Anyone can publish kind 30078 with this d-tag and self-appoint as an organizer
|
||||
nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
|
||||
// ✅ Only trust the admin list
|
||||
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
```
|
||||
|
||||
**❌ INSECURE - No author filtering:**
|
||||
```typescript
|
||||
// DANGER: This accepts events from ANYONE who publishes kind 30078
|
||||
// An attacker could appoint themselves as an organizer
|
||||
const events = await nostr.query([{
|
||||
kinds: [30078],
|
||||
'#d': ['pathos-organizers'],
|
||||
limit: 1
|
||||
}]);
|
||||
```
|
||||
|
||||
**Addressable Events Example:**
|
||||
```typescript
|
||||
// For addressable events, ALWAYS include the author in your filter
|
||||
// This prevents attackers from publishing events with the same d-tag
|
||||
const article = await nostr.query([{
|
||||
kinds: [30023], // Long-form article
|
||||
authors: [authorPubkey], // CRITICAL: Verify the author
|
||||
'#d': ['my-article-slug'],
|
||||
limit: 1
|
||||
}]);
|
||||
```
|
||||
|
||||
**URL Routing for Addressable/Replaceable Events:**
|
||||
|
||||
When creating URL paths for addressable or replaceable events, always include the author in the URL structure:
|
||||
|
||||
```typescript
|
||||
// ❌ INSECURE: Missing author - anyone could publish an event with this d-tag
|
||||
<Route path="/article/:slug" element={<Article />} />
|
||||
// URL: /article/hello-world
|
||||
|
||||
// ✅ SECURE: Includes author - can safely filter by both author and d-tag
|
||||
<Route path="/article/:npub/:slug" element={<Article />} />
|
||||
// URL: /article/npub1abc.../hello-world
|
||||
```
|
||||
|
||||
This ensures your route parameters provide both the author pubkey and the d-tag identifier needed to create a secure query filter.
|
||||
|
||||
**NIP-72 Community Moderation Example:**
|
||||
|
||||
When implementing moderated communities (NIP-72), you must query the community definition to get the moderator list, then filter approval events by those moderators:
|
||||
|
||||
```typescript
|
||||
// Step 1: Query the community definition to get moderators
|
||||
const communityEvents = await nostr.query([{
|
||||
kinds: [34550],
|
||||
authors: [communityOwnerPubkey], // CRITICAL: Only trust the community owner
|
||||
'#d': [communityId],
|
||||
limit: 1,
|
||||
}]);
|
||||
|
||||
if (communityEvents.length === 0) return [];
|
||||
|
||||
// Step 2: Extract moderator pubkeys from p tags
|
||||
const moderatorPubkeys = communityEvents[0].tags
|
||||
.filter(([name, _, __, role]) => name === 'p' && role === 'moderator')
|
||||
.map(([_, pubkey]) => pubkey);
|
||||
|
||||
// Step 3: Query approval events - ONLY from trusted moderators
|
||||
const approvals = await nostr.query([{
|
||||
kinds: [4550],
|
||||
authors: moderatorPubkeys, // CRITICAL: Only accept approvals from moderators
|
||||
'#a': [`34550:${communityOwnerPubkey}:${communityId}`],
|
||||
limit: 100,
|
||||
}]);
|
||||
```
|
||||
|
||||
Without filtering approvals by the moderator list, anyone could publish kind 4550 events claiming to approve posts for the community.
|
||||
|
||||
#### When Author Filtering Is NOT Required
|
||||
|
||||
Author filtering is not needed for public user-generated content where anyone should be able to post (kind 1 notes, reactions, discovery queries, public feeds, etc.).
|
||||
|
||||
#### Sanitizing URLs from Event Data
|
||||
|
||||
**CRITICAL**: Any URL extracted from Nostr event tags, content, or metadata fields is **untrusted user input**. Malicious URLs can cause harm in many ways beyond `javascript:` XSS — `data:` URIs for resource exhaustion, `http://` URLs leaking user IPs without TLS, relative paths triggering unintended requests to the app's own origin, and more. Reasoning about which rendering context is "safe enough" to skip sanitization is fragile and error-prone.
|
||||
|
||||
**Rule: sanitize every event-sourced URL unconditionally**, regardless of where it will be used (`href`, `img src`, `style`, etc.). Use `sanitizeUrl()` from `@/lib/sanitizeUrl`:
|
||||
|
||||
```typescript
|
||||
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` accepts `string | undefined | null` and 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`.
|
||||
|
||||
**Best practice — 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 extracted by regex that already constrains the protocol (e.g. `NoteContent` tokeniser matches only `https?://`)
|
||||
- Hardcoded or application-generated URLs (relay configs, internal routes, etc.)
|
||||
- URLs displayed as plain text without being placed into any HTML attribute or CSS value
|
||||
|
||||
#### Preventing CSS Injection from Event Data
|
||||
|
||||
**CRITICAL**: Any value from a Nostr event that is interpolated into a CSS string (inside a `<style>` element or inline `style` attribute) is a CSS injection vector. A malicious value containing `"`, `)`, `}`, or `;` can break out of the CSS context and inject arbitrary rules — for example, overlaying phishing content or hiding UI elements.
|
||||
|
||||
**Common CSS injection surfaces:**
|
||||
- `background-image: url("${url}")` — a URL with `"); body { display:none }` breaks out
|
||||
- `font-family: "${family}"` — a family name with `"; } body { visibility:hidden } .x {` breaks out
|
||||
- `@font-face { src: url("${url}") }` — same risk as background URLs
|
||||
|
||||
**Mitigation strategy — sanitize at the parse layer:**
|
||||
|
||||
1. **URLs in CSS `url()` values**: Pass through `sanitizeUrl()` at parse time. The `URL` constructor normalises the string, percent-encoding characters like `"`, `)`, and `\` that could escape the CSS context. Invalid or non-`https:` URLs are rejected entirely. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
|
||||
|
||||
2. **Strings in CSS declarations** (e.g. font family names): Use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens, underscores, apostrophes, and periods are permitted. Everything else is stripped.
|
||||
|
||||
```typescript
|
||||
// ❌ UNSAFE — raw event data interpolated into CSS
|
||||
const bgUrl = getTagValue(event.tags, 'bg');
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); }`;
|
||||
|
||||
const family = getTagValue(event.tags, 'f');
|
||||
style.textContent = `html { font-family: "${family}"; }`;
|
||||
|
||||
// ✅ SAFE — URLs validated, strings sanitised
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
const bgUrl = sanitizeUrl(getTagValue(event.tags, 'bg'));
|
||||
if (bgUrl) {
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); }`;
|
||||
}
|
||||
|
||||
// For non-URL strings, allowlist safe characters only
|
||||
const safeFamily = family.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
|
||||
style.textContent = `html { font-family: "${safeFamily}"; }`;
|
||||
```
|
||||
|
||||
**Rule of thumb**: Never interpolate untrusted strings into CSS without sanitisation. If it's a URL, use `sanitizeUrl()`. If it's any other string, strip characters that can break out of the CSS string context.
|
||||
Load the **`nostr-security` skill** for the full threat model, NIP-72 moderation walkthrough, sanitization helper examples, and the pre-merge checklist.
|
||||
|
||||
### The `useNostr` Hook
|
||||
|
||||
@@ -1164,7 +1021,15 @@ The router includes automatic scroll-to-top functionality and a 404 NotFound pag
|
||||
- Comprehensive provider setup with NostrLoginProvider, QueryClientProvider, and custom AppProvider
|
||||
- **Never use the `any` type**: Always use proper TypeScript types for type safety
|
||||
|
||||
## Loading States
|
||||
## CRITICAL Design Standards
|
||||
|
||||
- Create breathtaking, immersive designs that feel like bespoke masterpieces, rivaling the polish of Apple, Stripe, or luxury brands
|
||||
- Designs must be production-ready, fully featured, with no placeholders unless explicitly requested, ensuring every element serves a functional and aesthetic purpose
|
||||
- Avoid generic or templated aesthetics at all costs; every design must have a unique, brand-specific visual signature that feels custom-crafted
|
||||
- Headers must be dynamic, immersive, and storytelling-driven, using layered visuals, motion, and symbolic elements to reflect the brand’s identity—never use simple “icon and text” combos
|
||||
- Incorporate purposeful, lightweight animations for scroll reveals, micro-interactions (e.g., hover, click, transitions), and section transitions to create a sense of delight and fluidity
|
||||
|
||||
### Loading States
|
||||
|
||||
**Use skeleton loading** for structured content (feeds, profiles, forms). **Use spinners** only for buttons or short operations.
|
||||
|
||||
@@ -1189,7 +1054,7 @@ The router includes automatic scroll-to-top functionality and a 404 NotFound pag
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Empty States and No Content Found
|
||||
#### Empty States and No Content Found
|
||||
|
||||
When no content is found (empty search results, no data available, etc.), display a minimalist empty state with helpful messaging. The application uses NIP-65 relay management, so users can manage their relays through the settings or relay management interface.
|
||||
|
||||
@@ -1210,14 +1075,6 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
</div>
|
||||
```
|
||||
|
||||
## CRITICAL Design Standards
|
||||
|
||||
- Create breathtaking, immersive designs that feel like bespoke masterpieces, rivaling the polish of Apple, Stripe, or luxury brands
|
||||
- Designs must be production-ready, fully featured, with no placeholders unless explicitly requested, ensuring every element serves a functional and aesthetic purpose
|
||||
- Avoid generic or templated aesthetics at all costs; every design must have a unique, brand-specific visual signature that feels custom-crafted
|
||||
- Headers must be dynamic, immersive, and storytelling-driven, using layered visuals, motion, and symbolic elements to reflect the brand’s identity—never use simple “icon and text” combos
|
||||
- Incorporate purposeful, lightweight animations for scroll reveals, micro-interactions (e.g., hover, click, transitions), and section transitions to create a sense of delight and fluidity
|
||||
|
||||
### Design Principles
|
||||
|
||||
- Achieve Apple-level refinement with meticulous attention to detail, ensuring designs evoke strong emotions (e.g., wonder, inspiration, energy) through color, motion, and composition
|
||||
@@ -1331,53 +1188,22 @@ There is an important distinction between **writing new tests** and **running ex
|
||||
|
||||
**Do not write tests** unless the user explicitly requests them in plain language. Writing unnecessary tests wastes significant time and money. Only create tests when:
|
||||
|
||||
1. **The user explicitly asks for tests** to be written in their message
|
||||
2. **The user describes a specific bug in plain language** and requests tests to help diagnose it
|
||||
3. **The user says they are still experiencing a problem** that you have already attempted to solve (tests can help verify the fix)
|
||||
1. The user explicitly asks for tests to be written in their message
|
||||
2. The user describes a specific bug in plain language and requests tests to help diagnose it
|
||||
3. The user says they are still experiencing a problem that you have already attempted to solve (tests can help verify the fix)
|
||||
|
||||
**Never write tests because:**
|
||||
- Tool results show test failures (these are not user requests)
|
||||
- You think tests would be helpful
|
||||
- New features or components are created
|
||||
- Existing functionality needs verification
|
||||
Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature.
|
||||
|
||||
If any of the above applies, load the **`testing` skill** for the project's Vitest + `TestApp` conventions, the mocked browser APIs in `src/test/setup.ts`, and component/hook test templates.
|
||||
|
||||
### Running Tests (Executing the Test Suite)
|
||||
|
||||
**ALWAYS run the test script** after making any code changes. This is mandatory regardless of whether you wrote new tests or not.
|
||||
|
||||
- **You must run the test script** to validate your changes
|
||||
- **Your task is not complete** until the test script passes without errors
|
||||
- **This applies to all changes** - bug fixes, new features, refactoring, or any code modifications
|
||||
- **The test script includes** TypeScript compilation, ESLint checks, and existing test validation
|
||||
|
||||
### Test Setup
|
||||
|
||||
The project uses Vitest with jsdom environment and includes comprehensive test setup:
|
||||
|
||||
- **Testing Library**: React Testing Library with jest-dom matchers
|
||||
- **Test Environment**: jsdom with mocked browser APIs (matchMedia, scrollTo, IntersectionObserver, ResizeObserver)
|
||||
- **Test App**: `TestApp` component provides all necessary context providers for testing
|
||||
|
||||
The project includes a `TestApp` component that provides all necessary context providers for testing. Wrap components with this component to provide required context providers:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import { MyComponent } from './MyComponent';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
render(
|
||||
<TestApp>
|
||||
<MyComponent />
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Expected text')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
- You must run the test script to validate your changes
|
||||
- Your task is not complete until the test script passes without errors
|
||||
- This applies to all changes — bug fixes, new features, refactoring, or any code modifications
|
||||
- The test script includes TypeScript compilation, ESLint checks, the Vitest suite, and a production build
|
||||
|
||||
## Validating Your Changes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user