diff --git a/apps/web/src/lib/contrail/cursor.test.ts b/apps/web/src/lib/contrail/cursor.test.ts index c6eafac..934c309 100644 --- a/apps/web/src/lib/contrail/cursor.test.ts +++ b/apps/web/src/lib/contrail/cursor.test.ts @@ -1,10 +1,28 @@ import { describe, it, expect } from 'vitest'; -import { tagCursor, parseCursor } from './cursor'; +import { + tagCursor, + parseCursor, + encodeCursor, + decodeCursor, + nextCursor, + rawForQuery, + type CursorEnvelope +} from './cursor'; + +/** + * Hand-craft a base64url(JSON) token from ARBITRARY (including type-invalid / + * hostile) content, bypassing encodeCursor's type gate — this is how an attacker + * would forge a cursor. Uses Node's base64url encoding, which matches the + * production btoa-based encoder byte-for-byte for these payloads. + */ +function craft(obj: unknown): string { + return Buffer.from(JSON.stringify(obj), 'utf-8').toString('base64url'); +} // A pagination cursor handed to the client is tagged with the backend that // issued it so load-more can route by the tag instead of re-deriving the -// backend from request shape (om-7dbs). These pin the tag round-trip and the -// legacy (untagged) fallback contract both cursor kinds share. +// backend from request shape. These pin the tag round-trip and the legacy +// (untagged) fallback contract both cursor kinds share. describe('tagCursor', () => { it('prefixes a Meili offset with its backend tag', () => { expect(tagCursor('meili', '20')).toBe('meili:20'); @@ -56,3 +74,176 @@ describe('parseCursor', () => { expect(parseCursor('foo:bar')).toEqual({ backend: null, raw: 'foo:bar' }); }); }); + +// The client continuation cursor is now a self-describing envelope: +// base64url(JSON { v, q, args?, raw }). It names a SERVER-SIDE query; the server +// re-runs that query with its own filter values, so the client carries no +// pipeline/filters and a tampered token can only name another public-safe query +// or fail to decode. These pin the round-trip, the never-throw decode guard, and +// the deep-link query-match rule. +describe('encodeCursor / decodeCursor round-trip', () => { + it('round-trips every field (q + args + raw)', () => { + const envelope: CursorEnvelope = { + v: 1, + q: 'hosting', + args: { actor: 'did:plc:alice' }, + raw: 'eyJ0IjoxNzUsImsiOiJhdDovL3gifQ' + }; + expect(decodeCursor(encodeCursor(envelope))).toEqual(envelope); + }); + + it('round-trips an argless envelope', () => { + const envelope: CursorEnvelope = { v: 1, q: 'search-d1', raw: 'keyset' }; + expect(decodeCursor(encodeCursor(envelope))).toEqual(envelope); + }); + + it('round-trips the popular boolean arg', () => { + const envelope: CursorEnvelope = { v: 1, q: 'events', args: { popular: true }, raw: 'k' }; + expect(decodeCursor(encodeCursor(envelope))).toEqual(envelope); + }); + + it('emits base64url only — no + / or = padding', () => { + const token = encodeCursor({ v: 1, q: 'topic', args: { slug: 'ai' }, raw: 'a+b/c==dd' }); + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + expect(token).not.toMatch(/[+/=]/); + }); +}); + +describe('decodeCursor fail-safe (never throws, returns null on anything bad)', () => { + it('returns null for null/undefined/empty', () => { + expect(decodeCursor(null)).toBeNull(); + expect(decodeCursor(undefined)).toBeNull(); + expect(decodeCursor('')).toBeNull(); + }); + + it('returns null for legacy tagged cursors (contain ":", not base64url)', () => { + // Deploy-straddle: a meili:/d1: cursor issued by the previous deploy arrives + // at the new load-more. It must fail-safe to null (end pagination), never be + // resurrected into a query. + expect(decodeCursor('meili:20')).toBeNull(); + expect(decodeCursor('d1:eyJ0IjoxNzUsImsiOiJhdDovL3gifQ')).toBeNull(); + }); + + it('returns null for a bare legacy offset (valid base64url chars, not JSON)', () => { + // '20' is base64url-shaped but decodes to bytes that are not JSON. + expect(decodeCursor('20')).toBeNull(); + }); + + it('returns null for base64url of non-JSON bytes', () => { + expect(decodeCursor(Buffer.from('not json', 'utf-8').toString('base64url'))).toBeNull(); + }); + + it('returns null for non-base64url characters', () => { + expect(decodeCursor('has spaces')).toBeNull(); + expect(decodeCursor('{"v":1}')).toBeNull(); + }); + + it('returns null for a JSON array (not an object)', () => { + expect(decodeCursor(craft([1, 2, 3]))).toBeNull(); + }); + + it('returns null for a wrong/absent version', () => { + expect(decodeCursor(craft({ v: 2, q: 'events', raw: 'x' }))).toBeNull(); + expect(decodeCursor(craft({ q: 'events', raw: 'x' }))).toBeNull(); + }); + + it('returns null for an unknown query name', () => { + expect(decodeCursor(craft({ v: 1, q: 'plain', raw: 'x' }))).toBeNull(); + expect(decodeCursor(craft({ v: 1, q: 'listRecords', raw: 'x' }))).toBeNull(); + expect(decodeCursor(craft({ v: 1, q: '', raw: 'x' }))).toBeNull(); + }); + + it('returns null for a missing/empty/non-string raw', () => { + expect(decodeCursor(craft({ v: 1, q: 'events' }))).toBeNull(); + expect(decodeCursor(craft({ v: 1, q: 'events', raw: '' }))).toBeNull(); + expect(decodeCursor(craft({ v: 1, q: 'events', raw: 123 }))).toBeNull(); + }); + + it('returns null for mistyped args', () => { + expect(decodeCursor(craft({ v: 1, q: 'events', args: 'nope', raw: 'x' }))).toBeNull(); + expect(decodeCursor(craft({ v: 1, q: 'events', args: { popular: 'yes' }, raw: 'x' }))).toBeNull(); + expect(decodeCursor(craft({ v: 1, q: 'hosting', args: { actor: 42 }, raw: 'x' }))).toBeNull(); + }); + + it('returns null for an oversized token (> ~1500 chars)', () => { + const huge = encodeCursor({ v: 1, q: 'events', raw: 'x'.repeat(4000) }); + expect(huge.length).toBeGreaterThan(1500); + expect(decodeCursor(huge)).toBeNull(); + }); + + it('ignores unknown extra fields in args, keeping only allow-listed ones', () => { + const token = craft({ v: 1, q: 'events', args: { popular: true, evil: 'x' }, raw: 'k' }); + expect(decodeCursor(token)).toEqual({ v: 1, q: 'events', args: { popular: true }, raw: 'k' }); + }); +}); + +describe('nextCursor', () => { + it('returns null when the backend signalled no more pages (null/empty raw)', () => { + expect(nextCursor('events', null)).toBeNull(); + expect(nextCursor('events', undefined)).toBeNull(); + expect(nextCursor('events', '')).toBeNull(); + }); + + it('builds a decodable same-query envelope from a fresh raw keyset', () => { + const token = nextCursor('hosting', 'newkeyset', { actor: 'did:plc:alice' }); + expect(decodeCursor(token)).toEqual({ + v: 1, + q: 'hosting', + args: { actor: 'did:plc:alice' }, + raw: 'newkeyset' + }); + }); + + it('omits an empty args object so identical continuations round-trip identically', () => { + expect(decodeCursor(nextCursor('search-d1', 'k', {}))).toEqual({ + v: 1, + q: 'search-d1', + raw: 'k' + }); + }); +}); + +describe('rawForQuery (deep-link guard)', () => { + it('returns the raw when the envelope names the requested query AND same args', () => { + const token = encodeCursor({ v: 1, q: 'events', args: { popular: true }, raw: 'k1' }); + expect(rawForQuery(token, 'events', { popular: true })).toBe('k1'); + }); + + it('returns undefined when the envelope names a DIFFERENT query (cross-route keyset)', () => { + // A desc past-events keyset deep-linked into the asc events route must NOT + // resume — the route falls back to a fresh page 1. + const token = encodeCursor({ v: 1, q: 'past-events', args: { actor: 'did:plc:a' }, raw: 'k' }); + expect(rawForQuery(token, 'events', { popular: true })).toBeUndefined(); + }); + + it('returns undefined on an ARGS mismatch even when the query matches', () => { + // Same `q`, different scope = a keyset for a different result set. Each of + // these would skip/duplicate rows if resumed, so the guard rejects them. + const topicTech = encodeCursor({ v: 1, q: 'topic', args: { slug: 'technology' }, raw: 'k' }); + expect(rawForQuery(topicTech, 'topic', { slug: 'ai' })).toBeUndefined(); + expect(rawForQuery(topicTech, 'topic', { slug: 'technology' })).toBe('k'); + + const actorA = encodeCursor({ v: 1, q: 'hosting', args: { actor: 'did:plc:a' }, raw: 'k' }); + expect(rawForQuery(actorA, 'hosting', { actor: 'did:plc:b' })).toBeUndefined(); + expect(rawForQuery(actorA, 'hosting', { actor: 'did:plc:a' })).toBe('k'); + + // popular vs all: a rsvpsCountMin>=2 keyset must not resume the unfiltered list. + const popular = encodeCursor({ v: 1, q: 'events', args: { popular: true }, raw: 'k' }); + expect(rawForQuery(popular, 'events', { popular: false })).toBeUndefined(); + }); + + it('refuses to resume term-carrying search queries (term not in the envelope)', () => { + // The search term rides ?q=, not the envelope, so a search cursor can't be + // proven to match the route's term — never resume it from a deep link. + const d1 = encodeCursor({ v: 1, q: 'search-d1', raw: 'k' }); + const meili = encodeCursor({ v: 1, q: 'search-meili', raw: 'meili:20' }); + expect(rawForQuery(d1, 'search-d1')).toBeUndefined(); + expect(rawForQuery(meili, 'search-meili')).toBeUndefined(); + }); + + it('returns undefined for an undecodable / legacy token', () => { + expect(rawForQuery('meili:20', 'search-meili')).toBeUndefined(); + expect(rawForQuery(null, 'events')).toBeUndefined(); + expect(rawForQuery('garbage', 'events')).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/contrail/cursor.ts b/apps/web/src/lib/contrail/cursor.ts index b2f6fa2..b424261 100644 --- a/apps/web/src/lib/contrail/cursor.ts +++ b/apps/web/src/lib/contrail/cursor.ts @@ -1,19 +1,15 @@ -// Self-describing pagination cursors (om-7dbs). +// Self-describing pagination cursors — the codec behind "load more". // -// A cursor handed to the client is tagged with the backend that issued it, so -// load-more routes by the tag instead of re-deriving the backend from the -// request shape ("is search set AND is Meili configured"). That inference broke -// whenever a page's FIRST load came from one backend but its load-more resolved -// to the other: -// - a D1 keyset fed to Meili: Number(base64url) -> NaN -> offset 0 -> a -// relevance-reordered duplicate of page 1; -// - a Meili offset fed to D1 listRecords: ignored, and the discoverable / -// time-bound filters the first page applied get dropped. +// A page's continuation is an opaque ENVELOPE (below) that names the server-side +// query to resume; its `raw` payload is a backend-native cursor — a Meilisearch +// offset that tagCursor prefixes as `meili:`, or an opaque base64url(JSON) D1 +// keyset from @atmo-dev/contrail. tagCursor/parseCursor are that Meilisearch +// offset codec (also used by near-me); they WRAP the raw cursor, never rewrite +// it, and the `:` separator can't collide (base64url excludes ':', a Meili +// offset is decimal digits). // -// The raw cursor is opaque: a Meili offset string, or a base64url(JSON) D1 -// keyset built inside @atmo-dev/contrail. We WRAP it, never rewrite it — the -// separator below can't collide because base64url's alphabet excludes ':' and a -// Meili offset is decimal digits. +// See README → "Load-more pagination" for the model and the cross-backend bug +// that motivated it. export type CursorBackend = 'meili' | 'd1'; @@ -42,6 +38,14 @@ export type ParsedCursor = * - Anything else is an untagged legacy cursor (in-flight from before this * deploy, or an unknown prefix): { backend: null, raw: } so the caller * can fall back to the old inference and still consume it. + * + * NOTE — two callers, one permanent and one temporary: + * - PERMANENT: the search/near-me offset path (parseOffsetCursor) splits the + * `meili:` tag tagCursor emits. Not going away. + * - TEMPORARY: fail-safing pre-envelope cursors still in flight after a deploy. + * Nothing emits `d1:` anymore and the client continuation cursor is now the + * ENVELOPE below, so the `d1:`/untagged branches can be dropped once those + * legacy cursors have drained (tracked as a follow-up). */ export function parseCursor(cursor: string | null | undefined): ParsedCursor { if (cursor == null || cursor === '') return { backend: null, raw: null }; @@ -54,3 +58,213 @@ export function parseCursor(cursor: string | null | undefined): ParsedCursor { } return { backend: null, raw: cursor }; } + +// --------------------------------------------------------------------------- +// Continuation envelope — the self-describing token the client echoes back. +// +// A base64url(JSON { v, q, args?, raw }) blob naming the SERVER-SIDE query that +// produced the page (`q`) plus the opaque backend-native cursor to resume from +// (`raw`). Load-more re-runs that query server-side with its OWN filter values; +// the client carries no pipeline and no filters — only public-safe scope choices +// in `args`. The search TERM stays out of the envelope; it rides the ?q= URL +// param / remote input. +// +// See README → "Load-more pagination" for the full model + why a tampered token +// can't widen visibility. +// --------------------------------------------------------------------------- + +/** + * The server-side query that issued a page. The name implies the backend + * (search-meili -> Meili offset; everything else -> a D1 keyset), so no separate + * `backend` field is needed. Every value is a query whose result set is + * public-safe: the unlisted-inclusive plain listRecords pipeline is deliberately + * absent, so no decoded envelope can reach it. + */ +export const CURSOR_QUERIES = [ + 'events', + 'hosting', + 'past-events', + 'topic', + 'search-d1', + 'search-meili' +] as const; + +export type CursorQuery = (typeof CURSOR_QUERIES)[number]; + +/** Allow-listed, public-safe scope choices the client may legitimately carry. */ +export type CursorArgs = { + /** Public profile scope (hosting / past-events). */ + actor?: string; + /** Public topic slug (topic). */ + slug?: string; + /** Public "popular" toggle (events). */ + popular?: boolean; +}; + +export type CursorEnvelope = { + /** Schema version, for future migration. */ + v: 1; + /** Names the server-side registry entry that resumes this page. */ + q: CursorQuery; + /** Public-safe scope choices; omitted when empty. */ + args?: CursorArgs; + /** Opaque backend-native cursor (D1 keyset, or a meili-tagged offset). */ + raw: string; +}; + +/** + * Defensive upper bound on a decoded token's length. The real envelope is + * ~150-250 chars; anything far larger is malformed or hostile, so we refuse to + * decode it (end pagination) rather than parse an over-long URL/body. + */ +const MAX_CURSOR_LEN = 1500; + +/** base64url alphabet only — no '+' '/' '=' padding, no other characters. */ +const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; + +function encodeBase64Url(json: string): string { + const bytes = new TextEncoder().encode(json); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function decodeBase64Url(token: string): string { + const padded = token + '='.repeat((4 - (token.length % 4)) % 4); + const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} + +/** Drop undefined/mistyped entries; return undefined when nothing remains. */ +function cleanArgs(args: CursorArgs): CursorArgs | undefined { + const out: CursorArgs = {}; + if (typeof args.actor === 'string') out.actor = args.actor; + if (typeof args.slug === 'string') out.slug = args.slug; + if (typeof args.popular === 'boolean') out.popular = args.popular; + return Object.keys(out).length > 0 ? out : undefined; +} + +/** Encode a continuation envelope into an opaque base64url(JSON) client token. */ +export function encodeCursor(envelope: CursorEnvelope): string { + return encodeBase64Url(JSON.stringify(envelope)); +} + +/** + * Build the NEXT-page token, or null when the backend signalled no more pages. + * `raw` null/empty => null (never manufacture a cursor). `args` is normalized so + * identical continuations round-trip identically. + */ +export function nextCursor( + q: CursorQuery, + raw: string | null | undefined, + args?: CursorArgs +): string | null { + if (raw == null || raw === '') return null; + const cleaned = args ? cleanArgs(args) : undefined; + return encodeCursor({ + v: 1, + q, + ...(cleaned ? { args: cleaned } : {}), + raw + }); +} + +/** + * Decode a client token back into an envelope, or null on ANY problem — never + * throws. Rejects: null/empty, non-base64url characters (this fails-safe every + * legacy `meili:`/`d1:` tag, which contains ':'), oversized input, + * non-JSON, non-object, wrong version, unknown/absent `q`, a non-string/empty + * `raw`, or a mistyped `args`. The registry then treats a null decode as + * end-of-pagination. + */ +export function decodeCursor(token: string | null | undefined): CursorEnvelope | null { + if (token == null || token === '') return null; + if (token.length > MAX_CURSOR_LEN) return null; + if (!BASE64URL_RE.test(token)) return null; + + let json: string; + try { + json = decodeBase64Url(token); + } catch { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const obj = parsed as Record; + + if (obj.v !== 1) return null; + if (typeof obj.q !== 'string' || !(CURSOR_QUERIES as readonly string[]).includes(obj.q)) + return null; + if (typeof obj.raw !== 'string' || obj.raw === '') return null; + + let args: CursorArgs | undefined; + if (obj.args !== undefined) { + if (!obj.args || typeof obj.args !== 'object' || Array.isArray(obj.args)) return null; + const a = obj.args as Record; + const built: CursorArgs = {}; + if (a.actor !== undefined) { + if (typeof a.actor !== 'string') return null; + built.actor = a.actor; + } + if (a.slug !== undefined) { + if (typeof a.slug !== 'string') return null; + built.slug = a.slug; + } + if (a.popular !== undefined) { + if (typeof a.popular !== 'boolean') return null; + built.popular = a.popular; + } + args = Object.keys(built).length > 0 ? built : undefined; + } + + return { + v: 1, + q: obj.q as CursorQuery, + ...(args ? { args } : {}), + raw: obj.raw + }; +} + +/** Normalized deep-equal on the public-safe scope bag; absent === empty. */ +function argsEqual(a: CursorArgs | undefined, b: CursorArgs | undefined): boolean { + const x = a ? cleanArgs(a) : undefined; + const y = b ? cleanArgs(b) : undefined; + return x?.actor === y?.actor && x?.slug === y?.slug && x?.popular === y?.popular; +} + +/** + * Queries a deep-link `?cursor=` may resume: those whose full identity is + * (`q` + `args`). Search is excluded — its defining term rides `?q=`, not the + * envelope, so a search cursor can't be validated against the route. + */ +const DEEP_LINKABLE: readonly CursorQuery[] = ['events', 'hosting', 'past-events', 'topic']; + +/** + * Deep-link guard for a first-page load: return the inbound `?cursor=`'s opaque + * raw ONLY when the envelope was minted for the SAME query — same `q` AND same + * public-safe scope (`args`). A keyset is query-shape-specific, so a q-match + * alone isn't enough: a `technology` topic keyset on `/topics/ai` names the same + * `q` but indexes a different set, and resuming it would skip/duplicate rows. Any + * mismatch, an undecodable token, or a non-DEEP_LINKABLE query => fresh page 1. + */ +export function rawForQuery( + token: string | null | undefined, + q: CursorQuery, + args?: CursorArgs +): string | undefined { + if (!(DEEP_LINKABLE as readonly string[]).includes(q)) return undefined; + const envelope = decodeCursor(token); + if (!envelope || envelope.q !== q) return undefined; + if (!argsEqual(envelope.args, args)) return undefined; + return envelope.raw; +} -- 2.51.2 From 6ce2f3f8a0c017644766a1343ed6e77930bd9d66 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Wed, 8 Jul 2026 11:05:37 -0400 Subject: [PATCH 2/3] refactor(load-more): backend->resumer registry, retire fetchParams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch load-more through a map of query name -> resumer instead of a hand-written backend if/else plus a nested pipeline switch. Each resumer re-runs its page-1 query with server-authoritative filter values, so adding a backend or a paginated page is registering an entry, not editing a conditional. The unlisted-inclusive plain listRecords pipeline has no entry, so no decoded envelope can reach it — visibility filters can't be widened by an edited token. Topic search is re-derived server-side from the slug via a shared helper so page 1 and load-more can't drift. --- .../src/lib/contrail/events-load-more.test.ts | 375 ++++++++++++------ apps/web/src/lib/contrail/events-load-more.ts | 259 +++++++----- apps/web/src/lib/topics.ts | 15 + 3 files changed, 444 insertions(+), 205 deletions(-) diff --git a/apps/web/src/lib/contrail/events-load-more.test.ts b/apps/web/src/lib/contrail/events-load-more.test.ts index dbffdef..025278c 100644 --- a/apps/web/src/lib/contrail/events-load-more.test.ts +++ b/apps/web/src/lib/contrail/events-load-more.test.ts @@ -1,16 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -// runLoadMoreEvents must re-run the SAME read pipeline page 1 used. The bug -// (om-5iiw) was that it always called listRecords, so the discoverable filter -// (home) and the authored filter (profile hosting/past) were dropped on page -// 2+, leaking unlisted events and conference talks. These tests pin the -// routing: the `pipeline` selector picks the matching contrail fn and is -// stripped from the params handed to it (it is our selector, not an xrpc param). +// runLoadMoreEvents now decodes a self-describing continuation ENVELOPE and +// dispatches through a backend->resumer REGISTRY keyed by the envelope's query +// name — no routeMeili/if-else, no pipeline switch, no client-echoed +// query-reconstruction bag. These tests pin: (1) each query routes to the right +// server-side pipeline with SERVER-AUTHORITATIVE filters, (2) security — a +// tampered/forged envelope can never reach the unlisted-inclusive plain +// listRecords pipeline (it has no registry entry), (3) continuity — the next +// cursor re-encodes the SAME query so page N+1 stays adjacent and +// non-overlapping, (4) legacy/undecodable cursors end pagination cleanly +// without reconstruction. vi.mock('./index', () => ({ getServerClient: vi.fn(() => ({})) })); vi.mock('$lib/contrail', () => ({ flattenEventRecords: vi.fn((records: unknown[]) => records), + // Exported so the no-leak assertion can prove it is NEVER called — it has no + // registry entry, so no decoded envelope can reach it. listEventRecordsFromContrail: vi.fn(), listDiscoverableEventsFromContrail: vi.fn(), listAuthoredEventsFromContrail: vi.fn() @@ -20,182 +26,329 @@ vi.mock('$lib/search/server/query', () => ({ runEventSearchPage: vi.fn() })); -import { runLoadMoreEvents, type LoadMoreEventsInput } from './events-load-more'; +import { runLoadMoreEvents } from './events-load-more'; +import { encodeCursor, decodeCursor, type CursorEnvelope } from './cursor'; import { listAuthoredEventsFromContrail, listDiscoverableEventsFromContrail, listEventRecordsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; +import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; const mockRecords = vi.mocked(listEventRecordsFromContrail); const mockDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); const mockAuthored = vi.mocked(listAuthoredEventsFromContrail); const mockSearchBackend = vi.mocked(searchBackendFromEnv); +const mockRunSearch = vi.mocked(runEventSearchPage); -const emptyPage = { records: [], profiles: [], cursor: 'next' } as unknown as Awaited< - ReturnType ->; - -// env is opaque here — getServerClient is mocked, and the search backend is -// resolved via the (mocked) searchBackendFromEnv. const env = { DB: {} } as unknown as App.Platform['env']; -const call = (input: Partial) => - runLoadMoreEvents(env, input as LoadMoreEventsInput); + +const call = (cursor: string | undefined, q?: string) => runLoadMoreEvents(env, { cursor, q }); + +// A valid envelope token (as the server would emit it). +const token = (envelope: CursorEnvelope) => encodeCursor(envelope); + +// A forged token with ARBITRARY content, bypassing encodeCursor's type gate. +const forge = (obj: unknown) => Buffer.from(JSON.stringify(obj), 'utf-8').toString('base64url'); + +const page = (records: unknown[], cursor: string | null = 'next', profiles: unknown[] = []) => + ({ records, profiles, cursor }) as unknown as Awaited< + ReturnType + >; + +const noReads = () => { + expect(mockDiscoverable).not.toHaveBeenCalled(); + expect(mockAuthored).not.toHaveBeenCalled(); + expect(mockRecords).not.toHaveBeenCalled(); + expect(mockRunSearch).not.toHaveBeenCalled(); +}; afterEach(() => vi.clearAllMocks()); -describe('runLoadMoreEvents pipeline routing', () => { - it("routes pipeline:'discoverable' to listDiscoverable, never listRecords", async () => { - mockDiscoverable.mockResolvedValue(emptyPage); +describe('runLoadMoreEvents registry dispatch', () => { + it("routes 'events' to listDiscoverable with server-authoritative upcoming filters", async () => { + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://x' }], 'raw2')); - await call({ pipeline: 'discoverable', startsAtMin: '2026-01-01T00:00:00Z', cursor: 'c' }); + const result = await call(token({ v: 1, q: 'events', args: { popular: true }, raw: 'raw1' })); expect(mockDiscoverable).toHaveBeenCalledTimes(1); - expect(mockRecords).not.toHaveBeenCalled(); expect(mockAuthored).not.toHaveBeenCalled(); + expect(mockRecords).not.toHaveBeenCalled(); + const params = mockDiscoverable.mock.calls[0][1]; + // Same literals the events/+page.server.ts page-1 load uses (continuity). + expect(params).toMatchObject({ + sort: 'startsAt', + order: 'asc', + limit: 20, + profiles: true, + rsvpsCountMin: 2, + cursor: 'raw1' + }); + expect(typeof params.startsAtMin).toBe('string'); + // Next cursor re-encodes the SAME query + args with the fresh keyset. + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'events', + args: { popular: true }, + raw: 'raw2' + }); }); - it("routes pipeline:'authored' to listAuthored, never listRecords", async () => { - mockAuthored.mockResolvedValue(emptyPage); + it("drops rsvpsCountMin for a non-popular 'events' envelope", async () => { + mockDiscoverable.mockResolvedValue(page([], null)); + await call(token({ v: 1, q: 'events', args: { popular: false }, raw: 'r' })); + expect(mockDiscoverable.mock.calls[0][1]).not.toHaveProperty('rsvpsCountMin'); + }); - await call({ pipeline: 'authored', actor: 'did:plc:alice', cursor: 'c' }); + it("routes 'hosting' to listAuthored scoped to the actor, upcoming asc", async () => { + mockAuthored.mockResolvedValue(page([{ uri: 'at://h' }], 'raw2')); + + const result = await call( + token({ v: 1, q: 'hosting', args: { actor: 'did:plc:alice' }, raw: 'raw1' }) + ); expect(mockAuthored).toHaveBeenCalledTimes(1); - expect(mockRecords).not.toHaveBeenCalled(); expect(mockDiscoverable).not.toHaveBeenCalled(); + const params = mockAuthored.mock.calls[0][1]; + expect(params).toMatchObject({ + actor: 'did:plc:alice', + sort: 'startsAt', + order: 'asc', + profiles: true, + limit: 20, + cursor: 'raw1' + }); + expect(typeof params.startsAtMin).toBe('string'); + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'hosting', + args: { actor: 'did:plc:alice' }, + raw: 'raw2' + }); }); - it('falls back to plain listRecords when no pipeline is given', async () => { - mockRecords.mockResolvedValue(emptyPage); + it("routes 'past-events' to listAuthored scoped to the actor, past desc", async () => { + mockAuthored.mockResolvedValue(page([{ uri: 'at://p' }], 'raw2')); - await call({ cursor: 'c' }); + const result = await call( + token({ v: 1, q: 'past-events', args: { actor: 'did:plc:alice' }, raw: 'raw1' }) + ); - expect(mockRecords).toHaveBeenCalledTimes(1); - expect(mockDiscoverable).not.toHaveBeenCalled(); - expect(mockAuthored).not.toHaveBeenCalled(); + const params = mockAuthored.mock.calls[0][1]; + expect(params).toMatchObject({ + actor: 'did:plc:alice', + sort: 'startsAt', + order: 'desc', + limit: 20, + cursor: 'raw1' + }); + expect(typeof params.startsAtMax).toBe('string'); + expect(decodeCursor(result.cursor)).toMatchObject({ q: 'past-events' }); }); - it('strips the pipeline selector but forwards the real filters', async () => { - mockDiscoverable.mockResolvedValue(emptyPage); + it("routes 'topic' to listDiscoverable, deriving the search from the slug server-side", async () => { + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://t' }], 'raw2')); - await call({ pipeline: 'discoverable', rsvpsCountMin: 2, cursor: 'c' }); + const result = await call( + token({ v: 1, q: 'topic', args: { slug: 'technology' }, raw: 'raw1' }) + ); const params = mockDiscoverable.mock.calls[0][1]; - expect(params).not.toHaveProperty('pipeline'); - expect(params).toMatchObject({ rsvpsCountMin: 2, cursor: 'c' }); + // orQueryFromSlug('technology') — the SAME helper the topic page load uses. + expect(params.search).toBe('tech OR technology'); + expect(params).toMatchObject({ order: 'asc', limit: 20, cursor: 'raw1' }); + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'topic', + args: { slug: 'technology' }, + raw: 'raw2' + }); }); - it('does not consult the search backend for a non-search load', async () => { - mockDiscoverable.mockResolvedValue(emptyPage); + it("routes 'search-d1' to listDiscoverable with the term from input, upcoming desc", async () => { + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://s' }], 'raw2')); - await call({ pipeline: 'discoverable', cursor: 'c' }); + const result = await call(token({ v: 1, q: 'search-d1', raw: 'raw1' }), 'jazz'); - expect(mockSearchBackend).not.toHaveBeenCalled(); + const params = mockDiscoverable.mock.calls[0][1]; + expect(params).toMatchObject({ + search: 'jazz', + order: 'desc', + limit: SEARCH_PAGE_SIZE, + cursor: 'raw1' + }); + expect(typeof params.startsAtMin).toBe('string'); + expect(decodeCursor(result.cursor)).toMatchObject({ q: 'search-d1', raw: 'raw2' }); }); - it('tags the D1 cursor it returns to the client with the d1 backend', async () => { - mockDiscoverable.mockResolvedValue(emptyPage); // cursor: 'next' + it("routes 'search-meili' to runEventSearchPage with the term + raw offset, re-wraps next", async () => { + mockSearchBackend.mockReturnValue({ url: 'https://m', apiKey: 'k' }); + mockRunSearch.mockResolvedValue({ + events: [{ uri: 'at://s' }], + handles: { 'did:plc:a': 'alice' }, + cursor: 'meili:40', + distances: {} + } as unknown as Awaited>); - const result = await call({ pipeline: 'discoverable', cursor: 'd1:opaque' }); + const result = await call(token({ v: 1, q: 'search-meili', raw: 'meili:20' }), 'jazz'); - expect(result.cursor).toBe('d1:next'); + expect(mockRunSearch).toHaveBeenCalledTimes(1); + expect(mockRunSearch.mock.calls[0][2]).toMatchObject({ q: 'jazz', cursor: 'meili:20' }); + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-meili', raw: 'meili:40' }); + }); + + it('ends pagination when the contrail read returns null', async () => { + mockDiscoverable.mockResolvedValue(null); + const result = await call(token({ v: 1, q: 'events', args: { popular: true }, raw: 'k' })); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + }); + + it('ends pagination (null cursor) when the backend has no next page', async () => { + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://x' }], null)); + const result = await call(token({ v: 1, q: 'events', args: { popular: true }, raw: 'k' })); + expect(result.cursor).toBeNull(); }); }); -// The om-7dbs bug class: a page whose FIRST load came from one backend but whose -// load-more re-derived the OTHER, handing over an incompatible cursor. Routing -// by the cursor's own tag pins each continuation to the backend that issued it. -describe('runLoadMoreEvents backend routing by cursor tag', () => { - const mockRunSearch = vi.mocked(runEventSearchPage); - const searchPage = { - events: [], - handles: {}, - cursor: 'meili:40' - } as unknown as Awaited>; - - it('keeps a d1-tagged cursor on D1 even with a search term AND Meili configured', async () => { - // PR #49's trip case: D1 first page + search term + configured Meili. The - // old inference re-routed to Meili and NaN-parsed the keyset into a page-1 - // refetch. The tag must win: stay on D1, filters intact. - mockSearchBackend.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); - mockDiscoverable.mockResolvedValue(emptyPage); - - const result = await call({ - pipeline: 'discoverable', - search: 'jazz', - startsAtMin: '2026-01-01T00:00:00Z', - cursor: 'd1:keyset' - }); +// Security invariant: a client-held, mutable cursor must not let a tampered +// continuation widen visibility. It holds BY CONSTRUCTION — the unlisted- +// inclusive listRecords pipeline has NO registry entry, and the envelope carries +// no filter values to tamper. +describe('security: a tampered/forged envelope cannot surface non-discoverable events', () => { + const unlisted = { uri: 'at://did:plc:secret/community.lexicon.calendar.event/hidden' }; - expect(mockRunSearch).not.toHaveBeenCalled(); - expect(mockDiscoverable).toHaveBeenCalledTimes(1); - const params = mockDiscoverable.mock.calls[0][1]; - // The untagged keyset is forwarded to D1; filters survive. - expect(params).toMatchObject({ - cursor: 'keyset', - search: 'jazz', - startsAtMin: '2026-01-01T00:00:00Z' - }); - expect(result.cursor).toBe('d1:next'); + it("q tampered to 'plain' resumes nothing and never reaches plain listRecords", async () => { + // listRecords WOULD return the hidden event if it were reachable; prove it + // is never called and the hidden event never surfaces. + mockRecords.mockResolvedValue(page([unlisted])); + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://ok' }])); + + const result = await call(forge({ v: 1, q: 'plain', raw: 'anything' })); + + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + expect(result.events).not.toContainEqual(unlisted); + noReads(); }); - it('keeps a meili-tagged cursor on Meili and hands it the untagged offset', async () => { - mockSearchBackend.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); - mockRunSearch.mockResolvedValue(searchPage); + it('q tampered to an unknown string / missing / empty => empty, and listRecords never runs', async () => { + mockRecords.mockResolvedValue(page([unlisted])); + for (const forged of [ + forge({ v: 1, q: 'listRecords', raw: 'x' }), + forge({ v: 1, raw: 'x' }), + forge({ v: 1, q: '', raw: 'x' }) + ]) { + expect(await call(forged)).toEqual({ events: [], handles: {}, cursor: null }); + } + expect(mockRecords).not.toHaveBeenCalled(); + }); - const result = await call({ search: 'jazz', cursor: 'meili:20' }); + it('a valid discoverable envelope routes ONLY to listDiscoverable, never to listRecords', async () => { + mockRecords.mockResolvedValue(page([unlisted])); + mockDiscoverable.mockResolvedValue(page([{ uri: 'at://discoverable' }], null)); - expect(mockRunSearch).toHaveBeenCalledTimes(1); - expect(mockRunSearch.mock.calls[0][2]).toMatchObject({ q: 'jazz', cursor: '20' }); + const result = await call(token({ v: 1, q: 'events', args: { popular: true }, raw: 'k' })); + + expect(mockDiscoverable).toHaveBeenCalledTimes(1); expect(mockRecords).not.toHaveBeenCalled(); - expect(mockDiscoverable).not.toHaveBeenCalled(); - expect(result.cursor).toBe('meili:40'); + expect(result.events).not.toContainEqual(unlisted); }); - it('fails safe (ends pagination) for a meili-tagged cursor when no backend is configured', async () => { - // The Meili offset is meaningless to D1 listRecords; rather than restart - // page 1 on D1 or NaN-parse, end pagination. - mockSearchBackend.mockReturnValue(null); + it('dropping all args cannot surface a non-discoverable event (startsAtMin is server-applied)', async () => { + mockRecords.mockResolvedValue(page([unlisted])); + mockDiscoverable.mockResolvedValue(page([], null)); - const result = await call({ search: 'jazz', cursor: 'meili:20' }); + await call(token({ v: 1, q: 'events', raw: 'k' })); // args stripped entirely - expect(mockRunSearch).not.toHaveBeenCalled(); expect(mockRecords).not.toHaveBeenCalled(); - expect(mockDiscoverable).not.toHaveBeenCalled(); - expect(result).toEqual({ events: [], handles: {}, cursor: null }); + // The discoverability filter + startsAtMin live in the D1 pipeline, not the + // client cursor: listDiscoverable still ran with a server-supplied bound. + expect(typeof mockDiscoverable.mock.calls[0][1].startsAtMin).toBe('string'); }); - it('fails safe for a meili-tagged cursor when the search term was lost from the continuation', async () => { - mockSearchBackend.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); + it('switching q among public-safe queries re-scopes but never leaks or throws', async () => { + mockAuthored.mockResolvedValue(page([{ uri: 'at://authored' }], null)); + // A different actor on 'hosting' is exactly the same as browsing that public + // profile — permitted, and it still scopes to that actor. + const result = await call(token({ v: 1, q: 'hosting', args: { actor: 'did:plc:bob' }, raw: 'k' })); + expect(mockAuthored).toHaveBeenCalledTimes(1); + expect(mockAuthored.mock.calls[0][1]).toMatchObject({ actor: 'did:plc:bob' }); + expect(result.cursor).toBeNull(); + }); +}); - const result = await call({ cursor: 'meili:20' }); +describe('registry required-arg guards end cleanly (never throw, never fall through)', () => { + it('hosting with a missing actor => empty', async () => { + const result = await call(token({ v: 1, q: 'hosting', raw: 'k' })); + expect(mockAuthored).not.toHaveBeenCalled(); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + }); - expect(mockRunSearch).not.toHaveBeenCalled(); + it('hosting with a malformed actor => empty', async () => { + const result = await call( + token({ v: 1, q: 'hosting', args: { actor: 'not an actor!!' }, raw: 'k' }) + ); + expect(mockAuthored).not.toHaveBeenCalled(); + expect(result.cursor).toBeNull(); + }); + + it('topic with an unknown slug => empty', async () => { + const result = await call(token({ v: 1, q: 'topic', args: { slug: 'no-such-topic' }, raw: 'k' })); expect(mockDiscoverable).not.toHaveBeenCalled(); expect(result).toEqual({ events: [], handles: {}, cursor: null }); }); - it('legacy untagged cursor + search + Meili configured falls back to the old inference (Meili)', async () => { - mockSearchBackend.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); - mockRunSearch.mockResolvedValue(searchPage); + it('search-d1 with the search term lost => empty', async () => { + const result = await call(token({ v: 1, q: 'search-d1', raw: 'k' })); + expect(mockDiscoverable).not.toHaveBeenCalled(); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + }); - const result = await call({ search: 'jazz', cursor: '20' }); + it('search-meili with no configured backend => empty', async () => { + mockSearchBackend.mockReturnValue(null); + const result = await call(token({ v: 1, q: 'search-meili', raw: 'meili:20' }), 'jazz'); + expect(mockRunSearch).not.toHaveBeenCalled(); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + }); - expect(mockRunSearch).toHaveBeenCalledTimes(1); - // The legacy offset is passed through for the Meili path to parse. - expect(mockRunSearch.mock.calls[0][2]).toMatchObject({ q: 'jazz', cursor: '20' }); - expect(result.cursor).toBe('meili:40'); + it('search-meili with the search term lost => empty', async () => { + mockSearchBackend.mockReturnValue({ url: 'https://m', apiKey: 'k' }); + const result = await call(token({ v: 1, q: 'search-meili', raw: 'meili:20' })); + expect(mockRunSearch).not.toHaveBeenCalled(); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); }); +}); - it('legacy untagged cursor with no search context falls back to D1', async () => { - mockRecords.mockResolvedValue(emptyPage); +// Legacy meili:/d1: tags and bare offsets are in-flight during the deploy +// window. They (and any tampered token) must end pagination cleanly, WITHOUT +// reconstructing a query from client fields (that would re-open the insecure +// path). +describe('legacy / undecodable cursors end pagination cleanly with no read', () => { + it.each([ + ['a meili tag', 'meili:20'], + ['a d1 tag', 'd1:eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'], + ['a bare legacy offset', '20'], + ['a bare legacy keyset', 'eyJ0IjoxNzUsImsiOiJhdDovL3gifQ'], + ['a garbage token', 'not a real token'], + ['an empty string', ''] + ])('%s => empty, no query run', async (_label, cursor) => { + mockSearchBackend.mockReturnValue({ url: 'https://m', apiKey: 'k' }); + const result = await call(cursor, 'jazz'); + expect(result).toEqual({ events: [], handles: {}, cursor: null }); + noReads(); + }); - const result = await call({ cursor: 'legacyOpaqueKeyset' }); + it('an absent cursor => empty', async () => { + expect(await call(undefined)).toEqual({ events: [], handles: {}, cursor: null }); + noReads(); + }); - expect(mockRecords).toHaveBeenCalledTimes(1); - expect(mockRecords.mock.calls[0][1]).toMatchObject({ cursor: 'legacyOpaqueKeyset' }); + it('does NOT reconstruct a query from a legacy tag even with a search term present', async () => { + // Deploy-straddle: old client POSTs a legacy tag + (dropped) stale query + // params + a search term. The tag fails to decode => empty, no re-inference. + mockSearchBackend.mockReturnValue({ url: 'https://m', apiKey: 'k' }); + const result = await call('meili:20', 'jazz'); + expect(result.cursor).toBeNull(); expect(mockRunSearch).not.toHaveBeenCalled(); - expect(result.cursor).toBe('d1:next'); }); }); diff --git a/apps/web/src/lib/contrail/events-load-more.ts b/apps/web/src/lib/contrail/events-load-more.ts index a7b663c..402e4db 100644 --- a/apps/web/src/lib/contrail/events-load-more.ts +++ b/apps/web/src/lib/contrail/events-load-more.ts @@ -1,34 +1,31 @@ import * as v from 'valibot'; +import type { Client } from '@atcute/client'; +import type { ActorIdentifier } from '@atcute/lexicons'; +import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { getServerClient } from './index'; import { flattenEventRecords, listAuthoredEventsFromContrail, - listDiscoverableEventsFromContrail, - listEventRecordsFromContrail + listDiscoverableEventsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; -import { parseCursor, tagCursor } from './cursor'; -import type { ActorIdentifier } from '@atcute/lexicons'; +import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; +import { orQueryFromSlug } from '$lib/topics'; +import { decodeCursor, nextCursor, type CursorArgs, type CursorEnvelope, type CursorQuery } from './cursor'; + +const PAGE_SIZE = 20; +// The load-more remote input. The continuation cursor is now a self-describing +// ENVELOPE carrying the server-side query name + public-safe args, so the client +// no longer echoes a query-reconstruction bag of pipeline/filters. Only two +// fields are read: `cursor` (the envelope) and `q` (the search TERM, which stays +// OUT of the envelope and rides ?q=/input — see the search resumers). A legacy +// client may still POST extra query params; `v.object` drops them, so they are +// accepted WITHOUT being trusted. export const listEventsInput = v.object({ - actor: v.optional(v.string()), - search: v.optional(v.string()), - startsAtMin: v.optional(v.string()), - startsAtMax: v.optional(v.string()), - endsAtMin: v.optional(v.string()), - endsAtMax: v.optional(v.string()), - rsvpsCountMin: v.optional(v.number()), - rsvpsGoingCountMin: v.optional(v.number()), - profiles: v.optional(v.boolean()), - sort: v.optional(v.string()), - order: v.optional(v.picklist(['asc', 'desc'])), - limit: v.optional(v.number()), cursor: v.optional(v.string()), - // Which page-1 read pipeline this list came from. load-more MUST re-run the - // same pipeline or it drifts: 'discoverable' (home) drops the unlisted-event - // filter, 'authored' (profile hosting/past) drops the conference-talk filter, - // and either leaks records page 1 excluded. Absent => plain listRecords. - pipeline: v.optional(v.picklist(['discoverable', 'authored'])) + /** Free-text search term for the search page; ignored for every other query. */ + q: v.optional(v.string()) }); export type LoadMoreEventsInput = v.InferOutput; @@ -39,89 +36,163 @@ export type LoadMoreEventsResult = { cursor: string | null; }; +const EMPTY: LoadMoreEventsResult = { events: [], handles: {}, cursor: null }; + +function now(): string { + return new Date().toISOString(); +} + +/** + * Shape a contrail list response into a load-more result, re-encoding the next + * page's cursor as a same-query envelope (identical `q`/`args`, the fresh raw + * keyset) so continuations stay on the same server-authoritative query. + */ +function toResult( + q: CursorQuery, + args: CursorArgs | undefined, + response: Awaited> +): LoadMoreEventsResult { + if (!response) return EMPTY; + const events = flattenEventRecords(response.records ?? []); + const handles: Record = {}; + for (const p of response.profiles ?? []) { + if (p.handle) handles[p.did] = p.handle; + } + return { events, handles, cursor: nextCursor(q, response.cursor ?? null, args) }; +} + +/** + * A resumer re-runs the page-1 query named by the envelope, from the envelope's + * opaque `raw` keyset, with SERVER-AUTHORITATIVE filter values. It receives the + * decoded envelope (never the raw client bag) plus the free-text search term + * (search queries only). Missing/malformed required args => end cleanly (EMPTY); + * never throw, never fall through to another query. + */ +type Resumer = ( + env: App.Platform['env'], + client: Client, + envelope: CursorEnvelope, + searchTerm: string | undefined +) => Promise; + +// The backend->resumer REGISTRY, keyed by the envelope's query name. Adding a +// paginated query is REGISTERING an entry here, not editing a conditional; every +// filter VALUE is server-authoritative and lives in the entry. The plain, +// unlisted-inclusive listRecords pipeline deliberately has NO entry, so no +// decoded envelope can reach it. (See README → "Load-more pagination".) +const REGISTRY: Record = { + events: async (_env, client, { args, raw }) => { + const response = await listDiscoverableEventsFromContrail(client, { + startsAtMin: now(), + profiles: true, + sort: 'startsAt', + order: 'asc', + limit: PAGE_SIZE, + ...(args?.popular ? { rsvpsCountMin: 2 } : {}), + cursor: raw + }); + return toResult('events', args, response); + }, + + hosting: async (_env, client, { args, raw }) => { + if (!args?.actor || !isActorIdentifier(args.actor)) return EMPTY; + const response = await listAuthoredEventsFromContrail(client, { + actor: args.actor as ActorIdentifier, + startsAtMin: now(), + sort: 'startsAt', + order: 'asc', + profiles: true, + limit: PAGE_SIZE, + cursor: raw + }); + return toResult('hosting', args, response); + }, + + 'past-events': async (_env, client, { args, raw }) => { + if (!args?.actor || !isActorIdentifier(args.actor)) return EMPTY; + const response = await listAuthoredEventsFromContrail(client, { + actor: args.actor as ActorIdentifier, + startsAtMax: now(), + sort: 'startsAt', + order: 'desc', + profiles: true, + limit: PAGE_SIZE, + cursor: raw + }); + return toResult('past-events', args, response); + }, + + topic: async (_env, client, { args, raw }) => { + // Re-derive the search from the slug SERVER-side (shared helper), never from + // a client-supplied query. Unknown slug => end cleanly. + const search = args?.slug ? orQueryFromSlug(args.slug) : null; + if (!search) return EMPTY; + const response = await listDiscoverableEventsFromContrail(client, { + search, + startsAtMin: now(), + sort: 'startsAt', + order: 'asc', + profiles: true, + limit: PAGE_SIZE, + cursor: raw + }); + return toResult('topic', args, response); + }, + + 'search-d1': async (_env, client, { args, raw }, searchTerm) => { + const q = searchTerm?.trim(); + if (!q) return EMPTY; // search term lost from the continuation => end cleanly + const response = await listDiscoverableEventsFromContrail(client, { + search: q, + startsAtMin: now(), + sort: 'startsAt', + order: 'desc', + profiles: true, + limit: SEARCH_PAGE_SIZE, + cursor: raw + }); + return toResult('search-d1', args, response); + }, + + 'search-meili': async (env, client, { args, raw }, searchTerm) => { + const q = searchTerm?.trim(); + const backend = q ? searchBackendFromEnv(env) : null; + // Missing search term OR unconfigured backend => end cleanly rather than + // restart page 1 on the wrong backend. + if (!q || !backend) return EMPTY; + const page = await runEventSearchPage(backend, client, { q, cursor: raw }); + return { + events: page.events, + handles: page.handles, + cursor: nextCursor('search-meili', page.cursor, args) + }; + } +}; + /** * Shared load-more handler. Kept out of the `.remote.ts` adapter so it is a * plain function the SvelteKit remote-functions plugin won't wrap — that lets it * be unit-tested directly (the plugin rejects non-remote exports from * `*.remote.ts`, so a test there can't mock `$app/server`). + * + * Decode the envelope, look up its resumer, resume with server-authoritative + * filters, re-encode the next envelope — no per-backend if/else. An undecodable + * or legacy cursor decodes to null and ends pagination cleanly, without + * reconstructing the query from client fields. See README → + * "Load-more pagination". */ export async function runLoadMoreEvents( env: App.Platform['env'], input: LoadMoreEventsInput ): Promise { - const client = getServerClient(env.DB); - - // Route by the cursor's own tag, not by re-deriving the backend from request - // shape. The page that issued this cursor already committed to a backend; - // load-more MUST continue on that same one or first-load and load-more diverge - // and hand over an incompatible cursor (om-7dbs). - const { backend: cursorBackend, raw: cursorRaw } = parseCursor(input.cursor); - - // Only resolve the Meili backend when a search term is present (matches the - // search page's first-page path); avoids touching it for plain D1 loads. - const searchTerm = input.search?.trim(); - const searchBackend = searchTerm ? searchBackendFromEnv(env) : null; - - // Meili path when: the cursor is explicitly meili-tagged, OR it's an untagged - // legacy cursor (in-flight from before this deploy) and the old inference - // ("search set AND Meili configured") would have chosen Meili. A d1-tagged - // cursor is NEVER routed here, even with a search term + configured backend — - // that is exactly the divergence the tag exists to prevent. - const routeMeili = - cursorBackend === 'meili' || (cursorBackend === null && !!searchBackend && !!searchTerm); - if (routeMeili) { - if (!searchBackend || !searchTerm) { - // A meili-tagged cursor arrived but this context can't serve Meili (the - // backend is now unconfigured, or the search term was lost from the - // continuation). Feeding the offset to D1 listRecords would ignore it and - // drop filters, and re-inferring would restart page 1 on the wrong - // backend. Fail safe: end pagination cleanly. Errors otherwise propagate - // to EventList's catch so the user can retry with the cursor intact. - return { events: [], handles: {}, cursor: null }; - } - const page = await runEventSearchPage(searchBackend, client, { - q: searchTerm, - // Pass the untagged offset; runEventSearchPage also strips a meili tag - // itself, so a legacy bare offset works here too. - cursor: cursorRaw - }); - return { events: page.events, handles: page.handles, cursor: page.cursor }; - } - - // D1 path: an explicit d1 tag, or an untagged cursor with no Meili search - // context. Re-run the SAME page-1 pipeline so load-more inherits its filters. - // `pipeline` is our selector, not an xrpc param, so strip it. `cursor` is - // overwritten below with the untagged keyset (the inbound one carries the tag). - const { pipeline, ...rest } = input; - const params = { - ...rest, - actor: rest.actor as ActorIdentifier | undefined, - cursor: cursorRaw ?? undefined - }; - - const response = - pipeline === 'discoverable' - ? await listDiscoverableEventsFromContrail(client, params) - : pipeline === 'authored' - ? await listAuthoredEventsFromContrail(client, params) - : await listEventRecordsFromContrail(client, params); - - if (!response) { - return { events: [], handles: {}, cursor: null }; - } + const envelope = decodeCursor(input.cursor); + if (!envelope) return EMPTY; - const events = flattenEventRecords(response.records ?? []); + const resumer = REGISTRY[envelope.q]; + // decodeCursor already rejects an unknown `q`; this is belt-and-suspenders so + // the dispatch can never fall through to a default/plain pipeline. + if (!resumer) return EMPTY; - const handles: Record = {}; - for (const p of response.profiles ?? []) { - if (p.handle) handles[p.did] = p.handle; - } - - return { - events, - handles, - // Tag the keyset with the D1 backend so the next load-more stays on D1 and - // can't be re-inferred onto Meili (om-7dbs). - cursor: tagCursor('d1', response.cursor ?? null) - }; + const client = getServerClient(env.DB); + return resumer(env, client, envelope, input.q); } diff --git a/apps/web/src/lib/topics.ts b/apps/web/src/lib/topics.ts index 89b7b7e..c565549 100644 --- a/apps/web/src/lib/topics.ts +++ b/apps/web/src/lib/topics.ts @@ -214,3 +214,18 @@ export const TOPICS: Topic[] = [ export function getTopicBySlug(slug: string): Topic | undefined { return TOPICS.find((t) => t.slug === slug); } + +/** + * The FTS5 disjunction query for a topic slug: match events whose + * name/description mention ANY of the topic's hashtag terms. D1's SQLite FTS5 + * MATCH treats an uppercase `OR` as a real disjunction operator, so this is a + * true "any term" query. Returns null for an unknown slug so callers (the topic + * page load AND the load-more registry) can end pagination cleanly rather than + * run an empty/garbage search — the two MUST derive the query identically or + * page 1 and load-more drift apart. + */ +export function orQueryFromSlug(slug: string): string | null { + const topic = getTopicBySlug(slug); + if (!topic) return null; + return topic.hashtags.map((h) => h.replace(/^#/, '')).join(' OR '); +} -- 2.51.2 From b4a63106ca3cf03076a3bb7bc117c0a4afe558ad Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Wed, 8 Jul 2026 11:05:45 -0400 Subject: [PATCH 3/3] feat(routes): envelope page loads + deep search/topics pagination Home events, profile hosting/past-events, topic, and search each mint a self-describing continuation envelope from their own server-side filters; EventList echoes the opaque token on "load more", carrying no pipeline or filters of its own. This closes the gap that forced /search (D1 fallback) and /topics to return cursor:null, so both now paginate beyond page 1 with the discoverable + upcoming filters intact. Each route resumes a deep-linked ?cursor= only for its own query and scope; search page 1 does not resume from ?cursor= at all, since the term rides ?q= and can't be validated against the cursor. Adds the README "Load-more pagination" section describing the model. --- README.md | 14 ++ apps/web/src/lib/components/EventList.svelte | 29 ++--- .../src/routes/(app)/events/+page.server.ts | 14 +- apps/web/src/routes/(app)/events/+page.svelte | 14 +- .../(app)/p/[actor]/hosting/+page.server.ts | 11 +- .../(app)/p/[actor]/hosting/+page.svelte | 13 -- .../p/[actor]/past-events/+page.server.ts | 11 +- .../(app)/p/[actor]/past-events/+page.svelte | 13 -- .../src/routes/(app)/search/+page.server.ts | 31 +++-- apps/web/src/routes/(app)/search/+page.svelte | 8 +- .../routes/(app)/search/page.server.test.ts | 93 ++++++++++---- .../(app)/topics/[slug]/+page.server.ts | 29 ++--- .../routes/(app)/topics/[slug]/+page.svelte | 19 +-- .../(app)/topics/[slug]/page.server.test.ts | 121 ++++++++++++++++++ 14 files changed, 272 insertions(+), 148 deletions(-) create mode 100644 apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts diff --git a/README.md b/README.md index cf7a81e..cd8b790 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,20 @@ Many events carry only a street address, no coordinates — so they never surfac **Backfilling an existing corpus.** The drip only trickles, so to resolve a backlog run the off-Cloudflare CLI against the deployed D1: `pnpm -C apps/web geocode:backfill --limit 50`. It reaches D1 over the REST API, so it needs `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN` / `D1_DATABASE_ID` and `MEILI_URL` / `MEILI_KEY` (plus `SEARCH_INDEX` if not `events`), and a LocationIQ `GEOCODER_URL` / `GEOCODER_KEY`. It refuses a bulk or uncapped run against public Nominatim (keyless is capped to `--limit 1..25`). Useful flags: `--limit N` (`0` = no cap), `--dry-run`, `--retry-negative` (re-attempt negatively-cached addresses), and `--allow-public-nominatim` (override the public-host guard). Like search itself, geocoding only helps once the sink is feeding the index, so run this after the rollout steps above. +## Load-more pagination + +Every paginated list — the home events feed, a profile's hosting and past events, a topic, and search — shares one continuation mechanism, so "load more" always resumes the same query on the same backend that produced page 1. + +**Why a self-describing token.** Load-more used to have the client echo back the page-1 query parameters and let the server re-derive which backend to use from the request shape ("is a search term set, and is Meilisearch configured?"). That inference broke whenever a page's first load and its load-more resolved to different backends. A D1 keyset fed to Meilisearch became `Number(base64url)`, which is `NaN`, which collapses to offset 0 — a relevance-reordered duplicate of page 1. A Meilisearch offset fed to D1 was ignored, silently dropping the upcoming and discoverable filters page 1 had applied. + +**The envelope.** The continuation cursor is now an opaque, self-contained token: `base64url(JSON { v, q, args?, raw })`. `q` names the server-side query (`events`, `hosting`, `past-events`, `topic`, `search-d1`, or `search-meili`). `args` carries only public-safe scope choices (a profile actor, a topic slug, the "popular" toggle). `raw` is the opaque backend-native cursor to resume from. The client treats the whole token as a blob and echoes it back unchanged. Load-more looks `q` up in a registry of resumers (`events-load-more.ts`) and re-runs that query with server-authoritative filter values; the client supplies neither the pipeline nor any filter. + +**Why that's safe.** Because every filter value lives server-side in the registry entry, a tampered token cannot widen what it sees — it can only name another already-public query or fail to decode. The unlisted-inclusive plain `listRecords` pipeline deliberately has no registry entry, so no cursor can reach it. `decodeCursor` never throws and returns null on anything malformed, so load-more simply ends pagination cleanly. A deep-linked `?cursor=` only resumes when the envelope was minted for the *same* query — same `q` **and** the same public-safe scope (topic slug, profile actor, or popular/all toggle); a keyset is specific to its result set, so a `technology` topic cursor, another actor's keyset, or a `popular` cursor under `?filter=all` all start a fresh page 1 rather than resuming a foreign position. Search (`search-d1`/`search-meili`) deep-links never resume: their defining term rides `?q=`, not the envelope, so an inbound cursor can't be proven to match the route's term. + +**Backend-native cursors.** `raw` stays opaque and backend-specific: a D1 keyset built inside `@atmo-dev/contrail`, or a Meilisearch offset that `tagCursor` prefixes as `meili:`. `tagCursor` and `parseCursor` in `cursor.ts` are that Meilisearch offset codec, also used by near-me; the envelope simply wraps whatever they produce. Cursors minted before the envelope existed (top-level `meili:` or `d1:` tags, or bare offsets) are tolerated as a deploy-window courtesy — they fail to decode and end pagination cleanly rather than resuming incorrectly — and that tolerance can be dropped once such cursors have drained. + +**The pieces.** `cursor.ts` handles envelope encode/decode and backend tagging. `events-load-more.ts` holds the resumer registry and the shared load-more handler. Each route's `+page.server.ts` mints the page-1 envelope from its own filters, and `EventList.svelte` echoes the token on "load more". Adding a query or backend means registering a resumer, not editing a branch. + ## contributing open for contributions by all :) diff --git a/apps/web/src/lib/components/EventList.svelte b/apps/web/src/lib/components/EventList.svelte index 8c6853b..abf88f8 100644 --- a/apps/web/src/lib/components/EventList.svelte +++ b/apps/web/src/lib/components/EventList.svelte @@ -9,14 +9,19 @@ cursor, handles = {}, actor = undefined, - fetchParams, + q = undefined, gridClass = 'grid gap-6 sm:grid-cols-2' }: { events: FlatEventRecord[]; cursor: string | null; handles?: Record; actor?: string | undefined; - fetchParams: Record; + // The cursor is now a fully opaque, self-describing continuation envelope: + // load-more POSTs only { cursor }, no client-echoed pipeline/ + // filters. The single exception is the free-text search TERM, which stays + // OUT of the envelope and rides here so the search page's load-more can + // re-run its query; other pages leave it undefined. + q?: string | undefined; gridClass?: string; } = $props(); @@ -43,19 +48,13 @@ loading = true; try { - const params: Record = {}; - for (const [key, value] of Object.entries(fetchParams)) { - if (key === 'limit' || key === 'rsvpsGoingCountMin' || key === 'rsvpsCountMin') { - params[key] = Number(value); - } else if (key === 'profiles') { - params[key] = value === 'true'; - } else { - params[key] = value; - } - } - params.cursor = currentCursor; - - const result = await loadMoreEvents(params as Parameters[0]); + // Opaque token in, opaque token out: the envelope names the server-side + // query, so there is no client-side query reconstruction to echo. Only + // the search term (when present) rides alongside the cursor. + const result = await loadMoreEvents({ + cursor: currentCursor, + ...(q !== undefined ? { q } : {}) + }); extraEvents = [...extraEvents, ...result.events]; currentCursor = result.cursor; diff --git a/apps/web/src/routes/(app)/events/+page.server.ts b/apps/web/src/routes/(app)/events/+page.server.ts index 787b2bb..15b64ee 100644 --- a/apps/web/src/routes/(app)/events/+page.server.ts +++ b/apps/web/src/routes/(app)/events/+page.server.ts @@ -3,7 +3,7 @@ import { getServerClient, listDiscoverableEventsFromContrail } from '$lib/contrail'; -import { parseCursor, tagCursor } from '$lib/contrail/cursor'; +import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; const PAGE_SIZE = 20; @@ -11,10 +11,10 @@ const PAGE_SIZE = 20; export const load: PageServerLoad = async ({ url, platform }) => { const client = getServerClient(platform!.env.DB); const now = new Date().toISOString(); - // Untag any inbound cursor (deep link) before handing the opaque keyset to D1; - // legacy untagged cursors pass through unchanged (om-7dbs). - const cursor = parseCursor(url.searchParams.get('cursor')).raw ?? undefined; const isPopular = url.searchParams.get('filter') !== 'all'; + // Deep-link ?cursor= resumes only an 'events' cursor minted for the same + // popular/all filter; anything else -> fresh page 1 (see rawForQuery). + const cursor = rawForQuery(url.searchParams.get('cursor'), 'events', { popular: isPopular }); const response = await listDiscoverableEventsFromContrail(client, { startsAtMin: now, @@ -36,8 +36,8 @@ export const load: PageServerLoad = async ({ url, platform }) => { return { events: flattenEventRecords(response.records), handles, - // Tag the first-page cursor so load-more routes back to this same D1 - // discoverable pipeline instead of re-inferring a backend (om-7dbs). - cursor: tagCursor('d1', response.cursor ?? null) + // A self-describing envelope: load-more re-runs THIS server-side query + // (discoverable + startsAtMin=now + the popular toggle), no client filters. + cursor: nextCursor('events', response.cursor ?? null, { popular: isPopular }) }; }; diff --git a/apps/web/src/routes/(app)/events/+page.svelte b/apps/web/src/routes/(app)/events/+page.svelte index 35b20f6..8a3142e 100644 --- a/apps/web/src/routes/(app)/events/+page.svelte +++ b/apps/web/src/routes/(app)/events/+page.svelte @@ -8,18 +8,6 @@ let filter = $derived(page.url.searchParams.get('filter') === 'all' ? 'all' : 'popular'); - let fetchParams = $derived({ - // load-more must re-run the discoverable pipeline + popular filter page 1 - // used, or unlisted / non-popular events leak onto later pages. - pipeline: 'discoverable', - startsAtMin: new Date().toISOString(), - profiles: 'true', - sort: 'startsAt', - order: 'asc', - limit: '20', - ...(filter === 'popular' ? { rsvpsCountMin: '2' } : {}) - }); - function setFilter(val: string) { const url = new URL(page.url); if (val === 'all') url.searchParams.set('filter', 'all'); @@ -67,6 +55,6 @@ {/if}

