Files
zpan/server/http/page-token.ts
T
Jasper Van ad0f21bb39 fix: unify list pagination and realtime updates (#524)
* fix!: unify pagination and realtime change delivery

Replace offset paging on affected unbounded collections with signed keyset tokens and infinite loading. Persist scoped resource changes so one global SSE connection can resume and invalidate query caches safely.

BREAKING CHANGE: migrated list APIs now accept pageToken and return nextPageToken instead of page and total fields.

Refs #450

* fix: keep page tokens at the HTTP boundary

Move signed page-token handling out of the pure domain layer so dependency-cruiser architecture checks pass without changing behavior.

* fix: route background job stats through usecase

Keep the HTTP boundary from reaching directly into repository ports and cover the new usecase wrapper.

* fix: align clients and checks with cursor pagination

* refactor: unify pagination boundaries and infinite loading
2026-07-27 02:02:53 -04:00

165 lines
5.1 KiB
TypeScript

import type { Platform } from '../platform/interface'
import { badRequest } from '../usecases/ports'
const TOKEN_VERSION = 1
const TOKEN_TTL_MS = 72 * 60 * 60 * 1000
const TOKEN_PURPOSE = 'zpan:page-token:v1'
export type PageBoundary = Record<string, string | number | null>
export interface PageCursorCodec<T> {
decode(boundary: PageBoundary): T | undefined
encode(cursor: T): PageBoundary
}
type PageTokenPayload = {
v: typeof TOKEN_VERSION
boundary: PageBoundary
query: string
expiresAt: number
}
function invalidPageToken(): never {
throw badRequest('Invalid page token', 'INVALID_PAGE_TOKEN')
}
function encodeBase64Url(value: Uint8Array): string {
return Buffer.from(value).toString('base64url')
}
function decodeBase64Url(value: string): Uint8Array {
return new Uint8Array(Buffer.from(value, 'base64url'))
}
function secret(platform: Platform): string {
const value = platform.getEnv('BETTER_AUTH_SECRET')
if (!value) throw new Error('BETTER_AUTH_SECRET is required')
return value
}
async function signingKey(platform: Platform): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
new TextEncoder().encode(`${TOKEN_PURPOSE}:${secret(platform)}`),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
)
}
export async function pageQueryFingerprint(value: unknown): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(value)))
return encodeBase64Url(new Uint8Array(digest))
}
export async function encodePageToken(
platform: Platform,
input: { boundary: PageBoundary; query: string; now?: number },
): Promise<string> {
const payload: PageTokenPayload = {
v: TOKEN_VERSION,
boundary: input.boundary,
query: input.query,
expiresAt: (input.now ?? Date.now()) + TOKEN_TTL_MS,
}
const body = encodeBase64Url(new TextEncoder().encode(JSON.stringify(payload)))
const signature = await crypto.subtle.sign('HMAC', await signingKey(platform), new TextEncoder().encode(body))
return `${body}.${encodeBase64Url(new Uint8Array(signature))}`
}
export async function decodePageToken(
platform: Platform,
token: string,
input: { query: string; now?: number },
): Promise<PageBoundary> {
const [body, signature, extra] = token.split('.')
if (!body || !signature || extra) invalidPageToken()
const signatureBytes = decodeBase64Url(signature)
const verificationSignature = new Uint8Array(signatureBytes.byteLength)
verificationSignature.set(signatureBytes)
const valid = await crypto.subtle.verify(
'HMAC',
await signingKey(platform),
verificationSignature,
new TextEncoder().encode(body),
)
if (!valid) invalidPageToken()
let payload: PageTokenPayload
try {
payload = JSON.parse(new TextDecoder().decode(decodeBase64Url(body))) as PageTokenPayload
} catch {
invalidPageToken()
}
if (
payload.v !== TOKEN_VERSION ||
typeof payload.boundary !== 'object' ||
payload.boundary === null ||
payload.query !== input.query ||
!Number.isFinite(payload.expiresAt) ||
payload.expiresAt <= (input.now ?? Date.now())
) {
invalidPageToken()
}
return payload.boundary
}
export async function decodeOptionalPageToken<T>(
platform: Platform,
token: string | undefined,
input: { query: string; codec: PageCursorCodec<T> },
): Promise<T | undefined> {
if (!token) return undefined
const cursor = input.codec.decode(await decodePageToken(platform, token, { query: input.query }))
if (!cursor) invalidPageToken()
return cursor
}
export async function encodeNextPageToken<T>(
platform: Platform,
cursor: T | null,
input: { query: string; codec: PageCursorCodec<T> },
): Promise<string | null> {
if (!cursor) return null
return encodePageToken(platform, { query: input.query, boundary: input.codec.encode(cursor) })
}
type CreatedAtIdCursor = { createdAt: Date; id: string }
export const createdAtIdCursorCodec: PageCursorCodec<CreatedAtIdCursor> = {
decode(boundary) {
if (typeof boundary.createdAt !== 'number' || typeof boundary.id !== 'string') return undefined
return { createdAt: new Date(boundary.createdAt), id: boundary.id }
},
encode(cursor) {
return { createdAt: cursor.createdAt.getTime(), id: cursor.id }
},
}
type DirectoryCursor = CreatedAtIdCursor & { dirtype: number }
export const directoryCursorCodec: PageCursorCodec<DirectoryCursor> = {
decode(boundary) {
const cursor = createdAtIdCursorCodec.decode(boundary)
if (!cursor || typeof boundary.dirtype !== 'number') return undefined
return { dirtype: boundary.dirtype, ...cursor }
},
encode(cursor) {
return { dirtype: cursor.dirtype, ...createdAtIdCursorCodec.encode(cursor) }
},
}
type TrashCursor = CreatedAtIdCursor & { trashedAt: number }
export const trashCursorCodec: PageCursorCodec<TrashCursor> = {
decode(boundary) {
const cursor = createdAtIdCursorCodec.decode(boundary)
if (!cursor || typeof boundary.trashedAt !== 'number') return undefined
return { trashedAt: boundary.trashedAt, ...cursor }
},
encode(cursor) {
return { trashedAt: cursor.trashedAt, ...createdAtIdCursorCodec.encode(cursor) }
},
}