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();
+ });
+});