Skip to main content

lean_ctx/http_server/
team_billing.rs

1//! `GET /v1/storage` + `GET /v1/usage` — the team server's billing-plane
2//! surface (`docs/contracts/billing-plane-v2.md`, GL #463).
3//!
4//! `/v1/storage` reports the hosted workspace footprint (retrieval index,
5//! knowledge store, event log — everything the server persists under its data
6//! root and the workspaces' `.lean-ctx` state dirs). It is **server-measured**:
7//! the control plane's hourly `metering_job` polls it for Stripe meter events
8//! and threshold mails, so the report carries plain numbers and no content.
9//! Field casing is `camelCase` (`usedBytes`), matching what
10//! `lean-ctx-cloud/src/metering_job.rs` and `metering.rs::from_storage` read.
11//!
12//! `/v1/usage` is the unified usage snapshot for the account dashboard: the
13//! savings roll-up (from the same signed-batch store as `/v1/savings/summary`)
14//! plus a `storage` block in `snake_case` (`used_bytes`) — the spelling
15//! `metering.rs::from_usage` expects for that block.
16//!
17//! Sizing uses allocated disk blocks (`st_blocks * 512`) on Unix so sparse and
18//! partially-written files bill what they actually occupy; on other platforms
19//! it falls back to logical file length. Reports are cached for
20//! `STORAGE_CACHE_TTL` per process — the walk is `O(files)` and the metering
21//! job polls hourly, so 60 s keeps repeated dashboard hits cheap without
22//! letting bills go stale.
23//!
24//! Authorisation: both routes are gated by [`TeamScope::Audit`](super::team)
25//! in the team auth middleware — same sensitivity class as `/v1/metrics` and
26//! `/v1/savings/summary`, and the scope the control plane's audit-only token
27//! carries.
28
29use std::collections::BTreeSet;
30use std::path::{Path, PathBuf};
31use std::time::{Duration, Instant};
32
33use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
34use serde::Serialize;
35use serde_json::json;
36
37use super::team::TeamAppState;
38
39/// How long a measured storage report may be served from cache.
40pub(super) const STORAGE_CACHE_TTL: Duration = Duration::from_mins(1);
41
42/// Env var through which a deployment can *override* the plan quota in bytes
43/// (ops escape hatch). Normally the quota arrives as `storageQuotaBytes` in
44/// `team.json`, rendered per plan by the control plane's provisioning bridge
45/// (#282: Team 5 GiB, Enterprise 50 GiB).
46pub(super) const QUOTA_ENV: &str = "LEANCTX_TEAM_STORAGE_QUOTA_BYTES";
47
48/// Default quota when neither the env override nor `storageQuotaBytes` is
49/// present: the Team tier's 5 GiB, per the provisioning contract ("the server
50/// defaults to the Team tier when omitted",
51/// `lean-ctx-cloud/src/provisioning/instance.rs`). Always resolving to a
52/// concrete quota keeps the control plane's metering out of the degenerate
53/// `quota = 0 ⇒ state "none"` path on hosted instances.
54pub(super) const DEFAULT_TEAM_STORAGE_QUOTA_BYTES: u64 = 5 * 1024 * 1024 * 1024;
55
56/// One measured storage component (a directory or file the server persists).
57#[derive(Debug, Clone, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct StorageComponent {
60    /// Stable identifier: `server-data` or `workspace:<id>`.
61    pub id: String,
62    pub bytes: u64,
63}
64
65/// A measured (uncached) storage report.
66#[derive(Debug, Clone)]
67pub struct StorageReport {
68    pub used_bytes: u64,
69    pub components: Vec<StorageComponent>,
70}
71
72/// Cached report + when it was measured.
73#[derive(Default)]
74pub struct StorageCache(Option<(Instant, StorageReport)>);
75
76/// The measurement inputs, fixed at server startup.
77#[derive(Clone)]
78pub struct StorageRoots {
79    /// The server's data root (audit log, savings store, hosted indices) —
80    /// `/data` on hosted instances.
81    pub data_root: PathBuf,
82    /// Per-workspace persistent state (`<root>/.lean-ctx`), skipped when it
83    /// already lives under [`Self::data_root`] so nothing is counted twice.
84    pub workspaces: Vec<(String, PathBuf)>,
85    /// Plan quota in bytes, resolved once at startup
86    /// (env override → `storageQuotaBytes` → Team-tier default).
87    pub quota_bytes: u64,
88}
89
90impl StorageRoots {
91    /// Measure every component now. `O(files)` — call through the cache.
92    fn measure(&self) -> StorageReport {
93        let mut components = Vec::new();
94        let data_bytes = dir_allocated_bytes(&self.data_root);
95        components.push(StorageComponent {
96            id: "server-data".into(),
97            bytes: data_bytes,
98        });
99
100        for (id, state_dir) in &self.workspaces {
101            if state_dir.starts_with(&self.data_root) {
102                continue; // already inside server-data
103            }
104            components.push(StorageComponent {
105                id: format!("workspace:{id}"),
106                bytes: dir_allocated_bytes(state_dir),
107            });
108        }
109
110        let used_bytes = components
111            .iter()
112            .map(|c| c.bytes)
113            .fold(0u64, u64::saturating_add);
114        StorageReport {
115            used_bytes,
116            components,
117        }
118    }
119}
120
121/// `GET /v1/storage` — `camelCase`, served from the 60 s cache.
122pub async fn v1_storage(State(state): State<TeamAppState>) -> impl IntoResponse {
123    let (report, age) = cached_report(&state).await;
124    let body = json!({
125        "schemaVersion": 1,
126        "measuredAt": chrono::Utc::now().to_rfc3339(),
127        "usedBytes": report.used_bytes,
128        "quotaBytes": state.team.storage_roots.quota_bytes,
129        "components": report.components,
130        "cacheAgeSeconds": age.as_secs(),
131    });
132    (StatusCode::OK, Json(body))
133}
134
135/// `GET /v1/usage` — savings roll-up + `snake_case` storage block.
136pub async fn v1_usage(State(state): State<TeamAppState>) -> impl IntoResponse {
137    let dir = state.team.savings_store_dir.lock().await.clone();
138    let summary = tokio::task::spawn_blocking(move || super::savings_summary::aggregate(&dir))
139        .await
140        .unwrap_or_default();
141    let (report, _) = cached_report(&state).await;
142
143    let storage = json!({
144        "used_bytes": report.used_bytes,
145        "quota_bytes": state.team.storage_roots.quota_bytes,
146    });
147
148    let body = json!({
149        "schemaVersion": 1,
150        "generatedAt": chrono::Utc::now().to_rfc3339(),
151        "savings": {
152            "memberCount": summary.member_count,
153            "savedTokens": summary.totals.saved_tokens,
154            "netSavedTokens": summary.totals.net_saved_tokens,
155            "savedUsd": summary.totals.saved_usd,
156        },
157        // Signed-ledger events are the team's measured agent actions — the
158        // honest "tool calls" figure (each ledger entry is one measured call).
159        "toolCalls": summary.totals.total_events,
160        "storage": storage,
161    });
162    (StatusCode::OK, Json(body))
163}
164
165/// Serve from cache when fresh; otherwise re-measure off the async runtime.
166async fn cached_report(state: &TeamAppState) -> (StorageReport, Duration) {
167    {
168        let cache = state.team.storage_cache.lock().await;
169        if let Some((at, report)) = cache.0.as_ref() {
170            let age = at.elapsed();
171            if age < STORAGE_CACHE_TTL {
172                return (report.clone(), age);
173            }
174        }
175    }
176
177    let roots = state.team.storage_roots.clone();
178    let report = tokio::task::spawn_blocking(move || roots.measure())
179        .await
180        .unwrap_or(StorageReport {
181            used_bytes: 0,
182            components: Vec::new(),
183        });
184
185    let mut cache = state.team.storage_cache.lock().await;
186    cache.0 = Some((Instant::now(), report.clone()));
187    (report, Duration::ZERO)
188}
189
190fn quota_bytes_from_env() -> Option<u64> {
191    std::env::var(QUOTA_ENV).ok()?.trim().parse::<u64>().ok()
192}
193
194/// Resolve the effective quota: env override (ops escape hatch) →
195/// `storageQuotaBytes` from `team.json` (provisioning, #282) → Team-tier
196/// default. Pure so the precedence is unit-testable without env races.
197pub(super) fn resolve_quota_bytes(env_override: Option<u64>, config_quota: Option<u64>) -> u64 {
198    env_override
199        .or(config_quota)
200        .unwrap_or(DEFAULT_TEAM_STORAGE_QUOTA_BYTES)
201}
202
203/// Build the measurement roots from the server config: the audit log's parent
204/// is the server data root; each workspace contributes `<root>/.lean-ctx`.
205/// `config_quota` is the `storageQuotaBytes` value from `team.json`.
206pub(super) fn storage_roots_from_config(
207    audit_log_path: &Path,
208    workspaces: &[(String, PathBuf)],
209    config_quota: Option<u64>,
210) -> StorageRoots {
211    let data_root = audit_log_path
212        .parent()
213        .unwrap_or_else(|| Path::new("."))
214        .to_path_buf();
215    let workspaces = workspaces
216        .iter()
217        .map(|(id, root)| (id.clone(), root.join(".lean-ctx")))
218        .collect();
219    StorageRoots {
220        data_root,
221        workspaces,
222        quota_bytes: resolve_quota_bytes(quota_bytes_from_env(), config_quota),
223    }
224}
225
226/// Recursively sum a directory's allocated bytes. Missing paths are `0`
227/// (a fresh server simply has no footprint yet). Symlinks are not followed
228/// (`symlink_metadata`), so a link cannot inflate the bill or escape the root;
229/// hard-linked files are deduplicated by (dev, inode) on Unix.
230fn dir_allocated_bytes(path: &Path) -> u64 {
231    let mut seen: BTreeSet<(u64, u64)> = BTreeSet::new();
232    let mut total = 0u64;
233    let mut stack = vec![path.to_path_buf()];
234    while let Some(p) = stack.pop() {
235        let Ok(meta) = std::fs::symlink_metadata(&p) else {
236            continue;
237        };
238        if meta.is_symlink() {
239            continue;
240        }
241        if meta.is_dir() {
242            if let Ok(entries) = std::fs::read_dir(&p) {
243                stack.extend(entries.flatten().map(|e| e.path()));
244            }
245            continue;
246        }
247        #[cfg(unix)]
248        {
249            use std::os::unix::fs::MetadataExt;
250            if !seen.insert((meta.dev(), meta.ino())) {
251                continue; // hard link already counted
252            }
253            total = total.saturating_add(meta.blocks().saturating_mul(512));
254        }
255        #[cfg(not(unix))]
256        {
257            let _ = &mut seen;
258            total = total.saturating_add(meta.len());
259        }
260    }
261    total
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn temp_dir(tag: &str) -> PathBuf {
269        let d =
270            std::env::temp_dir().join(format!("leanctx_team_billing_{tag}_{}", std::process::id()));
271        let _ = std::fs::remove_dir_all(&d);
272        std::fs::create_dir_all(&d).unwrap();
273        d
274    }
275
276    #[test]
277    fn missing_dir_measures_zero() {
278        let missing = std::env::temp_dir().join("leanctx_team_billing_does_not_exist_xyz");
279        let _ = std::fs::remove_dir_all(&missing);
280        assert_eq!(dir_allocated_bytes(&missing), 0);
281    }
282
283    /// Quota precedence (#282/#463): env override → `storageQuotaBytes` from
284    /// `team.json` → Team-tier 5 GiB default. The report therefore always
285    /// carries a concrete quota and hosted metering never degenerates into
286    /// the `quota = 0 ⇒ "none"` state.
287    #[test]
288    fn quota_resolution_precedence() {
289        assert_eq!(resolve_quota_bytes(Some(7), Some(9)), 7);
290        assert_eq!(resolve_quota_bytes(None, Some(9)), 9);
291        assert_eq!(
292            resolve_quota_bytes(None, None),
293            DEFAULT_TEAM_STORAGE_QUOTA_BYTES
294        );
295        assert_eq!(DEFAULT_TEAM_STORAGE_QUOTA_BYTES, 5_368_709_120);
296    }
297
298    #[test]
299    fn allocated_bytes_cover_written_content() {
300        let d = temp_dir("alloc");
301        std::fs::write(d.join("a.jsonl"), vec![b'x'; 10_000]).unwrap();
302        std::fs::create_dir_all(d.join("nested")).unwrap();
303        std::fs::write(d.join("nested/b.bin"), vec![b'y'; 5_000]).unwrap();
304        let measured = dir_allocated_bytes(&d);
305        // Allocation granularity is FS-dependent; it must cover the logical
306        // sizes without an absurd blow-up (factor 64 ≈ one 64 KiB cluster
307        // per tiny file, far above any real FS we deploy on).
308        assert!(measured >= 15_000, "measured {measured} < logical 15000");
309        assert!(
310            measured < 15_000 * 64,
311            "measured {measured} implausibly large"
312        );
313        let _ = std::fs::remove_dir_all(&d);
314    }
315
316    #[cfg(unix)]
317    #[test]
318    fn hard_links_count_once_and_symlinks_do_not_escape() {
319        let d = temp_dir("links");
320        std::fs::write(d.join("real.bin"), vec![b'z'; 8_192]).unwrap();
321        std::fs::hard_link(d.join("real.bin"), d.join("hard.bin")).unwrap();
322        let outside = temp_dir("links_outside");
323        std::fs::write(outside.join("big.bin"), vec![b'w'; 100_000]).unwrap();
324        std::os::unix::fs::symlink(outside.join("big.bin"), d.join("escape.bin")).unwrap();
325
326        let measured = dir_allocated_bytes(&d);
327        let single = dir_allocated_bytes(&outside); // ~100k for comparison
328        assert!(measured < single, "symlink target must not be billed");
329        // 8 KiB once, not twice (allow allocation slack below 2x).
330        assert!(
331            (8_192..16_384).contains(&measured),
332            "hard link double-counted: {measured}"
333        );
334
335        let _ = std::fs::remove_dir_all(&d);
336        let _ = std::fs::remove_dir_all(&outside);
337    }
338
339    #[test]
340    fn workspace_state_dirs_under_data_root_are_not_double_counted() {
341        let d = temp_dir("dedupe");
342        let audit = d.join("audit.jsonl");
343        std::fs::write(&audit, "x").unwrap();
344        // Workspace lives under the data root — its .lean-ctx is part of
345        // server-data and must be skipped as a separate component.
346        let ws_root = d.join("ws1");
347        std::fs::create_dir_all(ws_root.join(".lean-ctx")).unwrap();
348        std::fs::write(ws_root.join(".lean-ctx/events.jsonl"), vec![b'e'; 4_096]).unwrap();
349        // And one external workspace that must be counted.
350        let ext = temp_dir("dedupe_ext");
351        std::fs::create_dir_all(ext.join(".lean-ctx")).unwrap();
352        std::fs::write(ext.join(".lean-ctx/k.jsonl"), vec![b'k'; 4_096]).unwrap();
353
354        let roots = storage_roots_from_config(
355            &audit,
356            &[
357                ("inside".into(), ws_root.clone()),
358                ("outside".into(), ext.clone()),
359            ],
360            None,
361        );
362        let report = roots.measure();
363        let ids: Vec<&str> = report.components.iter().map(|c| c.id.as_str()).collect();
364        assert!(ids.contains(&"server-data"));
365        assert!(
366            !ids.contains(&"workspace:inside"),
367            "nested workspace double-counted"
368        );
369        assert!(ids.contains(&"workspace:outside"));
370        let sum: u64 = report.components.iter().map(|c| c.bytes).sum();
371        assert_eq!(report.used_bytes, sum);
372
373        let _ = std::fs::remove_dir_all(&d);
374        let _ = std::fs::remove_dir_all(&ext);
375    }
376}