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;
}