import { badRequest } from "@/lib/api/errors"; /** * Cursor format (opaque for clients): * - base64url(JSON.stringify(payload)) * * We keep the cursor extensible so we can switch provider internals later * (e.g. async-search context_id) without changing the public API. * * Current v1 payload shape: * { * v: 1, * mode: "sync", * offset: number, * contextId?: string * } */ function isPlainObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function toBase64Url(jsonString) { // Node.js supports "base64url" encoding out of the box. return Buffer.from(String(jsonString), "utf8").toString("base64url"); } function fromBase64Url(b64) { return Buffer.from(String(b64), "base64url").toString("utf8"); } /** * Encode a cursor payload into an opaque string. * * @param {{ v?: number, mode?: string, offset: number, contextId?: string }} payload * @returns {string} */ export function encodeCursor(payload) { if (!isPlainObject(payload)) { throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor payload"); } const v = payload.v ?? 1; const mode = payload.mode ?? "sync"; const offset = payload.offset; if (!Number.isInteger(offset) || offset < 0) { throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor payload", { offset, }); } const normalized = { v, mode: String(mode), offset, }; if (payload.contextId) normalized.contextId = String(payload.contextId); return toBase64Url(JSON.stringify(normalized)); } /** * Decode an opaque cursor string. * * @param {string|null|undefined} cursor * @returns {{ v: number, mode: string, offset: number, contextId: string|null }} */ export function decodeCursor(cursor) { if (!cursor) { return { v: 1, mode: "sync", offset: 0, contextId: null }; } if (typeof cursor !== "string" || !cursor.trim()) { throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor"); } let raw; try { raw = fromBase64Url(cursor.trim()); } catch (err) { throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor"); } let parsed; try { parsed = JSON.parse(raw); } catch (err) { throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor"); } if (!isPlainObject(parsed)) { throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor"); } const v = Number(parsed.v ?? 1); const mode = String(parsed.mode ?? "sync"); const offset = Number(parsed.offset); if (!Number.isInteger(v) || v !== 1) { throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor"); } if (!Number.isInteger(offset) || offset < 0) { throw badRequest("VALIDATION_SEARCH_CURSOR", "Invalid cursor"); } const contextId = typeof parsed.contextId === "string" && parsed.contextId.trim() ? parsed.contextId.trim() : null; return { v, mode, offset, contextId }; }