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/// One aggregated MCP tool row of the personal breakdown (GL#104): the tools
174/// this person called through `/mcp/{server}` and what that context costs.
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176pub struct MeToolRow {
177    pub server_id: String,
178    pub tool: String,
179    pub calls: i64,
180    pub result_tokens: i64,
181    pub context_cost_usd: f64,
182}
183
184/// Personal aggregate totals over the queried window.
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct MeTotals {
187    pub requests: i64,
188    pub input_tokens: i64,
189    pub output_tokens: i64,
190    pub cost_usd: f64,
191    pub saved_tokens: i64,
192    pub saved_usd: f64,
193    /// Avoided-cost reference sum (0.0 without a configured baseline).
194    pub reference_cost_usd: f64,
195    /// Requests the active router rewrote (`routed_from IS NOT NULL`).
196    pub routed_requests: i64,
197}
198
199/// Response of `GET /api/me/usage`.
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct MeUsageResponse {
202    /// The person this data belongs to (pseudonymized when GDPR mode is on —
203    /// the same form the store holds, so what you see is what is stored).
204    pub person: String,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub team: Option<String>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub org_label: Option<String>,
209    pub version: String,
210    pub from: String,
211    pub to: String,
212    pub totals: MeTotals,
213    pub by_model: Vec<MeModelRow>,
214    pub by_project: Vec<MeProjectRow>,
215    /// MCP tools this person used (empty without MCP traffic — the shell
216    /// hides the section entirely then).
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub tools: Vec<MeToolRow>,
219    pub days: Vec<TimeseriesPoint>,
220}
221
222/// Person-scoped totals. Window bounds and person are bound parameters;
223/// everything else is static SQL (deterministic, injection-free).
224const ME_TOTALS_SQL: &str = "
225SELECT count(*)                                            AS requests,
226       coalesce(sum(input_tokens), 0)::BIGINT              AS input_tokens,
227       coalesce(sum(output_tokens), 0)::BIGINT             AS output_tokens,
228       coalesce(sum(cost_usd), 0)                          AS cost_usd,
229       coalesce(sum(saved_tokens), 0)::BIGINT              AS saved_tokens,
230       coalesce(sum(saved_usd), 0)                         AS saved_usd,
231       coalesce(sum(reference_cost_usd), 0)                AS reference_cost_usd,
232       count(*) FILTER (WHERE routed_from IS NOT NULL)     AS routed_requests
233FROM usage_events
234WHERE ts >= $1 AND ts <= $2 AND person = $3";
235
236const ME_BY_MODEL_SQL: &str = "
237SELECT model, provider,
238       count(*)                   AS requests,
239       sum(input_tokens)::BIGINT  AS input_tokens,
240       sum(output_tokens)::BIGINT AS output_tokens,
241       sum(cost_usd)              AS cost_usd,
242       sum(saved_usd)             AS saved_usd
243FROM usage_events
244WHERE ts >= $1 AND ts <= $2 AND person = $3
245GROUP BY model, provider
246ORDER BY cost_usd DESC";
247
248const ME_BY_PROJECT_SQL: &str = "
249SELECT project,
250       count(*)      AS requests,
251       sum(cost_usd) AS cost_usd,
252       sum(saved_usd) AS saved_usd
253FROM usage_events
254WHERE ts >= $1 AND ts <= $2 AND person = $3
255GROUP BY project
256ORDER BY cost_usd DESC";
257
258const ME_TIMESERIES_SQL: &str = "
259SELECT date_trunc('day', ts)                AS day,
260       count(*)                             AS requests,
261       coalesce(sum(cost_usd), 0)           AS cost_usd,
262       coalesce(sum(saved_usd), 0)          AS saved_usd,
263       coalesce(sum(reference_cost_usd), 0) AS reference_cost_usd
264FROM usage_events
265WHERE ts >= $1 AND ts <= $2 AND person = $3
266GROUP BY 1
267ORDER BY 1";
268
269/// `GET /api/me/usage?days=N` — the caller's own usage, keyed by the identity
270/// the proxy auth guard attached. Refuses tokens without a person identity:
271/// the personal view exists exactly for per-person keys (enterprise#11).
272async fn me_usage(
273    tags: Option<axum::Extension<GatewayTags>>,
274    Query(q): Query<MeQuery>,
275) -> Response {
276    let Some(person) = tags.as_ref().and_then(|t| t.person.clone()) else {
277        return (
278            StatusCode::FORBIDDEN,
279            Json(serde_json::json!({
280                "error": "this view needs a personal gateway key (it identifies you); \
281                          ask your admin for one: lean-ctx gateway keys add --person <you>"
282            })),
283        )
284            .into_response();
285    };
286    let Some(pool) = USER_POOL.get() else {
287        return (
288            StatusCode::SERVICE_UNAVAILABLE,
289            Json(serde_json::json!({
290                "error": "usage store not configured on this gateway (DATABASE_URL unset)"
291            })),
292        )
293            .into_response();
294    };
295    let team = tags.and_then(|t| t.0.team);
296    let days = q
297        .days
298        .unwrap_or(DEFAULT_WINDOW_DAYS)
299        .clamp(1, MAX_WINDOW_DAYS);
300    let to = chrono::Utc::now();
301    let from = to - chrono::Duration::days(i64::from(days));
302
303    match personal_usage(pool, &person, team, from, to).await {
304        Ok(resp) => Json(resp).into_response(),
305        Err(e) => {
306            tracing::warn!("personal usage query failed: {e:#}");
307            (
308                StatusCode::SERVICE_UNAVAILABLE,
309                Json(serde_json::json!({"error": "usage store unavailable"})),
310            )
311                .into_response()
312        }
313    }
314}
315
316/// Runs the person-scoped queries and assembles the response.
317///
318/// # Errors
319/// Propagates pool/query errors (the handler maps them to 503).
320pub async fn personal_usage(
321    pool: &Pool,
322    person: &str,
323    team: Option<String>,
324    from: chrono::DateTime<chrono::Utc>,
325    to: chrono::DateTime<chrono::Utc>,
326) -> anyhow::Result<MeUsageResponse> {
327    let client = pool.get().await?;
328
329    let t = client
330        .query_one(ME_TOTALS_SQL, &[&from, &to, &person])
331        .await?;
332    let totals = MeTotals {
333        requests: t.get("requests"),
334        input_tokens: t.get("input_tokens"),
335        output_tokens: t.get("output_tokens"),
336        cost_usd: t.get("cost_usd"),
337        saved_tokens: t.get("saved_tokens"),
338        saved_usd: t.get("saved_usd"),
339        reference_cost_usd: t.get("reference_cost_usd"),
340        routed_requests: t.get("routed_requests"),
341    };
342
343    let by_model = client
344        .query(ME_BY_MODEL_SQL, &[&from, &to, &person])
345        .await?
346        .iter()
347        .map(|r| MeModelRow {
348            model: r.get("model"),
349            provider: r.get("provider"),
350            requests: r.get("requests"),
351            input_tokens: r.get("input_tokens"),
352            output_tokens: r.get("output_tokens"),
353            cost_usd: r.get("cost_usd"),
354            saved_usd: r.get("saved_usd"),
355        })
356        .collect();
357
358    let by_project = client
359        .query(ME_BY_PROJECT_SQL, &[&from, &to, &person])
360        .await?
361        .iter()
362        .map(|r| MeProjectRow {
363            project: r.get("project"),
364            requests: r.get("requests"),
365            cost_usd: r.get("cost_usd"),
366            saved_usd: r.get("saved_usd"),
367        })
368        .collect();
369
370    // MCP tool usage (GL#104). A gateway that never served MCP traffic has no
371    // mcp_events table — that is an empty section, not an error.
372    let tools = client
373        .query(super::mcp::store::ME_TOOLS_SQL, &[&from, &to, &person])
374        .await
375        .map(|rows| {
376            rows.iter()
377                .map(|r| MeToolRow {
378                    server_id: r.get("server_id"),
379                    tool: r.get("tool"),
380                    calls: r.get("calls"),
381                    result_tokens: r.get("result_tokens"),
382                    context_cost_usd: r.get("context_cost_usd"),
383                })
384                .collect()
385        })
386        .unwrap_or_default();
387
388    let measured: Vec<TimeseriesPoint> = client
389        .query(ME_TIMESERIES_SQL, &[&from, &to, &person])
390        .await?
391        .iter()
392        .map(|r| {
393            let day: chrono::DateTime<chrono::Utc> = r.get("day");
394            TimeseriesPoint {
395                day: day.format("%Y-%m-%d").to_string(),
396                requests: r.get("requests"),
397                cost_usd: r.get("cost_usd"),
398                saved_usd: r.get("saved_usd"),
399                reference_cost_usd: r.get("reference_cost_usd"),
400            }
401        })
402        .collect();
403
404    let cfg = crate::core::config::Config::load();
405    Ok(MeUsageResponse {
406        person: person.to_string(),
407        team,
408        org_label: cfg.gateway_server.org_label.clone(),
409        version: env!("CARGO_PKG_VERSION").to_string(),
410        from: from.to_rfc3339(),
411        to: to.to_rfc3339(),
412        totals,
413        by_model,
414        by_project,
415        tools,
416        days: fill_gaps(&measured, from, to),
417    })
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn shell_paths_cover_exactly_the_public_surface() {
426        assert!(is_shell_path("/me"));
427        assert!(is_shell_path("/me/static/me.js"));
428        assert!(is_shell_path("/me/static/fonts/inter-variable.woff2"));
429        // The data API and everything else stay guarded.
430        assert!(!is_shell_path("/api/me/usage"));
431        assert!(!is_shell_path("/me2"));
432        assert!(!is_shell_path("/mex/static/a.js"));
433        assert!(!is_shell_path("/v1/messages"));
434    }
435
436    #[test]
437    fn embedded_assets_are_nonempty_and_wired() {
438        assert!(ME_HTML.contains("<!doctype html"));
439        assert!(
440            ME_HTML.contains("/me/static/me.js"),
441            "shell must load the app script"
442        );
443        assert!(
444            ME_HTML.contains("/me/static/base.css"),
445            "shell must reuse the console design system"
446        );
447        assert!(
448            ME_JS.contains("/api/me/usage"),
449            "app must talk to the guarded API"
450        );
451        assert!(
452            ME_FONTS_CSS.contains("/me/static/fonts/"),
453            "font faces must resolve on the proxy port"
454        );
455        assert!(!ME_CSS.is_empty());
456        assert!(!VENDOR_CHART_JS.is_empty());
457    }
458
459    #[test]
460    fn shell_never_embeds_credentials() {
461        for needle in ["Bearer ", "gk-", "LEAN_CTX_PROXY_TOKEN="] {
462            assert!(!ME_HTML.contains(needle), "me.html must not embed {needle}");
463        }
464        assert!(
465            !ME_JS.contains("localStorage.setItem('leanctx-me-key'"),
466            "key must live in sessionStorage, not persist in localStorage"
467        );
468    }
469
470    #[test]
471    fn window_days_are_clamped() {
472        for (input, expected) in [
473            (None, DEFAULT_WINDOW_DAYS),
474            (Some(0), 1),
475            (Some(7), 7),
476            (Some(9999), MAX_WINDOW_DAYS),
477        ] {
478            let days = input
479                .unwrap_or(DEFAULT_WINDOW_DAYS)
480                .clamp(1, MAX_WINDOW_DAYS);
481            assert_eq!(days, expected, "input {input:?}");
482        }
483    }
484
485    #[test]
486    fn response_shape_round_trips() {
487        // The response is a client contract for the /me shell — pin it.
488        let resp = MeUsageResponse {
489            person: "alice@zuehlke.com".into(),
490            team: Some("platform".into()),
491            org_label: Some("Zühlke Engineering AG".into()),
492            version: "3.8.18".into(),
493            from: "2026-06-03T00:00:00+00:00".into(),
494            to: "2026-07-03T00:00:00+00:00".into(),
495            totals: MeTotals {
496                requests: 412,
497                input_tokens: 9_000_000,
498                output_tokens: 310_000,
499                cost_usd: 84.12,
500                saved_tokens: 2_400_000,
501                saved_usd: 41.90,
502                reference_cost_usd: 190.55,
503                routed_requests: 96,
504            },
505            by_model: vec![MeModelRow {
506                model: "zuehlke/fast".into(),
507                provider: "foundry".into(),
508                requests: 96,
509                input_tokens: 1_000_000,
510                output_tokens: 50_000,
511                cost_usd: 4.20,
512                saved_usd: 12.80,
513            }],
514            by_project: vec![MeProjectRow {
515                project: "checkout".into(),
516                requests: 412,
517                cost_usd: 84.12,
518                saved_usd: 41.90,
519            }],
520            tools: vec![MeToolRow {
521                server_id: "github".into(),
522                tool: "get_issue".into(),
523                calls: 31,
524                result_tokens: 128_000,
525                context_cost_usd: 0.32,
526            }],
527            days: vec![],
528        };
529        let json = serde_json::to_value(&resp).expect("serializes");
530        assert_eq!(json["person"], "alice@zuehlke.com");
531        assert_eq!(json["totals"]["routed_requests"], 96);
532        assert_eq!(json["by_model"][0]["model"], "zuehlke/fast");
533        let parsed: MeUsageResponse = serde_json::from_value(json).expect("round-trips");
534        assert_eq!(parsed, resp);
535    }
536
537    #[tokio::test]
538    async fn me_usage_refuses_identityless_tokens() {
539        // Org token (no person) → 403; absent tags → 403.
540        for tags in [
541            None,
542            Some(axum::Extension(GatewayTags::default())),
543            Some(axum::Extension(GatewayTags {
544                person: None,
545                team: None,
546                project: Some("side-quest".into()),
547            })),
548        ] {
549            let resp = me_usage(tags, Query(MeQuery { days: Some(7) })).await;
550            assert_eq!(resp.status(), StatusCode::FORBIDDEN);
551        }
552    }
553
554    #[tokio::test]
555    async fn me_usage_without_store_is_503() {
556        // A personal key but no installed pool (plain proxy mode) → 503 with
557        // a actionable error, never a panic. (No pool is installed in unit
558        // tests — OnceLock stays empty.)
559        let tags = Some(axum::Extension(GatewayTags {
560            person: Some("alice".into()),
561            team: None,
562            project: None,
563        }));
564        let resp = me_usage(tags, Query(MeQuery { days: None })).await;
565        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
566    }
567}