Skip to main content

lean_ctx/gateway_server/
user_api.rs

1//! Personal usage view (`/me`, enterprise#64) — served on the **proxy port**,
2//! authenticated by the caller's own gateway key.
3//!
4//! The admin console (enterprise#45) answers "what does the org spend?"; this
5//! surface answers "what did *I* spend and save?". It reuses the same design
6//! language and the same `usage_events` store, but every query is scoped to
7//! the person resolved from the presented key — nobody sees anybody else's
8//! rows, and an org-wide token (no person identity) is refused.
9//!
10//! Wiring: the proxy compiles this router in under the `gateway-server`
11//! feature and mounts it inside its auth middleware. `gateway serve` installs
12//! the Postgres pool into [`install_pool`] before the proxy starts; without a
13//! store (plain `lean-ctx proxy`) the data endpoint answers 503 and the shell
14//! explains what is missing. Fail-open rule untouched: this is a read-only
15//! periphery, LLM traffic never depends on it.
16//!
17//! Auth split (same as the admin console): the static shell is public — every
18//! number comes from `GET /api/me/usage`, which sits behind the proxy's
19//! Bearer guard and reads the identity tags the guard attached. The key never
20//! appears in a URL; the shell keeps it in `sessionStorage`.
21
22use std::sync::OnceLock;
23
24use axum::extract::Query;
25use axum::http::{StatusCode, header};
26use axum::response::{IntoResponse, Json, Response};
27use deadpool_postgres::Pool;
28use serde::{Deserialize, Serialize};
29
30use crate::proxy::gateway_identity::GatewayTags;
31
32use super::admin_timeseries::{TimeseriesPoint, fill_gaps};
33
34static USER_POOL: OnceLock<Pool> = OnceLock::new();
35
36/// Installs the process-wide store pool for the personal view. First caller
37/// wins (one gateway run-mode per process); later calls return `false`.
38pub fn install_pool(pool: Pool) -> bool {
39    USER_POOL.set(pool).is_ok()
40}
41
42/// Default and maximum query window in days.
43const DEFAULT_WINDOW_DAYS: u32 = 30;
44const MAX_WINDOW_DAYS: u32 = 365;
45
46// -- Static shell ------------------------------------------------------------
47
48const ME_HTML: &str = include_str!("static/me.html");
49const ME_CSS: &str = include_str!("static/me.css");
50const ME_JS: &str = include_str!("static/me.js");
51/// Shared with the admin console: identical design tokens and components.
52const BASE_CSS: &str = include_str!("static/admin.css");
53/// Font faces with `/me/static/...` URLs (the proxy port serves no `/static/`).
54const ME_FONTS_CSS: &str = include_str!("static/me-fonts.css");
55const FONT_INTER_WOFF2: &[u8] = include_bytes!("../dashboard/static/fonts/inter-variable.woff2");
56const FONT_JETBRAINS_WOFF2: &[u8] =
57    include_bytes!("../dashboard/static/fonts/jetbrains-mono-variable.woff2");
58const FONT_SPACE_GROTESK_WOFF2: &[u8] =
59    include_bytes!("../dashboard/static/fonts/space-grotesk-variable.woff2");
60const VENDOR_CHART_JS: &str = include_str!("../dashboard/static/vendor/chart.umd.min.js");
61
62/// True for the unauthenticated shell paths (`/me` + its static assets). The
63/// proxy's auth guard exempts exactly these — the data API stays guarded.
64#[must_use]
65pub fn is_shell_path(path: &str) -> bool {
66    path == "/me" || path.starts_with("/me/static/")
67}
68
69/// The personal-view router: static shell + the guarded data endpoint.
70/// State-generic so the proxy can merge it regardless of its own state type;
71/// no handler here reads router state.
72pub fn router<S: Clone + Send + Sync + 'static>() -> axum::Router<S> {
73    axum::Router::new()
74        .route("/me", axum::routing::get(shell))
75        .route("/me/static/base.css", axum::routing::get(base_css))
76        .route("/me/static/me.css", axum::routing::get(me_css))
77        .route("/me/static/me.js", axum::routing::get(me_js))
78        .route("/me/static/fonts/fonts.css", axum::routing::get(fonts_css))
79        .route(
80            "/me/static/vendor/chart.umd.min.js",
81            axum::routing::get(chart_js),
82        )
83        .route("/me/static/fonts/{file}", axum::routing::get(font_file))
84        .route("/api/me/usage", axum::routing::get(me_usage))
85        .layer(axum::middleware::from_fn(super::security::security_headers))
86}
87
88async fn shell() -> impl IntoResponse {
89    (
90        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
91        ME_HTML,
92    )
93}
94
95async fn base_css() -> impl IntoResponse {
96    (
97        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
98        BASE_CSS,
99    )
100}
101
102async fn me_css() -> impl IntoResponse {
103    ([(header::CONTENT_TYPE, "text/css; charset=utf-8")], ME_CSS)
104}
105
106async fn me_js() -> impl IntoResponse {
107    (
108        [(
109            header::CONTENT_TYPE,
110            "application/javascript; charset=utf-8",
111        )],
112        ME_JS,
113    )
114}
115
116async fn fonts_css() -> impl IntoResponse {
117    (
118        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
119        ME_FONTS_CSS,
120    )
121}
122
123async fn chart_js() -> impl IntoResponse {
124    (
125        [(
126            header::CONTENT_TYPE,
127            "application/javascript; charset=utf-8",
128        )],
129        VENDOR_CHART_JS,
130    )
131}
132
133async fn font_file(axum::extract::Path(file): axum::extract::Path<String>) -> Response {
134    let bytes: &'static [u8] = match file.as_str() {
135        "inter-variable.woff2" => FONT_INTER_WOFF2,
136        "jetbrains-mono-variable.woff2" => FONT_JETBRAINS_WOFF2,
137        "space-grotesk-variable.woff2" => FONT_SPACE_GROTESK_WOFF2,
138        _ => return StatusCode::NOT_FOUND.into_response(),
139    };
140    ([(header::CONTENT_TYPE, "font/woff2")], bytes).into_response()
141}
142
143// -- Data endpoint -----------------------------------------------------------
144
145/// Query parameters of `GET /api/me/usage`.
146#[derive(Debug, Clone, Deserialize)]
147pub struct MeQuery {
148    /// Window length in days (default 30, clamped to `1..=365`).
149    pub days: Option<u32>,
150}
151
152/// One aggregated model row of the personal breakdown.
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct MeModelRow {
155    pub model: String,
156    pub provider: String,
157    pub requests: i64,
158    pub input_tokens: i64,
159    pub output_tokens: i64,
160    pub cost_usd: f64,
161    pub saved_usd: f64,
162}
163
164/// One aggregated project row of the personal breakdown.
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166pub struct MeProjectRow {
167    pub project: String,
168    pub requests: i64,
169    pub cost_usd: f64,
170    pub saved_usd: f64,
171}
172
173/// Personal aggregate totals over the queried window.
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175pub struct MeTotals {
176    pub requests: i64,
177    pub input_tokens: i64,
178    pub output_tokens: i64,
179    pub cost_usd: f64,
180    pub saved_tokens: i64,
181    pub saved_usd: f64,
182    /// Avoided-cost reference sum (0.0 without a configured baseline).
183    pub reference_cost_usd: f64,
184    /// Requests the active router rewrote (`routed_from IS NOT NULL`).
185    pub routed_requests: i64,
186}
187
188/// Response of `GET /api/me/usage`.
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct MeUsageResponse {
191    /// The person this data belongs to (pseudonymized when GDPR mode is on —
192    /// the same form the store holds, so what you see is what is stored).
193    pub person: String,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub team: Option<String>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub org_label: Option<String>,
198    pub version: String,
199    pub from: String,
200    pub to: String,
201    pub totals: MeTotals,
202    pub by_model: Vec<MeModelRow>,
203    pub by_project: Vec<MeProjectRow>,
204    pub days: Vec<TimeseriesPoint>,
205}
206
207/// Person-scoped totals. Window bounds and person are bound parameters;
208/// everything else is static SQL (deterministic, injection-free).
209const ME_TOTALS_SQL: &str = "
210SELECT count(*)                                            AS requests,
211       coalesce(sum(input_tokens), 0)::BIGINT              AS input_tokens,
212       coalesce(sum(output_tokens), 0)::BIGINT             AS output_tokens,
213       coalesce(sum(cost_usd), 0)                          AS cost_usd,
214       coalesce(sum(saved_tokens), 0)::BIGINT              AS saved_tokens,
215       coalesce(sum(saved_usd), 0)                         AS saved_usd,
216       coalesce(sum(reference_cost_usd), 0)                AS reference_cost_usd,
217       count(*) FILTER (WHERE routed_from IS NOT NULL)     AS routed_requests
218FROM usage_events
219WHERE ts >= $1 AND ts <= $2 AND person = $3";
220
221const ME_BY_MODEL_SQL: &str = "
222SELECT model, provider,
223       count(*)                   AS requests,
224       sum(input_tokens)::BIGINT  AS input_tokens,
225       sum(output_tokens)::BIGINT AS output_tokens,
226       sum(cost_usd)              AS cost_usd,
227       sum(saved_usd)             AS saved_usd
228FROM usage_events
229WHERE ts >= $1 AND ts <= $2 AND person = $3
230GROUP BY model, provider
231ORDER BY cost_usd DESC";
232
233const ME_BY_PROJECT_SQL: &str = "
234SELECT project,
235       count(*)      AS requests,
236       sum(cost_usd) AS cost_usd,
237       sum(saved_usd) AS saved_usd
238FROM usage_events
239WHERE ts >= $1 AND ts <= $2 AND person = $3
240GROUP BY project
241ORDER BY cost_usd DESC";
242
243const ME_TIMESERIES_SQL: &str = "
244SELECT date_trunc('day', ts)                AS day,
245       count(*)                             AS requests,
246       coalesce(sum(cost_usd), 0)           AS cost_usd,
247       coalesce(sum(saved_usd), 0)          AS saved_usd,
248       coalesce(sum(reference_cost_usd), 0) AS reference_cost_usd
249FROM usage_events
250WHERE ts >= $1 AND ts <= $2 AND person = $3
251GROUP BY 1
252ORDER BY 1";
253
254/// `GET /api/me/usage?days=N` — the caller's own usage, keyed by the identity
255/// the proxy auth guard attached. Refuses tokens without a person identity:
256/// the personal view exists exactly for per-person keys (enterprise#11).
257async fn me_usage(
258    tags: Option<axum::Extension<GatewayTags>>,
259    Query(q): Query<MeQuery>,
260) -> Response {
261    let Some(person) = tags.as_ref().and_then(|t| t.person.clone()) else {
262        return (
263            StatusCode::FORBIDDEN,
264            Json(serde_json::json!({
265                "error": "this view needs a personal gateway key (it identifies you); \
266                          ask your admin for one: lean-ctx gateway keys add --person <you>"
267            })),
268        )
269            .into_response();
270    };
271    let Some(pool) = USER_POOL.get() else {
272        return (
273            StatusCode::SERVICE_UNAVAILABLE,
274            Json(serde_json::json!({
275                "error": "usage store not configured on this gateway (DATABASE_URL unset)"
276            })),
277        )
278            .into_response();
279    };
280    let team = tags.and_then(|t| t.0.team);
281    let days = q
282        .days
283        .unwrap_or(DEFAULT_WINDOW_DAYS)
284        .clamp(1, MAX_WINDOW_DAYS);
285    let to = chrono::Utc::now();
286    let from = to - chrono::Duration::days(i64::from(days));
287
288    match personal_usage(pool, &person, team, from, to).await {
289        Ok(resp) => Json(resp).into_response(),
290        Err(e) => {
291            tracing::warn!("personal usage query failed: {e:#}");
292            (
293                StatusCode::SERVICE_UNAVAILABLE,
294                Json(serde_json::json!({"error": "usage store unavailable"})),
295            )
296                .into_response()
297        }
298    }
299}
300
301/// Runs the person-scoped queries and assembles the response.
302///
303/// # Errors
304/// Propagates pool/query errors (the handler maps them to 503).
305pub async fn personal_usage(
306    pool: &Pool,
307    person: &str,
308    team: Option<String>,
309    from: chrono::DateTime<chrono::Utc>,
310    to: chrono::DateTime<chrono::Utc>,
311) -> anyhow::Result<MeUsageResponse> {
312    let client = pool.get().await?;
313
314    let t = client
315        .query_one(ME_TOTALS_SQL, &[&from, &to, &person])
316        .await?;
317    let totals = MeTotals {
318        requests: t.get("requests"),
319        input_tokens: t.get("input_tokens"),
320        output_tokens: t.get("output_tokens"),
321        cost_usd: t.get("cost_usd"),
322        saved_tokens: t.get("saved_tokens"),
323        saved_usd: t.get("saved_usd"),
324        reference_cost_usd: t.get("reference_cost_usd"),
325        routed_requests: t.get("routed_requests"),
326    };
327
328    let by_model = client
329        .query(ME_BY_MODEL_SQL, &[&from, &to, &person])
330        .await?
331        .iter()
332        .map(|r| MeModelRow {
333            model: r.get("model"),
334            provider: r.get("provider"),
335            requests: r.get("requests"),
336            input_tokens: r.get("input_tokens"),
337            output_tokens: r.get("output_tokens"),
338            cost_usd: r.get("cost_usd"),
339            saved_usd: r.get("saved_usd"),
340        })
341        .collect();
342
343    let by_project = client
344        .query(ME_BY_PROJECT_SQL, &[&from, &to, &person])
345        .await?
346        .iter()
347        .map(|r| MeProjectRow {
348            project: r.get("project"),
349            requests: r.get("requests"),
350            cost_usd: r.get("cost_usd"),
351            saved_usd: r.get("saved_usd"),
352        })
353        .collect();
354
355    let measured: Vec<TimeseriesPoint> = client
356        .query(ME_TIMESERIES_SQL, &[&from, &to, &person])
357        .await?
358        .iter()
359        .map(|r| {
360            let day: chrono::DateTime<chrono::Utc> = r.get("day");
361            TimeseriesPoint {
362                day: day.format("%Y-%m-%d").to_string(),
363                requests: r.get("requests"),
364                cost_usd: r.get("cost_usd"),
365                saved_usd: r.get("saved_usd"),
366                reference_cost_usd: r.get("reference_cost_usd"),
367            }
368        })
369        .collect();
370
371    let cfg = crate::core::config::Config::load();
372    Ok(MeUsageResponse {
373        person: person.to_string(),
374        team,
375        org_label: cfg.gateway_server.org_label.clone(),
376        version: env!("CARGO_PKG_VERSION").to_string(),
377        from: from.to_rfc3339(),
378        to: to.to_rfc3339(),
379        totals,
380        by_model,
381        by_project,
382        days: fill_gaps(&measured, from, to),
383    })
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn shell_paths_cover_exactly_the_public_surface() {
392        assert!(is_shell_path("/me"));
393        assert!(is_shell_path("/me/static/me.js"));
394        assert!(is_shell_path("/me/static/fonts/inter-variable.woff2"));
395        // The data API and everything else stay guarded.
396        assert!(!is_shell_path("/api/me/usage"));
397        assert!(!is_shell_path("/me2"));
398        assert!(!is_shell_path("/mex/static/a.js"));
399        assert!(!is_shell_path("/v1/messages"));
400    }
401
402    #[test]
403    fn embedded_assets_are_nonempty_and_wired() {
404        assert!(ME_HTML.contains("<!doctype html"));
405        assert!(
406            ME_HTML.contains("/me/static/me.js"),
407            "shell must load the app script"
408        );
409        assert!(
410            ME_HTML.contains("/me/static/base.css"),
411            "shell must reuse the console design system"
412        );
413        assert!(
414            ME_JS.contains("/api/me/usage"),
415            "app must talk to the guarded API"
416        );
417        assert!(
418            ME_FONTS_CSS.contains("/me/static/fonts/"),
419            "font faces must resolve on the proxy port"
420        );
421        assert!(!ME_CSS.is_empty());
422        assert!(!VENDOR_CHART_JS.is_empty());
423    }
424
425    #[test]
426    fn shell_never_embeds_credentials() {
427        for needle in ["Bearer ", "gk-", "LEAN_CTX_PROXY_TOKEN="] {
428            assert!(!ME_HTML.contains(needle), "me.html must not embed {needle}");
429        }
430        assert!(
431            !ME_JS.contains("localStorage.setItem('leanctx-me-key'"),
432            "key must live in sessionStorage, not persist in localStorage"
433        );
434    }
435
436    #[test]
437    fn window_days_are_clamped() {
438        for (input, expected) in [
439            (None, DEFAULT_WINDOW_DAYS),
440            (Some(0), 1),
441            (Some(7), 7),
442            (Some(9999), MAX_WINDOW_DAYS),
443        ] {
444            let days = input
445                .unwrap_or(DEFAULT_WINDOW_DAYS)
446                .clamp(1, MAX_WINDOW_DAYS);
447            assert_eq!(days, expected, "input {input:?}");
448        }
449    }
450
451    #[test]
452    fn response_shape_round_trips() {
453        // The response is a client contract for the /me shell — pin it.
454        let resp = MeUsageResponse {
455            person: "alice@zuehlke.com".into(),
456            team: Some("platform".into()),
457            org_label: Some("Zühlke Engineering AG".into()),
458            version: "3.8.18".into(),
459            from: "2026-06-03T00:00:00+00:00".into(),
460            to: "2026-07-03T00:00:00+00:00".into(),
461            totals: MeTotals {
462                requests: 412,
463                input_tokens: 9_000_000,
464                output_tokens: 310_000,
465                cost_usd: 84.12,
466                saved_tokens: 2_400_000,
467                saved_usd: 41.90,
468                reference_cost_usd: 190.55,
469                routed_requests: 96,
470            },
471            by_model: vec![MeModelRow {
472                model: "zuehlke/fast".into(),
473                provider: "foundry".into(),
474                requests: 96,
475                input_tokens: 1_000_000,
476                output_tokens: 50_000,
477                cost_usd: 4.20,
478                saved_usd: 12.80,
479            }],
480            by_project: vec![MeProjectRow {
481                project: "checkout".into(),
482                requests: 412,
483                cost_usd: 84.12,
484                saved_usd: 41.90,
485            }],
486            days: vec![],
487        };
488        let json = serde_json::to_value(&resp).expect("serializes");
489        assert_eq!(json["person"], "alice@zuehlke.com");
490        assert_eq!(json["totals"]["routed_requests"], 96);
491        assert_eq!(json["by_model"][0]["model"], "zuehlke/fast");
492        let parsed: MeUsageResponse = serde_json::from_value(json).expect("round-trips");
493        assert_eq!(parsed, resp);
494    }
495
496    #[tokio::test]
497    async fn me_usage_refuses_identityless_tokens() {
498        // Org token (no person) → 403; absent tags → 403.
499        for tags in [
500            None,
501            Some(axum::Extension(GatewayTags::default())),
502            Some(axum::Extension(GatewayTags {
503                person: None,
504                team: None,
505                project: Some("side-quest".into()),
506            })),
507        ] {
508            let resp = me_usage(tags, Query(MeQuery { days: Some(7) })).await;
509            assert_eq!(resp.status(), StatusCode::FORBIDDEN);
510        }
511    }
512
513    #[tokio::test]
514    async fn me_usage_without_store_is_503() {
515        // A personal key but no installed pool (plain proxy mode) → 503 with
516        // a actionable error, never a panic. (No pool is installed in unit
517        // tests — OnceLock stays empty.)
518        let tags = Some(axum::Extension(GatewayTags {
519            person: Some("alice".into()),
520            team: None,
521            project: None,
522        }));
523        let resp = me_usage(tags, Query(MeQuery { days: None })).await;
524        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
525    }
526}