mnm_core/introspect.rs
1//! Shared typed contract for the `GET /v1/me` introspection endpoint.
2//!
3//! `GET /v1/me` reports auth state plus TWO independent limit systems: the
4//! request rate limit (req/s token bucket) and the embedding token budget
5//! (rolling hourly/daily windows). Historically the server produced this body
6//! ad-hoc with `serde_json::json!{}` and every consumer (`mnm status`, the MCP
7//! `status` tool, the MCP render summary) reached into it with stringly-typed
8//! `.get()` / `.pointer()` lookups — so a server-side field rename silently
9//! degraded every reader. These types are the single shared shape the server
10//! serializes and the consumers deserialize, so a rename is a compile error
11//! instead of a silent regression.
12//!
13//! Tier vocabularies differ deliberately and are kept as `String`:
14//! [`MeResponse::auth_type`] uses the auth tier vocabulary (`anonymous` /
15//! `read_uplift` / `admin`), while [`MeRateLimit::tier`] and
16//! [`MeTokenLimits::tier`] carry the wider rate-limit / token-limit tier
17//! vocabularies (e.g. `cidr_override`, which has no JWT equivalent).
18
19use serde::{Deserialize, Serialize};
20
21/// The full `GET /v1/me` body: auth identity plus both limit systems.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct MeResponse {
24 /// `true` when a bearer was presented and accepted.
25 pub authenticated: bool,
26 /// Auth tier vocabulary: `anonymous` (no/invalid bearer), `read_uplift`
27 /// (GitHub-SSO uplift JWT), or `admin` (challenge-response JWT). Matches the
28 /// JWT tier claim and the adjacent rate-limit / token-limit tier fields.
29 pub auth_type: String,
30 /// Identity string (GitHub login or admin user id), when authenticated.
31 pub identity: Option<String>,
32 /// `read` / `write` / `admin`.
33 pub permission_level: String,
34 /// Request rate-limit bucket state. `None` (serialized as `null`) when the
35 /// limiter is disabled.
36 pub rate_limit: Option<MeRateLimit>,
37 /// Embedding token-budget windows (hourly + daily).
38 pub token_limits: MeTokenLimits,
39 /// The server's package version.
40 pub server_version: String,
41}
42
43/// Request rate-limit bucket snapshot (req/s token bucket).
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct MeRateLimit {
46 /// Rate-limit tier vocabulary: `cidr_override` / `admin` / `read_uplift` /
47 /// `anonymous` (wider than the auth tier — kept as a string).
48 pub tier: String,
49 /// Bucket capacity (requests the tier may spend per refill window).
50 pub limit: u32,
51 /// Whole tokens left in the caller's bucket right now.
52 pub remaining: u32,
53 /// Seconds until the rate-limit bucket refills (RELATIVE duration). NOT a
54 /// timestamp — contrast [`MeTokenWindow::reset_at_secs`], which is absolute.
55 pub reset_secs: u64,
56}
57
58/// Embedding token-budget: tier label plus the two rolling windows.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct MeTokenLimits {
61 /// Token-limit tier vocabulary: `anonymous` / `read_uplift` / `admin`
62 /// (kept as a string to stay decoupled from the auth tier enum).
63 pub tier: String,
64 /// Rolling one-hour window.
65 pub hourly: MeTokenWindow,
66 /// Rolling one-day window.
67 pub daily: MeTokenWindow,
68}
69
70/// One rolling token-budget window (hourly or daily).
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct MeTokenWindow {
73 /// Configured token ceiling for this window.
74 pub limit: u64,
75 /// Tokens remaining before the window is exhausted.
76 pub remaining: u64,
77 /// Unix timestamp (seconds) when the oldest token-budget bucket in this
78 /// window expires (ABSOLUTE wall-clock instant). NOT a duration — contrast
79 /// [`MeRateLimit::reset_secs`], which is a relative number of seconds.
80 pub reset_at_secs: i64,
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 fn sample() -> MeResponse {
88 MeResponse {
89 authenticated: true,
90 auth_type: "read_uplift".to_owned(),
91 identity: Some("octocat".to_owned()),
92 permission_level: "read".to_owned(),
93 rate_limit: Some(MeRateLimit {
94 tier: "read_uplift".to_owned(),
95 limit: 120,
96 remaining: 87,
97 reset_secs: 31,
98 }),
99 token_limits: MeTokenLimits {
100 tier: "read_uplift".to_owned(),
101 hourly: MeTokenWindow {
102 limit: 200_000,
103 remaining: 150_000,
104 reset_at_secs: 1_200,
105 },
106 daily: MeTokenWindow {
107 limit: 2_000_000,
108 remaining: 1_900_000,
109 reset_at_secs: 50_000,
110 },
111 },
112 server_version: "0.4.2".to_owned(),
113 }
114 }
115
116 #[test]
117 fn round_trips_through_json() {
118 let me = sample();
119 let json = serde_json::to_value(&me).expect("serialize");
120 let back: MeResponse = serde_json::from_value(json).expect("deserialize");
121 assert_eq!(me, back);
122 }
123
124 #[test]
125 fn disabled_rate_limit_serializes_as_null_and_round_trips() {
126 let mut me = sample();
127 me.rate_limit = None;
128 let json = serde_json::to_value(&me).expect("serialize");
129 assert!(json["rate_limit"].is_null(), "disabled limiter must emit null, not absent");
130 let back: MeResponse = serde_json::from_value(json).expect("deserialize");
131 assert_eq!(me, back);
132 }
133}