mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 06:07:29 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user