vti_common/pagination/mod.rs
1//! Cursor pagination — workspace-wide standard for every list
2//! endpoint.
3//!
4//! Implements **M0.1.4** of the VTC MVP Phase 0 plan. The cursor
5//! contract from spec §9.1:
6//!
7//! ```text
8//! GET /v1/<collection>?cursor=<opaque>&limit=<1..200>
9//!
10//! → 200 OK
11//! {
12//! "items": [...],
13//! "next_cursor": "<opaque>" | null,
14//! "total_estimate": <u64 | null>
15//! }
16//! ```
17//!
18//! ## Design properties
19//!
20//! - **Opaque to consumers.** The cursor is a base64url-encoded
21//! binary blob; clients pass it back verbatim. No public structure.
22//! - **Tamper-evident.** The cursor's payload is HMAC-SHA256-signed
23//! under the per-community `audit_key`. A cursor minted for
24//! community A can't be replayed against community B; a guessed
25//! cursor can't iterate past the protocol boundary. See
26//! [`Cursor::decode`] for the verification flow.
27//! - **No structural feedback on failure.** A tampered, forged, or
28//! malformed cursor returns [`AppError::InvalidCursor`] (400) with
29//! no detail. The HMAC verification + payload deserialisation share
30//! a single error so the caller can't learn whether the tag check
31//! or the structure check rejected them.
32//! - **`(last_key, snapshot_id)` payload.** `last_key` is the raw
33//! storage key of the last item the previous page returned;
34//! `snapshot_id` is the wall-clock timestamp at mint. The
35//! snapshot is carried through opaquely (no consistency check
36//! today — newly-inserted rows may or may not appear in the next
37//! page). Plumbing for it is in place so a future tightening can
38//! land without a wire-shape change.
39//!
40//! ## Limit bounds
41//!
42//! - **min**: 1 (a zero-limit request would never make progress).
43//! - **max**: 200 — pinned in code per spec §9.1.
44//! - **default**: 50 — the most common operator-facing list size.
45//!
46//! Larger reads should iterate via repeated `next_cursor` calls;
47//! the maximum is non-negotiable to keep response sizes bounded.
48//!
49//! ## Storage iteration model
50//!
51//! For Phase 0, [`paginate`] takes the already-materialised list of
52//! raw key/value pairs from [`crate::store::KeyspaceHandle::prefix_iter_raw`]
53//! and walks them in-memory. That's O(N) per page but bounded by the
54//! community's keyspace size — adequate for the small lists (members,
55//! join requests, policies, audit) that Phase 0 ships. A cursor-aware
56//! `prefix_iter_after` method on `KeyspaceHandle` is the natural
57//! follow-up if community sizes outgrow this; the wire shape doesn't
58//! change.
59
60use std::io::{Cursor as IoCursor, Read};
61
62use base64::Engine;
63use hmac::{Hmac, KeyInit, Mac};
64use serde::{Deserialize, Serialize};
65use sha2::Sha256;
66
67use crate::error::AppError;
68use crate::store::RawKvPair;
69
70const B64: base64::engine::general_purpose::GeneralPurpose =
71 base64::engine::general_purpose::URL_SAFE_NO_PAD;
72
73/// HMAC tag length in bytes. 32 (full SHA-256 output) — overhead is
74/// trivial relative to base64-encoded cursors and removes any
75/// concern about short-tag birthday-style attacks.
76const HMAC_TAG_LEN: usize = 32;
77
78/// Maximum cursor `last_key` length to accept on decode. Prevents an
79/// adversary from supplying a multi-megabyte cursor that allocates
80/// inflated buffers. Keyspace keys in the workspace are well under
81/// this in practice.
82const MAX_LAST_KEY_LEN: u32 = 1024;
83
84/// Minimum list-page size accepted from query params. See module docs.
85pub const MIN_LIMIT: usize = 1;
86/// Maximum list-page size accepted from query params. See module docs.
87pub const MAX_LIMIT: usize = 200;
88/// Default page size when the caller omits `limit`. See module docs.
89pub const DEFAULT_LIMIT: usize = 50;
90
91type HmacSha256 = Hmac<Sha256>;
92
93// ---------------------------------------------------------------------------
94// Query params + response wrapper
95// ---------------------------------------------------------------------------
96
97/// Standard query-param shape for list endpoints. Bind this in a
98/// handler with `Query<PaginationParams>` and pass `cursor` /
99/// `limit` to [`paginate`].
100#[derive(Debug, Clone, Deserialize, Default)]
101pub struct PaginationParams {
102 /// Opaque cursor returned by the previous page's `next_cursor`.
103 /// `None` requests the first page.
104 pub cursor: Option<String>,
105 /// Caller-requested page size. Clamped to `[MIN_LIMIT, MAX_LIMIT]`
106 /// before use; `None` falls back to [`DEFAULT_LIMIT`].
107 pub limit: Option<usize>,
108}
109
110impl PaginationParams {
111 /// Apply the workspace's clamping rules. Always returns a valid
112 /// limit in `[MIN_LIMIT, MAX_LIMIT]`.
113 pub fn effective_limit(&self) -> usize {
114 self.limit
115 .unwrap_or(DEFAULT_LIMIT)
116 .clamp(MIN_LIMIT, MAX_LIMIT)
117 }
118}
119
120/// Standard response wrapper for list endpoints. Carries the
121/// requested page of items, the opaque cursor for the next page
122/// (`None` when the caller has reached the end), and an optional
123/// total-count estimate.
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
126pub struct Paginated<T> {
127 pub items: Vec<T>,
128 pub next_cursor: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub total_estimate: Option<u64>,
131}
132
133// ---------------------------------------------------------------------------
134// Cursor — internal payload + signed wire form
135// ---------------------------------------------------------------------------
136
137/// Decoded cursor payload. Public so callers that want the raw
138/// `last_key` (e.g. for debugging or custom iteration) can inspect
139/// it. Never construct one directly off the wire — use
140/// [`Cursor::decode`] so the HMAC tag gets verified.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Cursor {
143 /// Raw fjall storage key of the last item the previous page
144 /// returned. The next page begins **strictly after** this key.
145 pub last_key: Vec<u8>,
146 /// Unix-second timestamp at the time of minting. Currently
147 /// opaque metadata — see module docs.
148 pub snapshot_id: u64,
149}
150
151impl Cursor {
152 /// Construct a cursor for the page that ends at `last_key`.
153 pub fn new(last_key: Vec<u8>, snapshot_id: u64) -> Self {
154 Self {
155 last_key,
156 snapshot_id,
157 }
158 }
159
160 /// Encode + sign the cursor under `audit_key`. Returns the
161 /// base64url-encoded wire form ready for `next_cursor`.
162 pub fn encode(&self, audit_key: &[u8; 32]) -> String {
163 self.encode_bound(audit_key, &[])
164 }
165
166 /// As [`Cursor::encode`], but additionally binds `binding` into the
167 /// HMAC — without placing it on the wire.
168 ///
169 /// This is how a paginated query pins the filters a cursor was minted
170 /// under. The bytes are covered by the tag but not transmitted, so the
171 /// wire format is unchanged and a caller cannot rewrite them. Resuming
172 /// with a *different* binding fails verification and surfaces as
173 /// [`AppError::InvalidCursor`], which is what stops a consumer from
174 /// paging with one filter set and continuing with another — a silent
175 /// way to skip entries that must never happen on an audit log.
176 ///
177 /// Callers with no binding use [`Cursor::encode`], which passes an
178 /// empty slice and is therefore wire- and tag-compatible with cursors
179 /// minted before this method existed.
180 pub fn encode_bound(&self, audit_key: &[u8; 32], binding: &[u8]) -> String {
181 let mut buf = Vec::with_capacity(4 + self.last_key.len() + 8 + HMAC_TAG_LEN);
182 buf.extend_from_slice(&(self.last_key.len() as u32).to_be_bytes());
183 buf.extend_from_slice(&self.last_key);
184 buf.extend_from_slice(&self.snapshot_id.to_be_bytes());
185
186 let mut mac = HmacSha256::new_from_slice(audit_key).expect("32-byte HMAC key");
187 mac.update(&buf);
188 mac.update(binding);
189 let tag = mac.finalize().into_bytes();
190 buf.extend_from_slice(&tag);
191
192 B64.encode(&buf)
193 }
194
195 /// Decode + verify a wire-form cursor. Returns
196 /// [`AppError::InvalidCursor`] on any failure (malformed,
197 /// tampered, or signed under a different key) — the error
198 /// deliberately doesn't reveal which.
199 pub fn decode(wire: &str, audit_key: &[u8; 32]) -> Result<Self, AppError> {
200 Self::decode_bound(wire, audit_key, &[])
201 }
202
203 /// As [`Cursor::decode`], but requires the cursor to have been minted
204 /// with the same `binding` (see [`Cursor::encode_bound`]). A mismatch
205 /// is indistinguishable from a tampered cursor and yields
206 /// [`AppError::InvalidCursor`].
207 pub fn decode_bound(
208 wire: &str,
209 audit_key: &[u8; 32],
210 binding: &[u8],
211 ) -> Result<Self, AppError> {
212 let raw = B64.decode(wire).map_err(|_| AppError::InvalidCursor)?;
213 if raw.len() <= HMAC_TAG_LEN + 4 + 8 {
214 return Err(AppError::InvalidCursor);
215 }
216
217 let payload_len = raw.len() - HMAC_TAG_LEN;
218 let payload = &raw[..payload_len];
219 let received_tag = &raw[payload_len..];
220
221 let mut mac = HmacSha256::new_from_slice(audit_key).expect("32-byte HMAC key");
222 mac.update(payload);
223 mac.update(binding);
224 mac.verify_slice(received_tag)
225 .map_err(|_| AppError::InvalidCursor)?;
226
227 let mut reader = IoCursor::new(payload);
228 let mut key_len_buf = [0u8; 4];
229 reader
230 .read_exact(&mut key_len_buf)
231 .map_err(|_| AppError::InvalidCursor)?;
232 let key_len = u32::from_be_bytes(key_len_buf);
233 if key_len > MAX_LAST_KEY_LEN {
234 return Err(AppError::InvalidCursor);
235 }
236
237 let mut last_key = vec![0u8; key_len as usize];
238 reader
239 .read_exact(&mut last_key)
240 .map_err(|_| AppError::InvalidCursor)?;
241
242 let mut snapshot_buf = [0u8; 8];
243 reader
244 .read_exact(&mut snapshot_buf)
245 .map_err(|_| AppError::InvalidCursor)?;
246 let snapshot_id = u64::from_be_bytes(snapshot_buf);
247
248 // Reject trailing garbage so a tampered cursor with a valid
249 // suffix tag can't sneak through.
250 if reader.position() as usize != payload_len {
251 return Err(AppError::InvalidCursor);
252 }
253
254 Ok(Self {
255 last_key,
256 snapshot_id,
257 })
258 }
259}
260
261// ---------------------------------------------------------------------------
262// paginate helper
263// ---------------------------------------------------------------------------
264
265/// Paginate a materialised list of `(key, value)` pairs. The pairs
266/// are walked in **caller-provided order** — typically the result of
267/// `prefix_iter_raw`, which returns lexicographically-ordered keys
268/// for fjall keyspaces.
269///
270/// Behaviour:
271///
272/// - When `cursor` is `Some`, skips entries with key `<= cursor.last_key`.
273/// - Takes up to `limit` entries.
274/// - If more entries remain after `limit`, sets `next_cursor` to a
275/// freshly-signed cursor whose `last_key` is the last entry the
276/// caller saw.
277/// - Maps each entry's value bytes via `map_value`. Any per-row
278/// deserialisation error short-circuits with that row's error;
279/// callers that want resilience against bad rows should do the
280/// filtering themselves before calling this.
281///
282/// `audit_key` signs minted cursors; `snapshot_id` is the wall-clock
283/// the new cursor will carry (callers usually pass `Utc::now()` /
284/// `chrono::Utc::now().timestamp().max(0) as u64`).
285pub fn paginate<T, F>(
286 pairs: Vec<RawKvPair>,
287 cursor: Option<&Cursor>,
288 limit: usize,
289 audit_key: &[u8; 32],
290 snapshot_id: u64,
291 mut map_value: F,
292) -> Result<Paginated<T>, AppError>
293where
294 F: FnMut(&[u8]) -> Result<T, AppError>,
295{
296 let limit = limit.clamp(MIN_LIMIT, MAX_LIMIT);
297
298 let start = match cursor {
299 Some(c) => {
300 // Find the first pair whose key is strictly greater than
301 // `last_key`. Linear scan is fine for Phase 0 list sizes;
302 // see module docs for the long-term plan.
303 pairs
304 .iter()
305 .position(|(k, _)| k.as_slice() > c.last_key.as_slice())
306 .unwrap_or(pairs.len())
307 }
308 None => 0,
309 };
310
311 let mut items = Vec::with_capacity(limit.min(pairs.len().saturating_sub(start)));
312 let mut idx = start;
313 let mut last_seen_key: Option<Vec<u8>> = None;
314
315 while items.len() < limit && idx < pairs.len() {
316 let (key, value) = &pairs[idx];
317 items.push(map_value(value)?);
318 last_seen_key = Some(key.clone());
319 idx += 1;
320 }
321
322 let next_cursor = if idx < pairs.len() {
323 last_seen_key.map(|k| Cursor::new(k, snapshot_id).encode(audit_key))
324 } else {
325 None
326 };
327
328 Ok(Paginated {
329 items,
330 next_cursor,
331 total_estimate: None,
332 })
333}
334
335// ---------------------------------------------------------------------------
336// Tests
337// ---------------------------------------------------------------------------
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 const KEY_A: [u8; 32] = [0xAA; 32];
344 const KEY_B: [u8; 32] = [0xBB; 32];
345
346 /// A bound cursor only decodes under the same binding. This is what
347 /// stops a filtered listing from being resumed under different
348 /// filters — a silent way to skip entries.
349 #[test]
350 fn a_bound_cursor_rejects_a_different_binding() {
351 let c = Cursor::new(b"audit:2026-01-01:abc".to_vec(), 7);
352 let wire = c.encode_bound(&KEY_A, b"action=MemberAdded");
353
354 assert_eq!(
355 Cursor::decode_bound(&wire, &KEY_A, b"action=MemberAdded").unwrap(),
356 c
357 );
358 assert!(matches!(
359 Cursor::decode_bound(&wire, &KEY_A, b"action=MemberRemoved"),
360 Err(AppError::InvalidCursor)
361 ));
362 // Dropping the binding entirely is also a change.
363 assert!(matches!(
364 Cursor::decode(&wire, &KEY_A),
365 Err(AppError::InvalidCursor)
366 ));
367 // And the key still has to match.
368 assert!(matches!(
369 Cursor::decode_bound(&wire, &KEY_B, b"action=MemberAdded"),
370 Err(AppError::InvalidCursor)
371 ));
372 }
373
374 /// Unbound encode/decode must stay byte-compatible with cursors
375 /// minted before binding existed, so other paginated endpoints
376 /// (e.g. policy list) are unaffected.
377 #[test]
378 fn an_unbound_cursor_round_trips_as_before() {
379 let c = Cursor::new(b"policy:001".to_vec(), 3);
380 let wire = c.encode(&KEY_A);
381 assert_eq!(Cursor::decode(&wire, &KEY_A).unwrap(), c);
382 assert_eq!(wire, c.encode_bound(&KEY_A, &[]));
383 }
384
385 fn make_pairs(count: usize) -> Vec<RawKvPair> {
386 (0..count)
387 .map(|i| {
388 (
389 format!("item:{i:03}").into_bytes(),
390 format!(r#"{{"n":{i}}}"#).into_bytes(),
391 )
392 })
393 .collect()
394 }
395
396 fn deserialize_value(bytes: &[u8]) -> Result<serde_json::Value, AppError> {
397 serde_json::from_slice(bytes)
398 .map_err(|e| AppError::Internal(format!("test value deserialize failed: {e}")))
399 }
400
401 // ──────────── Cursor encode/decode ────────────
402
403 #[test]
404 fn cursor_round_trips_through_encode_decode() {
405 let c = Cursor::new(b"member:did:key:z6Mk".to_vec(), 1_700_000_000);
406 let wire = c.encode(&KEY_A);
407 let back = Cursor::decode(&wire, &KEY_A).unwrap();
408 assert_eq!(back, c);
409 }
410
411 #[test]
412 fn cursor_decoded_with_different_key_is_rejected() {
413 let c = Cursor::new(b"x".to_vec(), 42);
414 let wire = c.encode(&KEY_A);
415 let err = Cursor::decode(&wire, &KEY_B).expect_err("must reject");
416 assert!(matches!(err, AppError::InvalidCursor));
417 }
418
419 /// End-to-end mapping: a cursor minted under one community's
420 /// audit key (or under a community's pre-rotation key) MUST
421 /// produce HTTP 400 when handed to a route running with a
422 /// different active key. The unit test above proves the
423 /// `Cursor::decode` arithmetic; this one nails the wire layer
424 /// by walking through the `IntoResponse` impl so a future
425 /// regression in `AppError`'s status mapping (or a misguided
426 /// "treat invalid cursor as empty page" refactor) fails here.
427 #[test]
428 fn foreign_audit_key_cursor_maps_to_http_400() {
429 use axum::response::IntoResponse;
430
431 let c = Cursor::new(b"member:did:key:zAttacker".to_vec(), 17);
432 let wire = c.encode(&KEY_A);
433 let err = Cursor::decode(&wire, &KEY_B).expect_err("must reject");
434 let response = err.into_response();
435 assert_eq!(response.status(), 400);
436 }
437
438 #[test]
439 fn cursor_with_tampered_payload_is_rejected() {
440 let c = Cursor::new(b"safe-key".to_vec(), 1);
441 let wire = c.encode(&KEY_A);
442 // Mutate one byte of the base64 form (not the trailing tag)
443 // and expect rejection.
444 let mut bytes = B64.decode(&wire).unwrap();
445 bytes[5] ^= 0xFF;
446 let tampered = B64.encode(&bytes);
447 let err = Cursor::decode(&tampered, &KEY_A).expect_err("tampered");
448 assert!(matches!(err, AppError::InvalidCursor));
449 }
450
451 #[test]
452 fn cursor_malformed_base64_is_rejected() {
453 for bad in ["", "not!base64", "AAAA"] {
454 let err = Cursor::decode(bad, &KEY_A).expect_err("malformed");
455 assert!(matches!(err, AppError::InvalidCursor), "input {bad}");
456 }
457 }
458
459 #[test]
460 fn cursor_with_oversized_key_length_is_rejected() {
461 // Construct a wire form claiming a huge last_key length.
462 let mut payload = Vec::new();
463 payload.extend_from_slice(&u32::MAX.to_be_bytes());
464 payload.extend_from_slice(&0u64.to_be_bytes());
465 let mut mac = HmacSha256::new_from_slice(&KEY_A).unwrap();
466 mac.update(&payload);
467 let tag = mac.finalize().into_bytes();
468 payload.extend_from_slice(&tag);
469 let wire = B64.encode(&payload);
470 let err = Cursor::decode(&wire, &KEY_A).expect_err("oversized");
471 assert!(matches!(err, AppError::InvalidCursor));
472 }
473
474 // ──────────── paginate ────────────
475
476 #[test]
477 fn paginate_first_page_no_cursor() {
478 let pairs = make_pairs(7);
479 let out: Paginated<serde_json::Value> =
480 paginate(pairs, None, 3, &KEY_A, 100, deserialize_value).unwrap();
481 assert_eq!(out.items.len(), 3);
482 assert_eq!(out.items[0]["n"], 0);
483 assert_eq!(out.items[2]["n"], 2);
484 assert!(out.next_cursor.is_some());
485 }
486
487 #[test]
488 fn paginate_walks_entire_collection_without_duplicates() {
489 let pairs = make_pairs(7);
490 let mut cursor: Option<Cursor> = None;
491 let mut seen = Vec::new();
492 for _ in 0..5 {
493 let out: Paginated<serde_json::Value> = paginate(
494 pairs.clone(),
495 cursor.as_ref(),
496 3,
497 &KEY_A,
498 100,
499 deserialize_value,
500 )
501 .unwrap();
502 for item in &out.items {
503 seen.push(item["n"].as_u64().unwrap());
504 }
505 match out.next_cursor {
506 Some(wire) => cursor = Some(Cursor::decode(&wire, &KEY_A).unwrap()),
507 None => break,
508 }
509 }
510 assert_eq!(seen, (0..7).collect::<Vec<_>>());
511 }
512
513 #[test]
514 fn paginate_last_page_returns_no_next_cursor() {
515 let pairs = make_pairs(3);
516 let out: Paginated<serde_json::Value> =
517 paginate(pairs, None, 10, &KEY_A, 100, deserialize_value).unwrap();
518 assert_eq!(out.items.len(), 3);
519 assert!(out.next_cursor.is_none(), "last page must not link onward");
520 }
521
522 #[test]
523 fn paginate_clamps_limit() {
524 let pairs = make_pairs(500);
525 let out: Paginated<serde_json::Value> =
526 paginate(pairs, None, 9_999, &KEY_A, 100, deserialize_value).unwrap();
527 assert_eq!(out.items.len(), MAX_LIMIT);
528 }
529
530 #[test]
531 fn paginate_skips_past_cursor_key_exclusive() {
532 let pairs = make_pairs(5);
533 let cursor = Cursor::new(b"item:001".to_vec(), 1);
534 let out: Paginated<serde_json::Value> =
535 paginate(pairs, Some(&cursor), 10, &KEY_A, 100, deserialize_value).unwrap();
536 // We saw item:000 + item:001 → next page begins at item:002.
537 let ns: Vec<_> = out.items.iter().map(|v| v["n"].as_u64().unwrap()).collect();
538 assert_eq!(ns, vec![2, 3, 4]);
539 }
540
541 #[test]
542 fn paginate_returns_empty_when_cursor_already_past_end() {
543 let pairs = make_pairs(3);
544 let cursor = Cursor::new(b"zzz".to_vec(), 1);
545 let out: Paginated<serde_json::Value> =
546 paginate(pairs, Some(&cursor), 10, &KEY_A, 100, deserialize_value).unwrap();
547 assert!(out.items.is_empty());
548 assert!(out.next_cursor.is_none());
549 }
550
551 #[test]
552 fn pagination_params_effective_limit_clamps_and_defaults() {
553 assert_eq!(
554 PaginationParams {
555 cursor: None,
556 limit: None,
557 }
558 .effective_limit(),
559 DEFAULT_LIMIT
560 );
561 assert_eq!(
562 PaginationParams {
563 cursor: None,
564 limit: Some(0),
565 }
566 .effective_limit(),
567 MIN_LIMIT
568 );
569 assert_eq!(
570 PaginationParams {
571 cursor: None,
572 limit: Some(9999),
573 }
574 .effective_limit(),
575 MAX_LIMIT
576 );
577 assert_eq!(
578 PaginationParams {
579 cursor: None,
580 limit: Some(50),
581 }
582 .effective_limit(),
583 50
584 );
585 }
586}