@@ -188,10 +223,12 @@ export function NewSpellForm({ onDone }: { onDone: () => void }) {
);
}
-function Field({ label, children }: { label: string; children: React.ReactNode }) {
+function Field({ id, label, children }: { id: string; label: string; children: React.ReactNode }) {
return (
- {label}
+
{children}
);
diff --git a/src/apps/spells/index.tsx b/src/apps/spells/index.tsx
index 53c51b6..f4688b8 100644
--- a/src/apps/spells/index.tsx
+++ b/src/apps/spells/index.tsx
@@ -35,9 +35,13 @@ type Scope = 'mine' | 'discover';
export default function SpellsApp({ params, setTitle, setParams }: AppProps) {
const { user } = useCurrentUser();
const isMobile = useIsMobile();
- const [scope, setScope] = useState
(user ? 'mine' : 'discover');
+ const [requestedScope, setRequestedScope] = useState('mine');
const [formOpen, setFormOpen] = useState(false);
+ // Signing out mid-session must not leave "My Spells" showing the previous
+ // user's (still-cached) spells — same reasoning as the Feed app's scope.
+ const scope: Scope = user ? requestedScope : 'discover';
+
const mine = useMySpells();
const discover = useDiscoverSpells();
const query = scope === 'mine' ? mine : discover;
@@ -80,13 +84,13 @@ export default function SpellsApp({ params, setTitle, setParams }: AppProps) {
setScope('mine')}
+ onClick={() => setRequestedScope('mine')}
icon={}
label="My Spells"
/>
setScope('discover')}
+ onClick={() => setRequestedScope('discover')}
icon={}
label="Discover"
/>
diff --git a/src/hooks/useSpells.test.ts b/src/hooks/useSpells.test.ts
index d0bc1e7..83cf991 100644
--- a/src/hooks/useSpells.test.ts
+++ b/src/hooks/useSpells.test.ts
@@ -1,11 +1,23 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
-import { encodeSpellTags, parseSpell, resolveSpellFilter, resolveTimestamp } from './useSpells';
+import { encodeSpellTags, 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: '' };
}
+/** A ParsedSpell as if it came straight off a relay — encodeSpellTags always produces well-formed data, so malformed cases are built by hand. */
+function parsedSpell(overrides: Partial): ParsedSpell {
+ return {
+ description: '',
+ kinds: [1],
+ authors: [],
+ topics: [],
+ event: spellEvent([]),
+ ...overrides,
+ };
+}
+
describe('resolveTimestamp', () => {
it('resolves a relative duration against now', () => {
const now = Math.floor(Date.now() / 1000);
@@ -78,4 +90,27 @@ describe('resolveSpellFilter', () => {
const filter = resolveSpellFilter(parseSpell(event), { me: undefined, contacts: [] });
expect(filter?.['#t']).toEqual(['bitcoin', 'nostr']);
});
+
+ it('ignores a malformed (non-single-letter) tag filter instead of producing "#undefined"', () => {
+ const spell = parsedSpell({ tagFilter: { letter: '', values: ['x'] } });
+ const filter = resolveSpellFilter(spell, { me: undefined, contacts: [] });
+ expect(Object.keys(filter ?? {}).some((key) => key.startsWith('#'))).toBe(false);
+ });
+
+ it('clamps an excessive limit to the maximum', () => {
+ const spell = parsedSpell({ limit: 1_000_000 });
+ const filter = resolveSpellFilter(spell, { me: undefined, contacts: [] });
+ expect(filter?.limit).toBe(500);
+ });
+
+ it('drops a zero or NaN limit rather than sending a degenerate query', () => {
+ expect(resolveSpellFilter(parsedSpell({ limit: 0 }), { me: undefined, contacts: [] })?.limit).toBeUndefined();
+ expect(resolveSpellFilter(parsedSpell({ limit: NaN }), { me: undefined, contacts: [] })?.limit).toBeUndefined();
+ });
+
+ it('keeps a normal, in-range limit as-is', () => {
+ const spell = parsedSpell({ limit: 50 });
+ const filter = resolveSpellFilter(spell, { me: undefined, contacts: [] });
+ expect(filter?.limit).toBe(50);
+ });
});
diff --git a/src/hooks/useSpells.ts b/src/hooks/useSpells.ts
index 3b358a4..45a4d62 100644
--- a/src/hooks/useSpells.ts
+++ b/src/hooks/useSpells.ts
@@ -26,6 +26,11 @@ const UNIT_SECONDS: Record = {
y: 31_536_000,
};
+/** A single NIP-01 tag-filter letter, e.g. the `t` in `#t`. */
+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;
+
/** Resolves `now`, `` (e.g. `7d`) or a literal unix timestamp string. */
export function resolveTimestamp(value: string): number | undefined {
const trimmed = value.trim();
@@ -125,11 +130,16 @@ export function resolveSpellFilter(
filter.authors = resolved;
}
- if (spell.tagFilter) {
+ // 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) {
filter[`#${spell.tagFilter.letter}`] = spell.tagFilter.values;
}
- if (spell.limit) filter.limit = spell.limit;
+ if (spell.limit !== undefined && Number.isFinite(spell.limit) && spell.limit > 0) {
+ filter.limit = Math.min(spell.limit, MAX_SPELL_LIMIT);
+ }
if (spell.since) {
const since = resolveTimestamp(spell.since);
if (since !== undefined) filter.since = since;