{:else} - + {/if} diff --git a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts index 6486d82..35e75f4 100644 --- a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.server.ts @@ -5,7 +5,7 @@ import { getServerClient, listAuthoredEventsFromContrail } from '$lib/contrail'; -import { parseCursor, tagCursor } from '$lib/contrail/cursor'; +import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -20,8 +20,8 @@ export async function load({ params, url, platform }) { if (!did) throw error(404, 'Actor not found'); - // Untag any inbound cursor before the D1 read; legacy untagged passes through. - const cursor = parseCursor(url.searchParams.get('cursor')).raw ?? undefined; + // Deep-link ?cursor= resumes only a 'hosting' cursor for this actor; else fresh page 1. + const cursor = rawForQuery(url.searchParams.get('cursor'), 'hosting', { actor }); const now = new Date().toISOString(); const [profile, response] = await Promise.all([ @@ -39,8 +39,9 @@ export async function load({ params, url, platform }) { return { events: response ? flattenEventRecords(response.records) : [], - // Tag so load-more stays on this D1 authored pipeline (om-7dbs). - cursor: tagCursor('d1', response?.cursor ?? null), + // Self-describing envelope: load-more re-runs the authored + upcoming query + // scoped to this actor, server-side. + cursor: nextCursor('hosting', response?.cursor ?? null, { actor }), actorProfile: profile, actor, actorDid: did diff --git a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.svelte b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.svelte index dbd98b3..c55eee9 100644 --- a/apps/web/src/routes/(app)/p/[actor]/hosting/+page.svelte +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/+page.svelte @@ -10,18 +10,6 @@ let hostAvatar = $derived( hostProfile?.value?.avatar ? getProfileBlobUrl(hostDid, hostProfile.value.avatar) : undefined ); - - let fetchParams: Record = $derived({ - // load-more must re-run the authored pipeline page 1 used, or conference - // talks (excluded by listAuthored) leak onto later pages. - pipeline: 'authored', - profiles: 'true', - sort: 'startsAt', - order: 'asc', - startsAtMin: new Date().toISOString(), - ...(data.actor ? { actor: data.actor } : {}), - limit: '20' - }); @@ -55,7 +43,6 @@ events={data.events ?? []} cursor={data.cursor ?? null} actor={data.actor} - {fetchParams} gridClass="space-y-3" /> {:else} diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts index 56549b0..44fe029 100644 --- a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.server.ts @@ -5,7 +5,7 @@ import { getServerClient, listAuthoredEventsFromContrail } from '$lib/contrail'; -import { parseCursor, tagCursor } from '$lib/contrail/cursor'; +import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; import { isActorIdentifier } from '@atcute/lexicons/syntax'; import { error } from '@sveltejs/kit'; @@ -20,8 +20,8 @@ export async function load({ params, url, platform }) { if (!did) throw error(404, 'Actor not found'); - // Untag any inbound cursor before the D1 read; legacy untagged passes through. - const cursor = parseCursor(url.searchParams.get('cursor')).raw ?? undefined; + // Deep-link ?cursor= resumes only a 'past-events' cursor for this actor; else fresh page 1. + const cursor = rawForQuery(url.searchParams.get('cursor'), 'past-events', { actor }); const now = new Date().toISOString(); const [profile, response] = await Promise.all([ @@ -44,8 +44,9 @@ export async function load({ params, url, platform }) { return { events, - // Tag so load-more stays on this D1 authored pipeline (om-7dbs). - cursor: tagCursor('d1', response?.cursor ?? null), + // Self-describing envelope: load-more re-runs the authored + past query + // (desc, startsAtMax=now) scoped to this actor, server-side. + cursor: nextCursor('past-events', response?.cursor ?? null, { actor }), actorProfile: profile, actor, actorDid: did diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte index 3b73860..a2b76e1 100644 --- a/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/+page.svelte @@ -10,18 +10,6 @@ let hostAvatar = $derived( hostProfile?.value?.avatar ? getProfileBlobUrl(hostDid, hostProfile.value.avatar) : undefined ); - - let fetchParams: Record = $derived({ - // load-more must re-run the authored pipeline page 1 used, or conference - // talks (excluded by listAuthored) leak onto later pages. - pipeline: 'authored', - profiles: 'true', - sort: 'startsAt', - order: 'desc', - startsAtMax: new Date().toISOString(), - ...(data.actor ? { actor: data.actor } : {}), - limit: '20' - }); @@ -55,7 +43,6 @@ events={data.events ?? []} cursor={data.cursor ?? null} actor={data.actor} - {fetchParams} gridClass="space-y-3" /> {:else} diff --git a/apps/web/src/routes/(app)/search/+page.server.ts b/apps/web/src/routes/(app)/search/+page.server.ts index 95eeb5e..9914d94 100644 --- a/apps/web/src/routes/(app)/search/+page.server.ts +++ b/apps/web/src/routes/(app)/search/+page.server.ts @@ -5,23 +5,32 @@ import { } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; import { SEARCH_PAGE_SIZE } from '$lib/search/constants'; +import { nextCursor } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ url, platform }) => { const client = getServerClient(platform!.env.DB); const q = url.searchParams.get('q')?.trim() || ''; - const cursor = url.searchParams.get('cursor') ?? undefined; if (!q) return { events: [], handles: {}, cursor: null, query: '' }; + // Search page 1 does NOT resume from ?cursor=: the term rides ?q=, not the + // envelope, so an inbound cursor can't be proven to match this route's term. + // (Load-more still resumes via the remote command, which carries the term.) + // Meilisearch ranks (typo tolerance, prefix, relevance); D1 supplies the // records. Falls back to the LIKE-based D1 path when the search backend is // unconfigured (local dev) or down. const backend = searchBackendFromEnv(platform?.env); if (backend) { try { - const page = await runEventSearchPage(backend, client, { q, cursor }); - return { events: page.events, handles: page.handles, cursor: page.cursor, query: q }; + const page = await runEventSearchPage(backend, client, { q }); + return { + events: page.events, + handles: page.handles, + cursor: nextCursor('search-meili', page.cursor), + query: q + }; } catch (err) { console.error('search backend failed, falling back to D1 search:', err); } @@ -36,8 +45,7 @@ export const load: PageServerLoad = async ({ url, platform }) => { startsAtMin: new Date().toISOString(), sort: 'startsAt', order: 'desc', - limit: SEARCH_PAGE_SIZE, - cursor + limit: SEARCH_PAGE_SIZE }); if (!response) return { events: [], handles: {}, cursor: null, query: q }; @@ -50,14 +58,11 @@ export const load: PageServerLoad = async ({ url, platform }) => { return { events: flattenEventRecords(response.records), handles, - // The D1 fallback is first-batch-only; don't hand its cursor back. Even - // with self-describing cursors (om-7dbs), the search fetchParams carry no - // `pipeline`, so a d1-tagged cursor would route load-more through plain - // listRecords — dropping the discoverable filter and startsAtMin this page - // applies — and later pages would drift into past and non-discoverable - // events. Re-enabling consistent D1 pagination here would mean threading the - // discoverable pipeline + filters through; deferred. Drop the cursor. - cursor: null, + // Self-describing 'search-d1' envelope: load-more re-runs the SAME + // discoverable + startsAtMin + desc query, with the search term from ?q=/ + // input — later pages stay upcoming-only and discoverable, no drift into + // past/non-discoverable events. + cursor: nextCursor('search-d1', response.cursor ?? null), query: q }; }; diff --git a/apps/web/src/routes/(app)/search/+page.svelte b/apps/web/src/routes/(app)/search/+page.svelte index ee540d7..87378b7 100644 --- a/apps/web/src/routes/(app)/search/+page.svelte +++ b/apps/web/src/routes/(app)/search/+page.svelte @@ -57,13 +57,7 @@ events={data.events} cursor={data.cursor} handles={data.handles} - fetchParams={{ - search: data.query, - profiles: 'true', - sort: 'startsAt', - order: 'desc', - limit: '20' - }} + q={data.query} /> {/if} {/if} diff --git a/apps/web/src/routes/(app)/search/page.server.test.ts b/apps/web/src/routes/(app)/search/page.server.test.ts index 2e2f491..4062a03 100644 --- a/apps/web/src/routes/(app)/search/page.server.test.ts +++ b/apps/web/src/routes/(app)/search/page.server.test.ts @@ -2,12 +2,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; // The search load decides between two backends whose cursors are NOT // interchangeable: Meilisearch (offset cursor) and the D1 LIKE fallback (opaque -// cursor). loadMoreEvents re-routes to Meili whenever a backend is configured, -// so a D1 cursor handed back after a backend failure would be misread. And even -// with no backend at all, loadMoreEvents paginates via listRecords — without the -// discoverable filter or startsAtMin this load applies — so its cursor isn't -// safe to continue either. The D1 fallback is therefore first-batch-only. These -// tests pin that contract. +// keyset). Both now hand back a self-describing continuation ENVELOPE: the Meili +// path a 'search-meili' envelope, the D1 fallback a 'search-d1' envelope. The D1 +// fallback used to return cursor:null because load-more had no safe way to +// re-run the discoverable+startsAtMin query — the envelope closes that gap (and +// with it the earlier "search results stop after the first batch" limitation), +// so these tests now pin a REAL cursor on the D1 path, keyed to a query the +// load-more registry re-runs identically. vi.mock('$lib/contrail', () => ({ getServerClient: vi.fn(() => ({})), flattenEventRecords: vi.fn((records: unknown[]) => records), @@ -19,8 +20,9 @@ vi.mock('$lib/search/server/query', () => ({ })); import { load } from './+page.server'; -import { flattenEventRecords, listDiscoverableEventsFromContrail } from '$lib/contrail'; +import { listDiscoverableEventsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; const mockSearchBackendFromEnv = vi.mocked(searchBackendFromEnv); const mockRunEventSearchPage = vi.mocked(runEventSearchPage); @@ -54,23 +56,24 @@ describe('search page load', () => { expect(mockListDiscoverable).not.toHaveBeenCalled(); }); - it('serves the Meili page (offset cursor) when the backend succeeds', async () => { + it('serves the Meili page wrapped in a search-meili envelope when the backend succeeds', async () => { mockSearchBackendFromEnv.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); mockRunEventSearchPage.mockResolvedValue({ events: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], handles: { 'did:plc:a': 'alice' }, - cursor: '20', + cursor: 'meili:20', distances: {} } as unknown as Awaited>); const result = await runLoad('kite'); - expect(result.cursor).toBe('20'); - expect(result.events).toHaveLength(1); + // The offset rides inside a self-describing envelope, so load-more re-runs + // the Meili path (not D1 listRecords) with the term from ?q=/input. + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-meili', raw: 'meili:20' }); expect(mockListDiscoverable).not.toHaveBeenCalled(); }); - it('drops the D1 cursor when a configured backend fails, so load-more cannot misroute it', async () => { + it('paginates the D1 fallback with a search-d1 envelope when a configured backend fails', async () => { mockSearchBackendFromEnv.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); mockRunEventSearchPage.mockRejectedValue(new Error('meili down')); mockListDiscoverable.mockResolvedValue({ @@ -81,15 +84,13 @@ describe('search page load', () => { const result = await runLoad('kite'); - // Events still served from the D1 fallback... - expect(result.events).toHaveLength(1); expect(result.handles).toEqual({ 'did:plc:b': 'bob' }); - // ...but the incompatible D1 cursor is suppressed. - expect(result.cursor).toBeNull(); - expect(flattenEventRecords).toHaveBeenCalled(); + // The D1 cursor is now RESUMABLE: a search-d1 envelope whose load-more re-runs + // the same discoverable + startsAtMin + desc query. No more cursor:null. + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-d1', raw: 'd1-opaque-cursor' }); }); - it('drops the D1 cursor when no backend is configured, so load-more cannot drift past the first batch', async () => { + it('paginates the D1 fallback with a search-d1 envelope when no backend is configured', async () => { mockSearchBackendFromEnv.mockReturnValue(null); mockListDiscoverable.mockResolvedValue({ records: [{ uri: 'at://did:plc:c/community.lexicon.calendar.event/3' }], @@ -99,12 +100,56 @@ describe('search page load', () => { const result = await runLoad('kite'); - // First batch is served from the discoverable, upcoming-only D1 query... - expect(result.events).toHaveLength(1); - // ...but the cursor is suppressed: loadMoreEvents would paginate via - // listRecords without the discoverable filter or startsAtMin, drifting - // into past and non-discoverable events on later pages. - expect(result.cursor).toBeNull(); + // First batch is the discoverable, upcoming-only, desc D1 query... + const params = mockListDiscoverable.mock.calls[0][1]; + expect(params).toMatchObject({ search: 'kite', order: 'desc' }); + expect(typeof params.startsAtMin).toBe('string'); + // ...and later pages resume it via a real envelope, not cursor:null. + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-d1', raw: 'd1-opaque-cursor' }); expect(mockRunEventSearchPage).not.toHaveBeenCalled(); }); + + it('ends cleanly (cursor:null) only on a genuinely last D1 page', async () => { + mockSearchBackendFromEnv.mockReturnValue(null); + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:d/community.lexicon.calendar.event/4' }], + profiles: [], + cursor: null + } as unknown as Awaited>); + + const result = await runLoad('kite'); + expect(result.cursor).toBeNull(); + }); + + it('deep-link: does NOT resume even its OWN search-d1 envelope (term not in envelope)', async () => { + mockSearchBackendFromEnv.mockReturnValue(null); + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + // The keyset was minted for SOME term, but the term rides ?q= and is absent + // from the envelope — a `dogs` keyset under ?q=kite would corrupt pagination, + // and the two are indistinguishable. So page 1 always starts fresh. + const inbound = encodeCursor({ v: 1, q: 'search-d1', raw: 'page2keyset' }); + await runLoad('kite', inbound); + + expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('deep-link: ignores a foreign-query envelope (fresh page 1, no resume)', async () => { + mockSearchBackendFromEnv.mockReturnValue(null); + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + // An 'events' envelope deep-linked into /search must not resume its keyset. + const foreign = encodeCursor({ v: 1, q: 'events', args: { popular: true }, raw: 'nope' }); + await runLoad('kite', foreign); + + expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); }); diff --git a/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts b/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts index e7f9628..ba7b4c4 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts +++ b/apps/web/src/routes/(app)/topics/[slug]/+page.server.ts @@ -1,15 +1,16 @@ import { error } from '@sveltejs/kit'; -import { getTopicBySlug } from '$lib/topics'; +import { getTopicBySlug, orQueryFromSlug } from '$lib/topics'; import { flattenEventRecords, getServerClient, listDiscoverableEventsFromContrail } from '$lib/contrail'; +import { nextCursor, rawForQuery } from '$lib/contrail/cursor'; import type { PageServerLoad } from './$types'; const PAGE_SIZE = 20; -export const load: PageServerLoad = async ({ params, platform }) => { +export const load: PageServerLoad = async ({ params, url, platform }) => { const topic = getTopicBySlug(params.slug); if (!topic) error(404, 'Topic not found'); @@ -18,9 +19,9 @@ export const load: PageServerLoad = async ({ params, platform }) => { // Match events whose name/description mention ANY of the topic's hashtag // terms. The discoverable list runs `search` through D1's SQLite FTS5 MATCH, // where an uppercase OR is a real disjunction operator — so this is a true - // "any term" query. (Meili treats OR as a literal token, but this page never - // routes through Meili: see the cursor note below.) - const query = topic.hashtags.map((h) => h.replace(/^#/, '')).join(' OR '); + // "any term" query. Derived SERVER-side from the slug via the shared helper the + // load-more registry also uses, so page 1 and load-more can't drift. + const query = orQueryFromSlug(params.slug) ?? ''; const response = await listDiscoverableEventsFromContrail(client, { search: query, @@ -29,7 +30,9 @@ export const load: PageServerLoad = async ({ params, platform }) => { startsAtMin: new Date().toISOString(), sort: 'startsAt', order: 'asc', - limit: PAGE_SIZE + limit: PAGE_SIZE, + // Deep-link ?cursor= resumes only a 'topic' cursor for this slug; else fresh page 1. + cursor: rawForQuery(url.searchParams.get('cursor'), 'topic', { slug: params.slug }) }); const handles: Record = {}; @@ -41,15 +44,11 @@ export const load: PageServerLoad = async ({ params, platform }) => { topic, events: flattenEventRecords(response?.records ?? []), handles, - // First-batch-only, like the search page's D1 fallback. Self-describing - // cursors (om-7dbs) now stop this page's D1 cursor from being mis-consumed - // as a Meili offset, but they don't make it resumable here: this fetchParams - // contract carries no `pipeline`, so a d1-tagged cursor would still route - // load-more through plain listRecords, dropping the discoverable + - // startsAtMin filters this page relies on. So don't hand back a cursor. Deep - // topic pagination (threading the discoverable pipeline through) is tracked - // separately (om-47ak). - cursor: null, + // Self-describing 'topic' envelope carrying the slug: load-more re-derives + // the same OR-search from the slug SERVER-side and re-runs the identical + // discoverable + startsAtMin query — later pages stay upcoming-only and + // discoverable. + cursor: nextCursor('topic', response?.cursor ?? null, { slug: params.slug }), query }; }; diff --git a/apps/web/src/routes/(app)/topics/[slug]/+page.svelte b/apps/web/src/routes/(app)/topics/[slug]/+page.svelte index 119020c..b8ef977 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/+page.svelte +++ b/apps/web/src/routes/(app)/topics/[slug]/+page.svelte @@ -2,18 +2,6 @@ import EventList from '$lib/components/EventList.svelte'; let { data } = $props(); - - // Query is built server-side and passed through `data` so the two stay in - // sync. Mirrors the search page. The topic page is first-batch-only - // (data.cursor is null), so these params are only a contract for EventList, - // not an active pagination path. - let fetchParams = $derived({ - search: data.query, - profiles: 'true', - sort: 'startsAt', - order: 'asc', - limit: '20' - }); @@ -62,11 +50,6 @@

{:else} - + {/if} diff --git a/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts new file mode 100644 index 0000000..f3c8b9e --- /dev/null +++ b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// The topic page used to return cursor:null (first-batch-only) because load-more +// had no safe way to re-run its discoverable + OR-search + startsAtMin query. +// The envelope closes that gap: the load now emits a self-describing 'topic' +// envelope carrying the slug, and the load-more registry re-derives the SAME +// OR-search from that slug server-side. These pin the page-1 side of that +// continuity plus the deep-link query-match rule. +vi.mock('$lib/contrail', () => ({ + getServerClient: vi.fn(() => ({})), + flattenEventRecords: vi.fn((records: unknown[]) => records), + listDiscoverableEventsFromContrail: vi.fn() +})); + +import { load } from './+page.server'; +import { listDiscoverableEventsFromContrail } from '$lib/contrail'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; + +const mockListDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); + +type LoadResult = { + topic: { slug: string }; + events: unknown[]; + handles: Record; + cursor: string | null; + query: string; +}; + +function event(slug: string, cursor?: string) { + const url = new URL(`https://atmo.test/topics/${slug}`); + if (cursor) url.searchParams.set('cursor', cursor); + return { params: { slug }, url, platform: { env: {} } } as unknown as Parameters[0]; +} + +const run = async (slug: string, cursor?: string) => + (await load(event(slug, cursor))) as unknown as LoadResult; + +afterEach(() => vi.clearAllMocks()); + +describe('topic page load', () => { + it("builds a 'topic' envelope carrying the slug, deriving the OR-search server-side", async () => { + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [{ did: 'did:plc:a', handle: 'alice' }], + cursor: 'd1-topic-cursor' + } as unknown as Awaited>); + + const result = await run('technology'); + + const params = mockListDiscoverable.mock.calls[0][1]; + // orQueryFromSlug('technology') — the SAME helper the load-more registry uses. + expect(params.search).toBe('tech OR technology'); + expect(params).toMatchObject({ order: 'asc', limit: 20, profiles: true }); + expect(typeof params.startsAtMin).toBe('string'); + expect(result.query).toBe('tech OR technology'); + // A resumable envelope, not cursor:null. + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'topic', + args: { slug: 'technology' }, + raw: 'd1-topic-cursor' + }); + }); + + it('ends cleanly (cursor:null) only on a genuinely last page', async () => { + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [], + cursor: null + } as unknown as Awaited>); + + const result = await run('technology'); + expect(result.cursor).toBeNull(); + }); + + it('deep-link: resumes a topic envelope by feeding its raw keyset to D1', async () => { + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + const inbound = encodeCursor({ v: 1, q: 'topic', args: { slug: 'technology' }, raw: 'p2keyset' }); + await run('technology', inbound); + + expect(mockListDiscoverable.mock.calls[0][1]).toMatchObject({ cursor: 'p2keyset' }); + }); + + it('deep-link: ignores a foreign-query envelope (fresh page 1)', async () => { + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + const foreign = encodeCursor({ v: 1, q: 'events', args: { popular: true }, raw: 'nope' }); + await run('technology', foreign); + + expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it("deep-link: ignores a topic envelope minted for a DIFFERENT slug (fresh page 1)", async () => { + mockListDiscoverable.mockResolvedValue({ + records: [], + profiles: [], + cursor: null + } as unknown as Awaited>); + + // A 'technology' keyset deep-linked into /topics/ai names the same query but + // indexes a different OR-search result set — must not resume. + const otherSlug = encodeCursor({ v: 1, q: 'topic', args: { slug: 'technology' }, raw: 'nope' }); + await run('ai', otherSlug); + + expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('404s for an unknown slug before touching D1', async () => { + await expect(run('no-such-topic')).rejects.toThrow(); + expect(mockListDiscoverable).not.toHaveBeenCalled(); + }); +});