fix: validate spell tag filter/limit at write time, and hide malformed badges

Per review:
- encodeSpellTags() now validates the tag filter's letter and the
  limit before writing them, instead of only resolveSpellFilter()
  catching bad values on Run — a spell authored through this app can
  no longer save a filter it will silently fail to apply later.
  Exported isValidTagLetter() so both sides share one definition of
  "valid."
- The spell detail view's tag-filter badge now hides itself for a
  malformed tag filter (e.g. from a relay-sourced spell this app
  didn't author) instead of rendering "#undefined:" or similar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-06 18:23:35 +02:00
parent 1dc89496cf
commit e8d4d5d20b
3 changed files with 60 additions and 6 deletions

View File

@@ -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(', ')}
</Badge>
)}
{spell.tagFilter && (
{spell.tagFilter && isValidTagLetter(spell.tagFilter.letter) && spell.tagFilter.values.length > 0 && (
<Badge variant="outline" className="text-[11px]">
#{spell.tagFilter.letter}: {spell.tagFilter.values.join(', ')}
</Badge>

View File

@@ -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', () => {

View File

@@ -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`, `<n><unit>` (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;
}