diff --git a/src/apps/spells/index.tsx b/src/apps/spells/index.tsx index f4688b8..9db9249 100644 --- a/src/apps/spells/index.tsx +++ b/src/apps/spells/index.tsx @@ -18,6 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useIsMobile } from '@/hooks/useIsMobile'; import { + isValidTagLetter, parseSpell, resolveSpellFilter, useDiscoverSpells, @@ -297,7 +298,7 @@ function SpellDetail({ event }: { event: NostrEvent }) { authors: {spell.authors.join(', ')} )} - {spell.tagFilter && ( + {spell.tagFilter && isValidTagLetter(spell.tagFilter.letter) && spell.tagFilter.values.length > 0 && ( #{spell.tagFilter.letter}: {spell.tagFilter.values.join(', ')} diff --git a/src/hooks/useSpells.test.ts b/src/hooks/useSpells.test.ts index 83cf991..9658772 100644 --- a/src/hooks/useSpells.test.ts +++ b/src/hooks/useSpells.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import type { NostrEvent } from '@nostrify/nostrify'; -import { encodeSpellTags, parseSpell, resolveSpellFilter, resolveTimestamp, type ParsedSpell } from './useSpells'; +import { + encodeSpellTags, + isValidTagLetter, + parseSpell, + resolveSpellFilter, + resolveTimestamp, + type ParsedSpell, +} from './useSpells'; function spellEvent(tags: string[][], content = ''): NostrEvent { return { id: 'x', pubkey: 'author', created_at: 0, kind: 777, tags, content, sig: '' }; @@ -62,6 +69,36 @@ describe('encodeSpellTags / parseSpell round-trip', () => { expect(parsed.limit).toBe(50); expect(parsed.topics).toEqual(['bitcoin', 'social']); }); + + it('drops an invalid tag-filter letter instead of publishing a spell that can never run', () => { + const tags = encodeSpellTags({ kinds: [1], tagFilter: { letter: '', values: ['x'] } }); + expect(tags.some(([name]) => name === 'tag')).toBe(false); + }); + + it('drops a non-positive or non-integer limit instead of publishing one resolveSpellFilter will ignore', () => { + expect(encodeSpellTags({ kinds: [1], limit: 0 }).some(([name]) => name === 'limit')).toBe(false); + expect(encodeSpellTags({ kinds: [1], limit: -5 }).some(([name]) => name === 'limit')).toBe(false); + expect(encodeSpellTags({ kinds: [1], limit: 1.5 }).some(([name]) => name === 'limit')).toBe(false); + }); + + it('keeps a valid limit and tag filter', () => { + const tags = encodeSpellTags({ kinds: [1], limit: 50, tagFilter: { letter: 't', values: ['x'] } }); + expect(tags).toContainEqual(['limit', '50']); + expect(tags).toContainEqual(['tag', 't', 'x']); + }); +}); + +describe('isValidTagLetter', () => { + it('accepts a single letter', () => { + expect(isValidTagLetter('t')).toBe(true); + expect(isValidTagLetter('P')).toBe(true); + }); + + it('rejects empty, multi-character, and non-letter values', () => { + expect(isValidTagLetter('')).toBe(false); + expect(isValidTagLetter('tt')).toBe(false); + expect(isValidTagLetter('1')).toBe(false); + }); }); describe('resolveSpellFilter', () => { diff --git a/src/hooks/useSpells.ts b/src/hooks/useSpells.ts index 45a4d62..e09a458 100644 --- a/src/hooks/useSpells.ts +++ b/src/hooks/useSpells.ts @@ -31,6 +31,11 @@ const TAG_LETTER_RE = /^[a-zA-Z]$/; /** Caps a relay-sourced spell's `limit` so Run can't be tricked into a huge query. */ const MAX_SPELL_LIMIT = 500; +/** A valid NIP-01 tag-filter letter — the only kind `resolveSpellFilter` will act on. */ +export function isValidTagLetter(letter: string): boolean { + return TAG_LETTER_RE.test(letter); +} + /** Resolves `now`, `` (e.g. `7d`) or a literal unix timestamp string. */ export function resolveTimestamp(value: string): number | undefined { const trimmed = value.trim(); @@ -74,13 +79,24 @@ export interface ParsedSpell { event: NostrEvent; } -/** Builds the tag set for a spell event per the draft NIP. */ +/** + * Builds the tag set for a spell event per the draft NIP. Validates the tag + * filter's letter and the limit before writing them — an invalid `letter` + * or a non-positive/non-integer `limit` is dropped rather than published, + * since `resolveSpellFilter` would silently ignore it on Run anyway and a + * saved-but-inert filter is worse than one that never made it into the + * event at all. + */ export function encodeSpellTags(input: SpellInput): string[][] { const tags: string[][] = [['cmd', 'REQ']]; for (const kind of input.kinds) tags.push(['k', String(kind)]); if (input.authors?.length) tags.push(['authors', ...input.authors]); - if (input.tagFilter?.values.length) tags.push(['tag', input.tagFilter.letter, ...input.tagFilter.values]); - if (input.limit) tags.push(['limit', String(input.limit)]); + if (input.tagFilter?.values.length && isValidTagLetter(input.tagFilter.letter)) { + tags.push(['tag', input.tagFilter.letter, ...input.tagFilter.values]); + } + if (input.limit !== undefined && Number.isInteger(input.limit) && input.limit > 0) { + tags.push(['limit', String(input.limit)]); + } if (input.since?.trim()) tags.push(['since', input.since.trim()]); if (input.until?.trim()) tags.push(['until', input.until.trim()]); if (input.search?.trim()) tags.push(['search', input.search.trim()]); @@ -133,7 +149,7 @@ export function resolveSpellFilter( // Relay-provided events are untrusted: a malformed spell must not produce // a filter key like "#undefined", or a limit that is 0/NaN/huge enough to // lock up the UI on Run. - if (spell.tagFilter && TAG_LETTER_RE.test(spell.tagFilter.letter) && spell.tagFilter.values.length > 0) { + if (spell.tagFilter && isValidTagLetter(spell.tagFilter.letter) && spell.tagFilter.values.length > 0) { filter[`#${spell.tagFilter.letter}`] = spell.tagFilter.values; }