Skip to main content

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}