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