feat: server-authoritative case window via users.toml

The /api/oneliners time window is now read per-user from users.toml
(window_hours, default 72h). Clients no longer carry a window:
client.toml oneliner_window_hours, SyncConfig.window_hours, the
?hours=N query param, and the render_case_list cutoff filter are
gone. ETag suffix keeps the effective hours so an admin edit to
users.toml invalidates client caches on the next request.
OnelinersResponse.window_hours stays in the wire format, but now
exists solely to anchor client reconciliation.
This commit is contained in:
2026-04-21 16:48:01 +02:00
parent c8186c9f88
commit 3218fc62bd
24 changed files with 167 additions and 132 deletions
+18
View File
@@ -22,6 +22,9 @@ pub struct AuthenticatedUser {
pub slug: String,
pub role: String,
pub data_dir: PathBuf,
/// Copied from the matching entry in `users.toml` — the single
/// source of truth for how many hours of history clients see.
pub window_hours: u32,
}
impl AuthenticatedUser {
@@ -61,6 +64,7 @@ where
slug: slug.clone(),
role: user.role.clone(),
data_dir,
window_hours: user.window_hours,
})
}
}
@@ -71,6 +75,9 @@ pub struct AuthenticatedWebUser {
pub slug: String,
pub role: String,
pub data_dir: PathBuf,
/// Mirrors [`AuthenticatedUser::window_hours`] so the same policy
/// is available to web handlers without a second config lookup.
pub window_hours: u32,
}
impl AuthenticatedWebUser {
@@ -124,10 +131,21 @@ where
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
let data_dir = config.data_path.join(&session.slug);
// Look up the TOML user record for the policy fields the session
// does not carry. A missing record can only happen if users.toml
// was edited while a session was still live; fall back to the
// serde default so the request stays functional.
let window_hours = config
.users
.iter()
.find(|u| u.slug == session.slug)
.map(|u| u.window_hours)
.unwrap_or(72);
Ok(Self {
slug: session.slug.clone(),
role: session.role.clone(),
data_dir,
window_hours,
})
}
}
+44
View File
@@ -26,6 +26,13 @@ pub struct RetentionUserSettings {
pub auto_delete_days: u32,
}
/// Default time window (hours) of visible cases when the user entry
/// omits `window_hours`. Chosen so Monday morning still shows Friday's
/// unfinished work. Server-authoritative: clients never override this.
fn default_window_hours() -> u32 {
72
}
/// A single user entry from users.toml.
#[derive(Debug, Deserialize)]
pub struct User {
@@ -37,6 +44,13 @@ pub struct User {
pub whisper: WhisperUserSettings,
#[serde(default)]
pub retention: RetentionUserSettings,
/// Time window (hours) that defines which cases are visible to this
/// user's clients. Server-authoritative: the `/api/oneliners` scan
/// filters recordings older than `now - window_hours` before they
/// reach any client. Clients render what the server returns and
/// apply no additional time filter.
#[serde(default = "default_window_hours")]
pub window_hours: u32,
}
/// Wrapper for deserializing the [[user]] array from TOML.
@@ -275,6 +289,7 @@ mod tests {
role: "doctor".into(),
whisper: WhisperUserSettings::default(),
retention: RetentionUserSettings::default(),
window_hours: default_window_hours(),
}
}
@@ -402,4 +417,33 @@ mod tests {
let err = validate_and_index_users(&users).unwrap_err();
assert!(err.contains("duplicate slug"), "got: {err}");
}
#[test]
fn parses_user_without_window_hours_defaults_to_72() {
let toml_str = r#"
[[user]]
slug = "a"
api_key = "k1"
web_password = ""
role = "doctor"
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
// Missing field falls back to the serde default so existing
// users.toml files continue to work unchanged.
assert_eq!(parsed.user[0].window_hours, 72);
}
#[test]
fn parses_user_with_explicit_window_hours() {
let toml_str = r#"
[[user]]
slug = "a"
api_key = "k1"
web_password = ""
role = "doctor"
window_hours = 168
"#;
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.user[0].window_hours, 168);
}
}
+12 -19
View File
@@ -1,10 +1,13 @@
//! GET /api/oneliners — list oneliners for the authenticated user.
//!
//! Time window is caller-controlled via `?hours=N` (default 16, clamped
//! to [1, MAX_HOURS]). Each entry carries the case-id, the oneliner
//! state (or null if no state file has been written yet), and UTC
//! timestamps for when the case started and when its oneliner was last
//! written.
//! The time window is **server-authoritative**: it is read from the
//! user's `users.toml` entry (`window_hours`) and surfaced via the
//! [`AuthenticatedUser`] extractor. Clients no longer control it —
//! any `?hours=N` on the request URL is ignored.
//!
//! Each entry carries the case-id, the oneliner state (or null if no
//! state file has been written yet), and UTC timestamps for when the
//! case started and when its oneliner was last written.
//!
//! Conditional-GET: the response carries a weak ETag
//! `W/"<fingerprint_hex>-<hours>"`. The fingerprint is a deterministic
@@ -15,18 +18,17 @@
//! shifts at least one tuple and the hash moves. Server restart is a
//! no-op: same FS, same hash.
//!
//! The `hours` component is included so a client swapping window sizes
//! does not get a stale 304.
//! The `hours` component of the ETag stays so that an admin editing
//! `window_hours` in `users.toml` (and restarting the server)
//! invalidates existing client caches automatically.
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use axum::Json;
use axum::extract::Query;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use doctate_common::oneliners::{OnelinerEntry, OnelinersResponse};
use serde::Deserialize;
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use tracing::warn;
@@ -35,20 +37,11 @@ use crate::auth::AuthenticatedUser;
use crate::error::AppError;
use crate::paths;
const DEFAULT_HOURS: u32 = 16;
const MAX_HOURS: u32 = 168;
#[derive(Deserialize)]
pub struct OnelinersQuery {
hours: Option<u32>,
}
pub async fn handle_oneliners(
user: AuthenticatedUser,
Query(params): Query<OnelinersQuery>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let hours = params.hours.unwrap_or(DEFAULT_HOURS).clamp(1, MAX_HOURS);
let hours = user.window_hours;
let now = OffsetDateTime::now_utc();
let since = now - Duration::hours(hours as i64);