diff --git a/docs/channels.md b/docs/channels.md index 9c9dab1c7f..ba1fd7a761 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -1204,6 +1204,10 @@ The port retains the prepared-store implementation and its behavior tests: menu reads a whole large roster. Narrow row profile selectors, and request-warmed avatars (fetched and decoded, nothing retained; disabled under the Save-Data preference). Signature verification yields in batches. + A request for more identities than the budget holds names those that fit + instead of failing. A batch whose response exceeds the 8 MiB read budget, + such as one heavy with legacy inline avatars, still loads no names; reading + only the rendered rows is a follow-up. - Account/relay-scoped IndexedDB: 64 records / 8 MiB global disk budget, 24-hour expiry. Signed cached events are reverified before display. The same database stores account/relay-scoped startup discovery and sidebar organization (a separate diff --git a/docs/relay-queries.md b/docs/relay-queries.md index f51bc7cf49..502d6c5a9e 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -271,7 +271,11 @@ never comes from the selected row. An accessible selected reply remains availabl even if the original root is missing or the reply lies beyond the traversal cap. Reference queries omit `#h` for legacy edits/deletes but retain session visibility -checks. Raw target/overlay responses reaching 500 events fail before filtering. +checks. Raw target, edit/deletion/summary and tombstone responses reaching 500 +events fail before filtering: a truncated read cannot prove the message was not +changed or removed. Reactions cannot hide or change the target, so the overlay +request reads them through a separate filter. A full page of reactions still opens +the target; older reactions are not read. Evidence shares the thread's 2,000-event / 4 MiB budget and 64-view ceiling. Known tombstones survive sparse refreshes and shared-cache eviction. Exact reads share verification, admission, access epochs and live reconciliation without diff --git a/src/app/shell/useSearchMessages.test.tsx b/src/app/shell/useSearchMessages.test.tsx index 137276ebbc..3253d20b6f 100644 --- a/src/app/shell/useSearchMessages.test.tsx +++ b/src/app/shell/useSearchMessages.test.tsx @@ -1,11 +1,14 @@ // @vitest-environment jsdom import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { expect, it } from "vitest"; +import type { ReadFilter, RelayEvent } from "../../features/relay/events"; import { createRelaySession } from "../../features/relay/session"; import { + type Key, keypair, message, metadata, + profile, roster, scriptedTransport, signed, @@ -122,3 +125,310 @@ it.each([ } }, ); + +it("resolves from:name on a roster larger than the profile budget when some names fail", async () => { + const viewer = keypair(); + const relay = keypair(); + const alice = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + const members = [ + viewer.pubkey, + alice.pubkey, + // Keys just above Alice's sort after hers, so she is in the first batch. + ...Array.from({ length: 1098 }, (_, index) => + (BigInt(`0x${alice.pubkey}`) + BigInt(index + 1)) + .toString(16) + .padStart(64, "0"), + ), + ]; + const discovery = [ + metadata(relay, "crew", "crew"), + roster(relay, "crew", members), + ]; + const owner = createRelaySession( + { + ...wire.transport, + writer: { + sign: async (template) => signed(viewer, template), + publish: async () => {}, + }, + query(filters, signal) { + if (filters.some((filter) => filter.kinds?.includes(9))) + return wire.transport.query(filters, signal); + const names = filters.find((filter) => filter.kinds?.includes(0)); + // The failed batches leave the names partial, so a name search over + // their members checks for a namesake among them. + if (names) + return names.authors?.includes(alice.pubkey) + ? Promise.resolve([profile(alice, { name: "Alice" })]) + : names.search + ? Promise.resolve([]) + : Promise.reject(new Error("Names unavailable")); + return Promise.resolve( + discovery.filter((event) => + filters.some((filter) => filter.kinds?.includes(event.kind)), + ), + ); + }, + }, + { outboxStorage: { load: () => [], save() {} } }, + ); + try { + owner.session.channels.ensureList(); + await waitFor(() => + expect(owner.session.channels.get?.("crew")?.members).toHaveLength(1100), + ); + const { result } = renderHook(() => + useSearchMessages(owner.session, "from:alice ", "crew"), + ); + await waitFor(() => expect(wire.pending).toHaveLength(1)); + const read = wire.next(); + expect(read.filters).toEqual([ + { + kinds: [40002, 40008, 9], + limit: 20, + "#h": ["crew"], + authors: [alice.pubkey], + }, + ]); + await act(async () => + read.respond([message(alice, "crew", "hello", 1700000000)]), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeUndefined(); + expect(result.current.messages.map(({ preview }) => preview)).toEqual([ + "hello", + ]); + } finally { + cleanup(); + owner.dispose(); + } +}); + +/** A key whose hex starts with `digit`, so it sorts to a known side of the + * 1,024-id profile budget. */ +function keyStarting(digit: string) { + for (;;) { + const key = keypair(); + if (key.pubkey.startsWith(digit)) return key; + } +} + +/** A session that has joined "crew" with `members`. Author reads answer from + * `profiles` and the relay's name search answers `found`, within its + * `authors` when it has them. */ +async function crew( + members: readonly string[], + profiles: readonly RelayEvent[], + found: readonly RelayEvent[], +) { + const viewer = keypair(); + const relay = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + const requested = new Set(); + const searches: ReadFilter[] = []; + const discovery = [ + metadata(relay, "crew", "crew"), + roster(relay, "crew", [viewer.pubkey, ...members]), + ]; + const owner = createRelaySession( + { + ...wire.transport, + writer: { + sign: async (template) => signed(viewer, template), + publish: async () => {}, + }, + query(filters, signal) { + if (filters.some((filter) => filter.kinds?.includes(9))) + return wire.transport.query(filters, signal); + const names = filters.find((filter) => filter.kinds?.includes(0)); + if (names?.search) { + searches.push(names); + return Promise.resolve( + found.filter( + (event) => !names.authors || names.authors.includes(event.pubkey), + ), + ); + } + if (names) { + for (const author of names.authors ?? []) requested.add(author); + return Promise.resolve( + profiles.filter((event) => names.authors?.includes(event.pubkey)), + ); + } + return Promise.resolve( + discovery.filter((event) => + filters.some((filter) => filter.kinds?.includes(event.kind)), + ), + ); + }, + }, + { outboxStorage: { load: () => [], save() {} } }, + ); + owner.session.channels.ensureList(); + await waitFor(() => + expect(owner.session.channels.get?.("crew")?.members).toHaveLength( + members.length + 1, + ), + ); + return { owner, wire, requested, searches }; +} + +const near = keyStarting("1"); +const far = keyStarting("f"); +const outsider = keypair(); +const alice = (key: Key) => profile(key, { name: "Alice" }); +// 1,097 keys between the two, so the roster passes the profile budget with +// `near` inside it and `far` past it. +const between = Array.from( + { length: 1097 }, + (_, index) => `5${index.toString(16).padStart(63, "0")}`, +); + +const outsiders = Array.from({ length: 39 }, () => keypair()); + +it.each([ + { + roster: "namesakes on both sides of the budget", + named: [near, far], + found: [near, far], + author: undefined, + unreadOnly: true, + }, + { + roster: "only the member past the budget", + named: [far], + found: [far], + author: far, + unreadOnly: false, + }, + { + roster: "a named member and a namesake outside the channel", + named: [near], + found: [near, outsider], + author: near, + unreadOnly: true, + }, + { + // A relay-wide search would return a full page of 40 Alices. + roster: "a named member and 39 namesakes outside the channel", + named: [near], + found: [near, ...outsiders], + author: near, + unreadOnly: true, + }, +])( + "resolves from:name on $roster", + async ({ named, found, author, unreadOnly }) => { + const { owner, wire, requested, searches } = await crew( + [near.pubkey, ...between, far.pubkey], + named.map(alice), + found.map(alice), + ); + try { + const { result } = renderHook(() => + useSearchMessages(owner.session, "from:alice ", "crew"), + ); + if (author) { + await waitFor(() => expect(wire.pending).toHaveLength(1)); + const read = wire.next(); + expect(read.filters[0]?.authors).toEqual([author.pubkey]); + await act(async () => read.respond([])); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.ambiguousAuthor).toBeUndefined(); + } else { + await waitFor(() => expect(result.current.ambiguousAuthor).toBe(true)); + expect(wire.pending).toHaveLength(0); + } + expect(requested.has(near.pubkey)).toBe(true); + expect(requested.has(far.pubkey)).toBe(false); + expect(searches).toHaveLength(1); + // Beside a loaded match only the members whose names did not load are + // searched; otherwise the relay-wide name search runs. + const authors = searches[0]?.authors; + expect(authors?.includes(far.pubkey) ?? false).toBe(unreadOnly); + expect(authors?.includes(near.pubkey) ?? false).toBe(false); + } finally { + cleanup(); + owner.dispose(); + } + }, +); + +it("treats a full page of member name matches past the budget as ambiguous", async () => { + // Forty members past the budget whose profiles match the typed prefix + // without naming Alice fill the page, so another Alice may be on the next. + const crowd = Array.from({ length: 40 }, () => keyStarting("f")); + const { owner, wire, searches } = await crew( + [near.pubkey, ...between.slice(0, 1057), ...crowd.map((key) => key.pubkey)], + [alice(near)], + crowd.map((key) => profile(key, { name: "Alice Smith" })), + ); + try { + const { result } = renderHook(() => + useSearchMessages(owner.session, "from:alice ", "crew"), + ); + await waitFor(() => expect(result.current.ambiguousAuthor).toBe(true)); + expect(wire.pending).toHaveLength(0); + expect(searches).toHaveLength(1); + expect(searches[0]?.authors).toEqual( + expect.arrayContaining(crowd.map((key) => key.pubkey)), + ); + } finally { + cleanup(); + owner.dispose(); + } +}); + +it("searches the members whose names did not load in parts that fit the filter budget", async () => { + // 1,925 roster keys, so 901 are past the 1,024-id profile budget. + const many = Array.from( + { length: 1922 }, + (_, index) => `5${index.toString(16).padStart(63, "0")}`, + ); + const { owner, wire, searches } = await crew( + [near.pubkey, ...many, far.pubkey], + [alice(near)], + [alice(near), alice(far)], + ); + try { + const { result } = renderHook(() => + useSearchMessages(owner.session, "from:alice ", "crew"), + ); + await waitFor(() => expect(result.current.ambiguousAuthor).toBe(true)); + expect(wire.pending).toHaveLength(0); + expect(searches.map(({ authors }) => authors?.length).sort()).toEqual([ + 1, 900, + ]); + expect(searches.flatMap(({ authors }) => authors ?? [])).toContain( + far.pubkey, + ); + } finally { + cleanup(); + owner.dispose(); + } +}); + +it("resolves from:name on a fully read roster without a relay name search", async () => { + // The viewer and a member the relay answered without a profile are known + // unnamed, not missing. + const { owner, wire, searches } = await crew( + [near.pubkey, between[0] ?? ""], + [alice(near)], + [alice(near)], + ); + try { + const { result } = renderHook(() => + useSearchMessages(owner.session, "from:alice ", "crew"), + ); + await waitFor(() => expect(wire.pending).toHaveLength(1)); + const read = wire.next(); + expect(read.filters[0]?.authors).toEqual([near.pubkey]); + await act(async () => read.respond([])); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(searches).toHaveLength(0); + } finally { + cleanup(); + owner.dispose(); + } +}); diff --git a/src/app/shell/useSearchMessages.ts b/src/app/shell/useSearchMessages.ts index cd47415a10..2fb3c8f8d7 100644 --- a/src/app/shell/useSearchMessages.ts +++ b/src/app/shell/useSearchMessages.ts @@ -220,10 +220,21 @@ export function useSearchMessages( const knownMembers = channelId ? (session.channels.get?.(channelId)?.members ?? []) : []; + // A partial directory still contains verified member profiles. if (knownMembers.length) { - await session.profiles.ensure(knownMembers, "foreground"); + await session.profiles + .ensure(knownMembers, "foreground") + .catch(() => {}); controller.signal.throwIfAborted(); } + // Members past the profile budget or in a failed batch have no + // loaded name. A member the relay answered without a profile is + // known unnamed. + const loaded = session.profiles.snapshot(); + const unnamed = session.profiles.unnamed?.(); + const unread = knownMembers.filter( + (pubkey) => !loaded.has(pubkey) && !unnamed?.has(pubkey), + ); // A key matches by its profile name or, for a selectable agent, // by the agent's own name. const named = (pubkey: string, profileName = "") => @@ -242,16 +253,42 @@ export function useSearchMessages( if (unique.length > 1) return { ambiguous: true }; return { author: unique[0] }; }; + // Relay name search is limited. A match outside its first page is + // unknown, so a full page is as ambiguous as duplicate names. + // The relay does not fold accents, so it gets the typed text. + const limit = 40; if (scoped.length) { - const found = resolve([...scoped, ...agentMatches]); - if (found.ambiguous) + // A member whose name did not load may share it, so search only + // those members' profiles. A request's 64 KiB filter budget + // holds about 975 keys. + const pages = await Promise.all( + Array.from( + { length: Math.ceil(unread.length / 900) }, + (_, at) => + session.read( + [ + { + kinds: [0], + authors: unread.slice(at * 900, at * 900 + 900), + search: typed.toLowerCase(), + search_mode: "prefix", + limit, + }, + ], + { signal: controller.signal, fresh: true }, + ), + ), + ); + const namesakes = pages.flatMap((page) => + [...foldProfiles(page)] + .filter(([pubkey, profile]) => named(pubkey, profile.name)) + .map(([pubkey]) => pubkey), + ); + const found = resolve([...scoped, ...namesakes, ...agentMatches]); + if (found.ambiguous || pages.some((page) => page.length >= limit)) return { messages: [], ambiguousAuthor: true }; author = found.author; } else { - // Relay name search is limited. A match outside its first page - // is unknown, so a full page is as ambiguous as duplicate names. - // The relay does not fold accents, so it gets the typed text. - const limit = 40; const candidates = await session.searchProfiles( typed.toLowerCase(), { limit, signal: controller.signal }, diff --git a/src/bundled/moderation/CommunityAdmin.test.tsx b/src/bundled/moderation/CommunityAdmin.test.tsx index 7aa258360c..ddeec13987 100644 --- a/src/bundled/moderation/CommunityAdmin.test.tsx +++ b/src/bundled/moderation/CommunityAdmin.test.tsx @@ -98,7 +98,7 @@ function relay( profiles: { subscribe: () => () => {}, snapshot: () => profiles, - ensure: async () => {}, + ensure: async (_ids: readonly string[]) => {}, }, }; const value = { @@ -110,6 +110,7 @@ function relay( }; return { read, + session, relay: { snapshot: () => value, subscribe: () => () => {}, @@ -150,6 +151,26 @@ it("gates administration to owners and admins by the verified roster", async () expect(screen.queryByRole("button", { name: "Invite members" })).toBeNull(); }); +it("keeps public-key labels when member names cannot load", async () => { + broker({}); + const { relay: data, session } = relay(owner); + const requested: (readonly string[])[] = []; + // Not a mock: a spy settles the promise itself, hiding an unhandled + // rejection, which Vitest otherwise fails the run on. + session.profiles.ensure = async (ids) => { + requested.push(ids); + throw new Error("Names unavailable"); + }; + render( true} />); + const keys = publicKeyLabels([owner, admin, member]); + expect( + await screen.findByRole("button", { + name: `Actions for ${keys.get(member)}`, + }), + ).toBeVisible(); + expect(requested).toEqual([[owner, admin, member]]); +}); + it("treats a roster signed by anyone else as unavailable", async () => { broker({}); const { relay: data } = relay(owner, [snapshot(roster, owner)]); diff --git a/src/bundled/moderation/CommunityAdmin.tsx b/src/bundled/moderation/CommunityAdmin.tsx index c86548b1d2..ebfefd87c9 100644 --- a/src/bundled/moderation/CommunityAdmin.tsx +++ b/src/bundled/moderation/CommunityAdmin.tsx @@ -151,6 +151,7 @@ function Members({ const profileGeneration = useProfileGeneration(session.profiles); useEffect(() => { void profileGeneration; + // Names are optional: members without one keep their public-key label. if (members) void session.profiles .ensure(members.map((member) => member.pubkey)) diff --git a/src/bundled/relay-notes/NotesPage.test.tsx b/src/bundled/relay-notes/NotesPage.test.tsx index c09c7641bd..29e5fec3b9 100644 --- a/src/bundled/relay-notes/NotesPage.test.tsx +++ b/src/bundled/relay-notes/NotesPage.test.tsx @@ -1169,9 +1169,6 @@ it("pages beyond the real profile directory budget and retains evicted agent cla ), } as unknown as Parameters[0]); try { - await expect( - directory.ensure(entries.map((entry) => entry.pubkey)), - ).rejects.toThrow("directory budget"); const f = fixture(); let page = 0; f.readPage.mockImplementation(async ([filter]) => { diff --git a/src/features/relay/channel-references.test.ts b/src/features/relay/channel-references.test.ts index 542f660a91..5d2019235f 100644 --- a/src/features/relay/channel-references.test.ts +++ b/src/features/relay/channel-references.test.ts @@ -412,6 +412,44 @@ it("looks a mounted link up again when a reset drops its in-flight reply", async expect(link.result.current).toMatchObject({ state: "found", name: "launch" }); }); +it("looks a mounted link up again when the viewer leaves its private channel", async () => { + const h = await setup(); + h.emit([ + roster(relay, SECRET, [viewer.pubkey]), + metadata(relay, SECRET, "inner", 1_700_000_000, [["private"]]), + ]); + const link = renderHook(() => useChannelReference(h.channels, SECRET)); + await vi.waitFor(() => + expect(link.result.current).toMatchObject({ state: "found", joined: true }), + ); + await flush(); + expect(h.pending).toHaveLength(0); + await act(async () => { + h.emit([roster(relay, SECRET, [], 1_700_000_001)]); + }); + await h.lookup([SECRET], []); + expect(link.result.current).toEqual({ state: "withheld" }); +}); + +it("looks a mounted link up again when a public channel the viewer left turns private", async () => { + const h = await setup(); + const link = renderHook(() => useChannelReference(h.channels, MINE)); + await act(async () => { + h.emit([roster(relay, MINE, [], 1_700_000_001)]); + }); + expect(link.result.current).toMatchObject({ state: "found", joined: false }); + // Its messages stay unreadable until a lookup confirms it is still open. + await h.lookup([MINE], [open(MINE, "mine")]); + expect(link.result.current).toMatchObject({ state: "found", joined: false }); + expect(link.result.current).not.toHaveProperty("readable"); + // Access was already lost, so this revokes nothing and resets no answer. + await act(async () => { + h.emit([metadata(relay, MINE, "mine", 1_700_000_002, [["private"]])]); + }); + await h.lookup([MINE], []); + expect(link.result.current).toEqual({ state: "withheld" }); +}); + it("keeps a cancelled lookup from the old cache out of the new one's backoff", async () => { const h = await setup(); const release = h.channels.refer?.(SECRET); diff --git a/src/features/relay/profile-details.test.ts b/src/features/relay/profile-details.test.ts index 23b9b92a1e..3e698e5ff2 100644 --- a/src/features/relay/profile-details.test.ts +++ b/src/features/relay/profile-details.test.ts @@ -2,7 +2,8 @@ import { createHash } from "node:crypto"; import { schnorr } from "@noble/curves/secp256k1.js"; import { bytesToHex } from "nostr-tools/utils"; import { expect, it, vi } from "vitest"; -import { eventDto, type RelayEvent } from "./events"; +import { ReadError } from "./errors"; +import { eventDto, type ReadFilter, type RelayEvent } from "./events"; import { selectProfiles } from "./profile-selection"; import { foldProfiles } from "./profiles"; import { createProfileDirectory } from "./profile-directory"; @@ -593,9 +594,16 @@ it("holds an open channel's roster on top of the base and shrinks back when rele expect(snapshot.has(ids[1] as string)).toBe(false); expect(snapshot.has(ids[500] as string)).toBe(false); expect(snapshot.get(ids[501] as string)?.name).toBe("P501"); - await expect(directory.ensure(ids.slice(0, 1100))).rejects.toThrow( - "exceeds the directory budget", + // Past the shrunk limit, a request names only the profiles that fit. + read.mockClear(); + await directory.ensure( + unsignedProfiles(1100, "X").map((event) => event.pubkey), ); + expect( + read.mock.calls.map( + (call) => (call as unknown as [ReadFilter[]])[0][0]?.authors?.length, + ), + ).toEqual([500, 500, 24]); } finally { unsubscribe(); directory.dispose(); @@ -774,3 +782,65 @@ it("publishes a background read's pages once, also when the session accepts each vi.useRealTimers(); } }); + +const hexIds = (count: number) => + Array.from({ length: count }, (_, index) => + index.toString(16).padStart(64, "0"), + ); +/** Unsigned heads: the directory trusts its reader to have verified them. */ +const named = (pubkey: string) => + ({ + id: pubkey, + pubkey, + kind: 0, + created_at: 1, + tags: [], + content: JSON.stringify({ name: `Name ${pubkey.slice(-3)}` }), + sig: "", + }) as unknown as RelayEvent; + +it("names the identities that fit a request larger than the cache instead of failing", async () => { + const ids = hexIds(1100); + const read = vi.fn(async ([filter]: readonly ReadFilter[]) => + (filter?.authors ?? []).map(named), + ); + const directory = createProfileDirectory({ read }); + try { + await directory.ensure(ids); + expect(read.mock.calls.map(([[filter]]) => filter?.authors)).toEqual([ + ids.slice(0, 500), + ids.slice(500, 1000), + ids.slice(1000, 1024), + ]); + expect([...directory.queries.snapshot().keys()].sort()).toEqual( + ids.slice(0, 1024), + ); + // Asking again reads nothing, so a retry cannot churn the cache. + await directory.ensure(ids); + expect(read).toHaveBeenCalledTimes(3); + } finally { + directory.dispose(); + } +}); + +it("reports a failed batch only after the request's other batches land", async () => { + const ids = hexIds(600); + const read = vi.fn(async ([filter]: readonly ReadFilter[]) => { + const authors = filter?.authors ?? []; + // Not retried, so the batch fails at once. + if (authors.length === 500) throw new ReadError("denied", "Names denied"); + // The other batch lands in a later task than that failure. + await new Promise((resolve) => setTimeout(resolve)); + return authors.map(named); + }); + const directory = createProfileDirectory({ read }); + try { + await expect(directory.ensure(ids)).rejects.toThrow("Names denied"); + expect([...directory.queries.snapshot().keys()].sort()).toEqual( + ids.slice(500), + ); + expect(read).toHaveBeenCalledTimes(2); + } finally { + directory.dispose(); + } +}); diff --git a/src/features/relay/profile-directory.ts b/src/features/relay/profile-directory.ts index 74b9171f92..1fafc7ac71 100644 --- a/src/features/relay/profile-directory.ts +++ b/src/features/relay/profile-directory.ts @@ -43,7 +43,7 @@ export interface ProfileQueries { * next task returns the limit to the largest remaining reservation and * evicts the least recently used profiles past it. */ reserve?(members: number): () => void; - /** Profiles the cache can hold now; one ensure may ask for at most this many. */ + /** Profiles the cache can hold now; one ensure names at most this many. */ limit?(): number; /** Moves when the budget drops loaded profiles or a shrink cancels pending * background reads. A view that requested names once and skips repeats must @@ -321,9 +321,9 @@ export function createProfileDirectory( refresh = false, ) { if (closed) throw new DOMException("Session closed", "AbortError"); - const unique = [...new Set(ids)]; - if (!refresh && unique.length > limit) - throw new Error("Profile request exceeds the directory budget"); + // More ids than the cache holds would only evict names this request + // loaded, so a larger request names its first ids and leaves the rest. + const unique = [...new Set(ids)].slice(0, refresh ? undefined : limit); // A hit marks the profile recently used, so the budget evicts profiles // nobody has asked for rather than the oldest ones still on screen. for (const id of unique) events.get(id); @@ -421,10 +421,15 @@ export function createProfileDirectory( for (const id of authors) pending.set(id, batch); waiting.add(batch.work); } - await Promise.all(waiting); + // Every batch settles before a failure is reported, so a caller that + // tolerates it reads all the names that did load. + const failed = (await Promise.allSettled(waiting)).find( + (outcome) => outcome.status === "rejected", + ); // Every caller sees its names published when its ensure resolves, also // one that joined another caller's read. flush(); + if (failed) throw failed.reason; } function clear() { controller.abort(); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 8ef96810b2..92e6eacc82 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -2519,9 +2519,10 @@ export function createRelaySession( throw new DOMException("Stale thread target", "AbortError"); settings?.signal?.throwIfAborted(); // A capped raw target/overlay read cannot establish a safe fold. + // Reactions cannot hide or change a message; the thread caps them. if ( !filters.some((filter) => filter.depth_limit) && - events.length >= 500 + events.filter((event) => event.kind !== 7).length >= 500 ) throw new Error("Selected message exceeded its evidence limit"); // The thread owner admits the complete target fold atomically. diff --git a/src/features/relay/thread-target.test.ts b/src/features/relay/thread-target.test.ts index 6660ed0e9a..954b4f3706 100644 --- a/src/features/relay/thread-target.test.ts +++ b/src/features/relay/thread-target.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, it } from "vitest"; +import { afterEach, beforeAll, expect, it } from "vitest"; import { createRelaySession } from "./session"; import { createThreadView } from "./threads"; import { ReadError } from "./errors"; @@ -27,6 +27,13 @@ const reply = message(alice, "a", "Selected reply", 10000, [ ]); const aux = (kind: number, target: RelayEvent, content = "", time = 10001) => signed(alice, { kind, content, created_at: time, tags: [["e", target.id]] }); +/** A full reaction page and 480 edits of `reply`, signed once. */ +const overlays: RelayEvent[] = []; +beforeAll(() => { + for (let i = 0; i < 500; i++) overlays.push(aux(7, reply, "+", 10001 + i)); + for (let i = 0; i < 480; i++) + overlays.push(aux(40003, reply, `Edit ${i}`, 10001 + i)); +}); function setup(id = reply.id) { const wire = scriptedTransport(viewer.pubkey, relay.pubkey); let traffic!: LiveCallbacks; @@ -228,14 +235,58 @@ it("distinguishes absent targets and failed lookup, then permits explicit retry" expect(h.view.snapshot().target?.id).toBe(reply.id); }); -it("rejects a capped raw overlay response before visibility filtering hides its size", async () => { +it("opens a target with a full reaction page and hundreds of edits, still loading older replies", async () => { + const h = setup(); + const reading = h.view.refresh(); + h.next().respond([reply]); + await flush(); + // Reactions share the safety-overlay request: no extra round trip. + const request = h.next(); + expect(request.filters.map((filter) => filter.kinds)).toEqual([ + [39005, 40003, 5, 9005], + [7], + ]); + request.respond(overlays); + await flush(); + // Their tombstones take two requests: one holds about 975 ids. + const asked: (readonly string[])[] = []; + for (let part = 0; part < 2; part++) { + const tombstones = h.next(); + asked.push(tombstones.filters[0]?.["#e"] ?? []); + tombstones.respond([]); + await flush(); + } + expect(asked.map((ids) => ids.length)).toEqual([900, 80]); + expect(asked.flat().sort()).toEqual(overlays.map((event) => event.id).sort()); + h.next().respond([ + root, + message(alice, "a", "First reply", 2, [["e", root.id, "", "reply"]]), + ]); + await reading; + expect(h.view.snapshot()).toMatchObject({ + targetStatus: "ready", + target: { id: reply.id, content: "Edit 479" }, + limited: false, + canLoadMore: true, + }); + expect(h.view.snapshot().target?.reactions[0]?.events).toHaveLength(500); + const more = h.view.loadMore(); + h.next().respond([root]); + await more; + expect(h.view.snapshot()).toMatchObject({ + targetStatus: "ready", + limited: false, + }); +}); + +it("rejects a capped raw safety-overlay response before visibility filtering hides its size", async () => { const h = setup(); const reading = h.view.refresh(); h.next().respond([reply]); await flush(); const other = message(alice, "private", "Hidden", 2); h.next().respond( - Array.from({ length: 500 }, (_, i) => aux(7, other, `${i}`)), + Array.from({ length: 500 }, (_, i) => aux(5, other, `${i}`)), ); await reading; expect(h.view.snapshot()).toMatchObject({ diff --git a/src/features/relay/threads.ts b/src/features/relay/threads.ts index a5cdaa034a..05c77db5a9 100644 --- a/src/features/relay/threads.ts +++ b/src/features/relay/threads.ts @@ -446,7 +446,12 @@ export function createThreadView({ // incomplete selected fold. Existing displayed content remains untouched. if (!stage([event])) return "error"; const overlays = await targetReader.read( - [{ kinds: [5, 7, 9005, 40003, 39005], "#e": [id], limit: 500 }], + [ + { kinds: [5, 9005, 40003, 39005], "#e": [id], limit: 500 }, + // Reactions cannot hide or change the target, so a full page is a + // lower bound rather than incomplete safety evidence. + { kinds: [7], "#e": [id], limit: 500 }, + ], { signal: owned.signal }, ); if (!active()) return "cancelled"; @@ -458,9 +463,10 @@ export function createThreadView({ event.tags.some(([name, value]) => name === "e" && value === id), ) .map((event) => event.id); - if (ids.length) { + // A request's 64 KiB filter budget holds about 975 ids. + for (let at = 0; at < ids.length; at += 900) { const tombstones = await targetReader.read( - [{ kinds: [5, 9005], "#e": ids, limit: 500 }], + [{ kinds: [5, 9005], "#e": ids.slice(at, at + 900), limit: 500 }], { signal: owned.signal }, ); if (!active()) return "cancelled"; diff --git a/src/features/relay/verified-profile.test.ts b/src/features/relay/verified-profile.test.ts index 611b3a49b4..04cba1b198 100644 --- a/src/features/relay/verified-profile.test.ts +++ b/src/features/relay/verified-profile.test.ts @@ -614,7 +614,7 @@ it("wires session profile demand, live updates, disconnection, and refreshed dis const wire = scriptedTransport(alice.pubkey, relay.pubkey); let live: import("./live").LiveCallbacks | undefined; let sink: VerifiedProfileSink | undefined; - let requests = 0; + const subjects = new Set(); const refresh = vi.fn(async () => null); const owner = createRelaySession({ ...wire.transport, @@ -627,8 +627,8 @@ it("wires session profile demand, live updates, disconnection, and refreshed dis update() {}, retry() {}, dispose() {}, - syncVerifiedProfiles(_filter, next) { - requests++; + syncVerifiedProfiles(filter, next) { + for (const subject of filter["#d"]) subjects.add(subject); sink = next; return () => {}; }, @@ -638,16 +638,13 @@ it("wires session profile demand, live updates, disconnection, and refreshed dis try { assert(live); live.state({ status: "connected", routes: [] }); - // An over-budget request is rejected before it reaches verification. - await expect( - owner.session.profiles.ensure( - Array.from({ length: 1025 }, (_, index) => - index.toString(16).padStart(64, "0"), - ), - ), - ).rejects.toThrow("budget"); + // An over-budget request verifies only the identities that fit. + const ids = Array.from({ length: 1025 }, (_, index) => + index.toString(16).padStart(64, "0"), + ); + await owner.session.profiles.ensure(ids); await Promise.resolve(); - expect(requests).toBe(0); + expect(subjects).toEqual(new Set(ids.slice(0, 1024))); await owner.session.profiles.ensure([alice.pubkey]); assert(sink); sink.event(projection()); diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs index 52e679c277..1a4adb40ec 100644 --- a/tests/browser/policy-relay.mjs +++ b/tests/browser/policy-relay.mjs @@ -245,6 +245,24 @@ export function policyRelay({ filters.flatMap((filter) => answer(communityOf(url), filter)), ); } + if (filters.length === 2 && filters[1].kinds?.join() === "7") { + // A selected target's safety overlays and its reactions, one target. + const target = filters[0]["#e"]; + expect(target).toEqual([expect.any(String)]); + expect(filters).toEqual([ + { kinds: expect.any(Array), "#e": target, limit: 500 }, + { kinds: [7], "#e": target, limit: 500 }, + ]); + expect(filters[0].kinds.toSorted((a, b) => a - b)).toEqual([ + 5, 9005, 39005, 40003, + ]); + const community = communityOf(url); + for (const filter of filters) + report.queries.push({ community, filter, at: performance.now() }); + return Response.json( + filters.flatMap((filter) => answer(community, filter)), + ); + } if (filters.length === 2 && filters[0].kinds?.includes(39000)) { // Exact channel authority lookup, distinct from sidebar preferences. const ids = filters[0]["#d"];