From 00e831ea83afa30d3de14b46f957164e05cadef5 Mon Sep 17 00:00:00 2001
From: Dessalines39394 <245616256+Dessalines39394@users.noreply.github.com>
Date: Sun, 16 Aug 2026 11:12:08 +0000
Subject: [PATCH 001/144] fix(docs): restore Star History chart with a working
provider
The Star History chart was broken because GitHub stargazer API restrictions disabled the previous service. Point the chart at a working alternative so the README graph renders again.
---
README.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 096cdc00..0ff14ec5 100644
--- a/README.md
+++ b/README.md
@@ -205,11 +205,11 @@ OxiCloud is a community-driven project, and we appreciate all contributions. Che
## Star History
-
+
-
-
-
+
+
+
From 20e6e05bb454a4ae56ceba0049c0c5f9e5d89f6f Mon Sep 17 00:00:00 2001
From: Edouard Vanbelle
Date: Wed, 19 Aug 2026 10:10:39 +0200
Subject: [PATCH 002/144] feat(sessions): identify online sessions (connected
users)
identify online session by writing the `last_seen_at`
information is stored in a map and flush each 30s to prevent performance impact on pgsql
---
Cargo.toml | 2 +-
docs/plan/sessions.md | 401 ++++++++++++++++++
.../20261014000000_sessions_last_seen_at.sql | 28 ++
src/application/dtos/session_dto.rs | 180 +++++++-
src/application/ports/auth_ports.rs | 9 +
.../services/auth_application_service.rs | 74 ++--
.../services/device_auth_service.rs | 11 +-
src/common/di.rs | 43 ++
src/domain/entities/session.rs | 18 +
.../repositories/pg/session_pg_repository.rs | 12 +-
src/infrastructure/services/jwt_service.rs | 73 +++-
.../services/last_seen_tracker.rs | 239 +++++++++++
src/infrastructure/services/mod.rs | 2 +
.../services/session_liveness_gauges.rs | 169 ++++++++
src/interfaces/middleware/auth.rs | 21 +
src/main.rs | 72 ++++
16 files changed, 1317 insertions(+), 37 deletions(-)
create mode 100644 docs/plan/sessions.md
create mode 100644 migrations/20261014000000_sessions_last_seen_at.sql
create mode 100644 src/infrastructure/services/last_seen_tracker.rs
create mode 100644 src/infrastructure/services/session_liveness_gauges.rs
diff --git a/Cargo.toml b/Cargo.toml
index 346ba04a..6f3d85ba 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,7 @@ mimalloc = { version = "0.1.52", default-features = false }
axum = { version = "0.8.9", features = ["multipart", "http1", "http2", "tokio", "macros"] }
# "process" was previously enabled implicitly through aws-config's feature
# unification; ffmpeg_video_frame_service needs it, so declare it ourselves.
-tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process"] }
+tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process", "signal"] }
tokio-util = { version = "0.7.18", features = ["io", "codec", "compat"] }
tokio-stream = { version = "0.1.18", features = ["fs", "sync"] }
bytes = "1.11.1"
diff --git a/docs/plan/sessions.md b/docs/plan/sessions.md
new file mode 100644
index 00000000..1ce0c0cc
--- /dev/null
+++ b/docs/plan/sessions.md
@@ -0,0 +1,401 @@
+# Session Liveness Tracking — `last_seen_at` + Prometheus
+
+Track per-session and per-user "currently active" signals cheaply, and
+expose them as Prometheus gauges so an operator (demo instance,
+production) can plot concurrency over time. Also unlocks future
+features that need "when was this session last used" (idle-timeout
+enforcement, per-user session-limit quotas, admin dashboard freshness).
+
+Companion doc for the design decisions covered here — narrative on the
+overall session model lives in
+[docs/architecture/auth-model.md](../architecture/auth-model.md).
+
+## Purpose — what we want to see
+
+Two distinct signals, deliberately separate:
+
+1. **Online sessions** (`oxicloud_sessions_online`) — count of
+ non-revoked `auth.sessions` rows that had a request within the last
+ N minutes. One user with three devices (browser + phone + Nextcloud
+ desktop) contributes **three** to this count. Useful for
+ provisioning ("how many concurrent connections do I need to
+ support?") and load-shape planning.
+
+2. **Online users** (`oxicloud_sessions_online_users`) — count of
+ DISTINCT `user_id` values behind those online sessions. Same
+ three-device user contributes **one** to this count. Useful for
+ billing shape ("how many humans are actually using the system?")
+ and for the demo landing page's "N users online right now" widget.
+
+The gap between the two IS the multi-device factor. A healthy system
+where users routinely have web + desktop client should show `sessions
+≈ 2 × users`. A sudden `sessions >> users × 3` is a signal — an app
+that opens fresh sessions instead of reusing them, or a
+credential-stuffing pattern.
+
+### Terminology — "online" vs "active"
+
+The admin sessions panel already has a lifecycle filter
+`Active | Expired | Revoked` — where **active** means
+`!revoked && !expired` (row is still usable). That's orthogonal to
+"had a request lately", so both concepts fighting for the same word
+was going to confuse admins reading the panel.
+
+**Decision (Ed, 2026-08-18)** — "online" is the *presence* signal
+throughout the stack:
+
+- **UI**: green-dot badge next to each row when
+ `SessionSummaryDto::is_online == true`; grey dot + "last seen X ago"
+ otherwise. Lifecycle filter stays `Active | Expired | Revoked`
+ unchanged.
+- **DTO**: `is_online: bool` on `SessionSummaryDto`, computed
+ server-side (avoids the SPA doing clock math and drifting from the
+ server view). Guaranteed `false` on revoked / expired rows so an
+ admin never sees "Online" on a row they just revoked.
+- **Metrics**: `oxicloud_sessions_online` / `_online_users`.
+- **Threshold**: single `pub const ONLINE_WINDOW` in
+ `application/dtos/session_dto.rs` — DTO derivation AND gauge query
+ read from the same constant so the per-row badge count and the
+ gauge aggregate stay consistent by construction.
+
+## Why the existing signals don't answer this — DECIDED
+
+`auth.sessions.created_at` is the closest existing proxy. But it
+moves on **session rotation**, not per-request:
+
+- Sessions rotate on every silent refresh (`apiFetch`'s 401 → refresh
+ path). Rotation cadence = `access_token_expiry_secs` (default
+ 3600, i.e. 1 h).
+- So `WHERE created_at > NOW() - INTERVAL '1 hour'` catches everyone
+ who refreshed in the last cycle — but a user who's actively clicking
+ around for 45 min hasn't rotated yet, so their `created_at` is 45
+ min old. Threshold `< 30 min` false-negatives them.
+- Resolution is capped at the access-token TTL. At the recommended
+ prod value of 15 min, `created_at` gives 15-min granularity. At the
+ test value of 60 s it's near-real-time — but no operator wants
+ to force 60 s token TTL just for observability.
+
+So `created_at` is an OK first-pass proxy but bad enough that we
+should add a dedicated column that moves per-request.
+
+## Schema — `last_seen_at`
+
+Migration `_sessions_last_seen_at.sql`:
+
+```sql
+ALTER TABLE auth.sessions
+ ADD COLUMN last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
+
+-- Partial index — the "active in the last N min" query is the only
+-- reason to scan this column, and it always filters on revoked = FALSE.
+-- Full index would double the write cost for zero read benefit.
+CREATE INDEX idx_sessions_last_seen_at ON auth.sessions(last_seen_at)
+ WHERE revoked = FALSE;
+```
+
+Default `NOW()` — existing rows on migration land at "just seen"
+which is slightly optimistic but the alternative (NULL / epoch) makes
+every historic session count as long-idle for the first N min after
+deploy. `NOW()` matches "assume everyone's active" and the metric
+converges to reality within one N-min bucket.
+
+**Not in scope for this schema**: adding `last_seen_at` to
+`auth.app_passwords`. Nextcloud-desktop clients authenticate through
+that table; separate concern, separate PR if we want desktop-client
+liveness.
+
+## Coalescing writes — DECIDED: in-process DashMap + periodic flush
+
+Naive shape (one `UPDATE` per authenticated request) would multiply
+the DB write rate by every non-mutating request the SPA fires
+(listing pages, thumbnails, delta-upload chunk PUTs). Untenable.
+
+The pattern that scales:
+
+- Middleware stamps `(session_id, Instant::now())` into a shared
+ `DashMap>`. **O(1)** per request, no I/O.
+- Background task drains the map every N seconds (default 30) and
+ emits ONE batched `UPDATE ... FROM UNNEST(...)` covering every
+ distinct session seen in the window.
+- The map data structure IS the dedup: same `session_id` → same key
+ → last write wins. A session seen 100× in the window contributes
+ ONE row to the batched update, with the latest timestamp.
+
+The `UPDATE` uses `greatest()` to be idempotent under any
+retry/race/clock-skew:
+
+```sql
+UPDATE auth.sessions AS s
+SET last_seen_at = greatest(s.last_seen_at, t.seen_at)
+FROM UNNEST($1::uuid[], $2::timestamptz[]) AS t(id, seen_at)
+WHERE s.id = t.id;
+```
+
+**Restart durability**: up to one flush interval of activity lost on
+hard crash. On graceful shutdown (SIGTERM) run one final flush
+synchronously before exit — zero loss on planned rolling restarts.
+
+**Failure durability**: if the batched UPDATE fails (PG blip),
+DON'T clear the DashMap; next tick retries with the accumulated set
+overlaid on any new activity.
+
+### Why not `Arc>`
+
+Every authenticated request writes. Under any concurrency (delta
+uploads, thumbnail bursts, folder listing paginations firing in
+parallel) a single mutex becomes the bottleneck. `DashMap`'s
+per-shard locking (16-32 shards by default) parallelises writes
+across distinct keys — different session_ids don't contend.
+
+### Why not `Arc>`
+
+The workload is write-heavy (every auth'd request writes, reads only
+fire in the flusher). RwLock would still serialize the writes for no
+benefit.
+
+### Why not PG `NOTIFY` / `LISTEN`
+
+Considered. Trade-offs:
+
+- **Pro**: cross-instance coalescing — multiple OxiCloud processes
+ behind a load balancer push to one channel, single flusher owns
+ writes.
+- **Con**: every request pays a PG round-trip (`SELECT
+ pg_notify(...)`) — ~1 ms per request vs ~50 ns for DashMap insert.
+ On hot endpoints (delta chunk PUTs, thumbnails) this is
+ measurable.
+- **Con**: adds a persistent LISTEN connection to the pool.
+- **Con**: OxiCloud is single-instance today. The multi-instance win
+ doesn't apply.
+
+**Deferred**: if OxiCloud ever grows a multi-instance deployment
+story (Kubernetes, active-active behind a load balancer), migrate
+the flusher to `NOTIFY`-based ingest — schema stays identical, only
+the tracker implementation swaps. Document the migration path in
+[Future — multi-instance](#future--multi-instance) below.
+
+## Metric surface — Prometheus
+
+Exposed via the existing `/metrics` endpoint (see
+`src/interfaces/metrics.rs`; gated on `OXICLOUD_METRICS_LISTEN`).
+
+### Gauges (polled every 30 s from a background task)
+
+```
+# HELP oxicloud_sessions_online Non-revoked sessions seen in the last N min.
+# TYPE oxicloud_sessions_online gauge
+oxicloud_sessions_online
+
+# HELP oxicloud_sessions_online_users Distinct users behind online sessions.
+# TYPE oxicloud_sessions_online_users gauge
+oxicloud_sessions_online_users
+
+# HELP oxicloud_sessions_total_non_revoked Total non-revoked sessions
+# regardless of activity — the long tail (mobile clients still holding
+# refresh tokens they haven't used in weeks).
+# TYPE oxicloud_sessions_total_non_revoked gauge
+oxicloud_sessions_total_non_revoked
+```
+
+Queries powering each:
+
+```sql
+-- oxicloud_sessions_online
+SELECT COUNT(*) FROM auth.sessions
+WHERE revoked = FALSE
+ AND last_seen_at > NOW() - $1::interval; -- $1 = ONLINE_WINDOW
+
+-- oxicloud_sessions_online_users
+SELECT COUNT(DISTINCT user_id) FROM auth.sessions
+WHERE revoked = FALSE
+ AND last_seen_at > NOW() - $1::interval;
+
+-- oxicloud_sessions_total_non_revoked
+SELECT COUNT(*) FROM auth.sessions WHERE revoked = FALSE;
+```
+
+All three run on the maintenance pool (background polling shouldn't
+compete with request-serving connections). Three lightweight
+`COUNT(*)` reads every 30 s; measured cost negligible even on
+tens-of-thousands-of-rows tables thanks to the partial index.
+
+### Counters (already in-place shape)
+
+`oxicloud_sessions_created_total` and
+`oxicloud_sessions_revoked_total{reason}` — extend the existing
+counter surface in the auth service (`session.created` audit line
+sites) to also `metrics::counter!(...)`. Not strictly needed for the
+"how many active" question but useful sanity signal on the
+dashboard: rate of creation vs rate of revocation should be
+approximately balanced at steady state.
+
+## Config surface
+
+**No new env var.** Ed's call (2026-08-18): tuning the online window
+is a deployment-shape question we haven't had to answer in practice,
+and adding an env knob invites premature customization. The three
+knobs stay hardcoded:
+
+- **Online window** — 5 min. Feels responsive for a demo
+ landing page without over-fluctuating with tab-open-then-close
+ blips. Lives at `pub const ONLINE_WINDOW` in
+ `src/application/dtos/session_dto.rs`; the gauges module in
+ `src/infrastructure/services/session_liveness_gauges.rs` reads
+ from that constant so the DTO badge and the gauge aggregate
+ can't drift.
+- **Flush interval** — 30 s. Balances DB write load against gauge
+ freshness (typical Prometheus scrape at 15 s sees the value
+ refreshed after at most two scrapes). Lives at `FLUSH_INTERVAL`
+ in `src/infrastructure/services/last_seen_tracker.rs`.
+- **DashMap shard count** — crate default (16). Only worth
+ surfacing when profiling shows shard contention.
+
+## Middleware wiring
+
+Auth extractor (`CurrentUserId`) already loads the session by
+refresh-token cookie / bearer-token subject. Extend the post-load
+path:
+
+```rust
+// After successful session lookup + auth checks:
+state.last_seen_tracker.stamp(session.id);
+```
+
+`LastSeenTracker` shape:
+
+```rust
+pub struct LastSeenTracker {
+ seen: Arc>>,
+ pool: Arc,
+}
+
+impl LastSeenTracker {
+ pub fn new(pool: Arc) -> Arc {
+ let seen = Arc::new(DashMap::new());
+ let this = Arc::new(Self { seen: seen.clone(), pool: pool.clone() });
+ tokio::spawn(this.clone().flush_loop());
+ this
+ }
+
+ /// Called from the auth middleware on every authenticated request.
+ /// O(1); no I/O; no round-trip.
+ pub fn stamp(&self, session_id: Uuid) {
+ self.seen.insert(session_id, Utc::now());
+ }
+
+ /// Called from the graceful-shutdown handler.
+ pub async fn flush_now(&self) -> Result<(), sqlx::Error> {
+ /* drain + one batched UPDATE, same as the loop body */
+ }
+
+ async fn flush_loop(self: Arc) {
+ let mut ticker = tokio::time::interval(Duration::from_secs(30));
+ loop {
+ ticker.tick().await;
+ let _ = self.flush_now().await; // errors logged, not propagated
+ }
+ }
+}
+```
+
+Wired in `common/di.rs`; injected into `AppState` and referenced by
+the auth extractor.
+
+## Graceful shutdown
+
+Hook into the existing SIGTERM handler in `main.rs` to call
+`tracker.flush_now().await` before the runtime exits. Ensures rolling
+restarts / container replacements don't lose the last 30 s of
+liveness data.
+
+## Admin dashboard integration (deferred, sibling PR)
+
+Once `last_seen_at` exists, the admin sessions panel can render a
+"last seen X min ago" column instead of only "created X ago". Small
+follow-up — not part of this plan's scope, but the schema addition
+unlocks it.
+
+## Testing
+
+Two hermetic units (no DB):
+
+1. `DashMap` dedup test — insert same key 10× with different
+ timestamps, drain, assert one entry with the latest timestamp.
+2. `flush_now()` UPDATE-shape test — mock a `PgExecutor`, assert the
+ batched UNNEST binds match the drained set. Uses `sqlx-mock` or
+ equivalent.
+
+One integration (real PG, gated on `integration_tests` cfg):
+
+3. End-to-end — insert a session row, `stamp()` it, call `flush_now`,
+ assert `last_seen_at > created_at`. Covers the whole write path
+ including the `greatest()` guard.
+
+## Phasing
+
+1. **Migration** — add column + partial index. Ships alone; zero
+ application-layer impact. Reversible via `DROP COLUMN`. ✅ **2026-08-18**
+ (`migrations/20261014000000_sessions_last_seen_at.sql`).
+2. **`LastSeenTracker` service** — DashMap + flusher task. Wire into
+ `AppState`. Middleware calls `stamp()`. Now `last_seen_at` moves
+ in real time. ✅ **2026-08-18** (`src/infrastructure/services/last_seen_tracker.rs`).
+3. **JWT `sid` claim** — token minters carry the fresh session's
+ id; auth middleware reads it and stamps with no DB round trip.
+ New tokens carry it, old tokens still validate (Option → no-op).
+ ✅ **2026-08-18** (extends `TokenClaims::sid` on
+ `application/ports/auth_ports.rs`).
+4. **Prometheus gauges** — background poller updates the three
+ gauges every 30 s using the queries above. Gated on
+ `OXICLOUD_METRICS_LISTEN` (no recorder → no periodic PG hits).
+ ✅ **2026-08-18** (`src/infrastructure/services/session_liveness_gauges.rs`).
+5. **Graceful-shutdown flush** — hook into SIGTERM handler.
+ ✅ **2026-08-18** (added `shutdown_signal` +
+ `with_graceful_shutdown` in `main.rs`).
+6. **Session DTO exposes `last_seen_at` + `is_online`** —
+ `GET /api/admin/sessions` returns both so the admin table + external
+ monitors can read them. `is_online` is the server-side derivation
+ against `ONLINE_WINDOW` (see terminology decision above). ✅
+ **2026-08-18** (`application/dtos/session_dto.rs`).
+7. **Admin dashboard "Online" column** (deferred to a sibling PR) —
+ render `is_online` as a green/grey dot next to each row plus
+ "last seen X ago" from `last_seen_at`.
+
+## Future — multi-instance
+
+If OxiCloud grows a multi-instance deployment story (K8s replicaset,
+active-active behind a load balancer), the in-process DashMap becomes
+insufficient — each process holds its own map, N flushers race
+UPDATEs, coalescing across instances doesn't happen.
+
+Migration path when that becomes real:
+
+1. Keep the schema (`last_seen_at` column + partial index).
+2. Replace the `LastSeenTracker::stamp()` in-process insert with a
+ `SELECT pg_notify('oxicloud_session_seen', $session_id)` call.
+3. Move the flusher into a **single elected worker** (leader election
+ via advisory lock in PG). That worker `LISTEN`s the channel,
+ accumulates into an in-process HashMap, flushes on the same 30 s
+ ticker.
+4. Every OxiCloud instance publishes; one instance consumes.
+
+Trade-off: NOTIFY costs a PG round-trip per request (~1 ms) vs the
+current ~50 ns DashMap insert. Only pay that when multi-instance
+coalescing actually matters. Schema and gauge queries stay identical;
+only the tracker implementation swaps.
+
+## Open questions
+
+1. ~~Definition of "active"~~ — DECIDED 2026-08-18: hardcoded to
+ 5 min, no env var. See [Config surface](#config-surface).
+2. **Should the gauge query drop long-inactive sessions?** — a
+ Nextcloud desktop client checking in every 6 h qualifies as
+ "active" if the window is 6 h. Probably want two separate metrics
+ (web-active < 5 min AND dav-active < 6 h) but scope creep for the
+ initial ship.
+3. **Multi-tab dedup on the UI side** — three browser tabs of the
+ same user share ONE session (same cookies, same refresh token,
+ same row). Naturally deduped at the DB layer — no action needed.
+4. **App-password rows** — Nextcloud desktop / mobile clients use
+ `auth.app_passwords` on top of the session model. Whether they
+ should get their own `last_seen_at` (and a companion metric) is
+ deferred; separate concern, separate PR.
diff --git a/migrations/20261014000000_sessions_last_seen_at.sql b/migrations/20261014000000_sessions_last_seen_at.sql
new file mode 100644
index 00000000..40b36c1d
--- /dev/null
+++ b/migrations/20261014000000_sessions_last_seen_at.sql
@@ -0,0 +1,28 @@
+-- Session liveness tracking — per-request `last_seen_at` stamp on
+-- `auth.sessions`, moved by the in-process `LastSeenTracker`
+-- (see `src/application/services/last_seen_tracker.rs`) via a
+-- batched UPDATE every 30 s.
+--
+-- Distinct from `created_at`: that column moves on session ROTATION
+-- (every silent refresh), so its resolution is capped at the
+-- access-token TTL (default 3600 s). `last_seen_at` moves on every
+-- authenticated request, so the "active in the last N min" query
+-- underlying `oxicloud_sessions_active` / `_active_users` gauges is
+-- accurate to the flusher's 30 s cadence regardless of token TTL.
+--
+-- See `docs/plan/sessions.md` for the full design (why DashMap +
+-- periodic flush, why partial index, why `NOW()` default).
+
+ALTER TABLE auth.sessions
+ ADD COLUMN last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
+
+-- Partial index — the only reads on this column are the gauge
+-- queries in `session_liveness_gauges.rs`, and they always filter
+-- `revoked = FALSE`. Indexing only unrevoked rows keeps the write
+-- cost of the 30 s batched UPDATE flat: rotated / revoked rows
+-- fall out of the index automatically when `revoked` flips to TRUE
+-- (partial-index maintenance drops them, no re-scan). A full
+-- b-tree on the column would double index size for zero read
+-- benefit — every gauge query would skip the revoked half anyway.
+CREATE INDEX idx_sessions_last_seen_at ON auth.sessions(last_seen_at)
+ WHERE revoked = FALSE;
diff --git a/src/application/dtos/session_dto.rs b/src/application/dtos/session_dto.rs
index 87fe23ba..170457ee 100644
--- a/src/application/dtos/session_dto.rs
+++ b/src/application/dtos/session_dto.rs
@@ -14,6 +14,8 @@
//! separate batch fetch (extra round-trip). Frontend cross-references
//! `user_id` against its cached user list.
+use std::time::Duration;
+
use chrono::{DateTime, Utc};
use serde::Serialize;
use utoipa::ToSchema;
@@ -21,6 +23,20 @@ use uuid::Uuid;
use crate::domain::entities::session::{Session, SessionOrigin};
+/// The "recently seen" threshold that turns a session's
+/// `last_seen_at` into a green-dot "Online" badge on the admin
+/// sessions panel — AND the same window that drives the
+/// `oxicloud_sessions_online[_users]` Prometheus gauges (see
+/// `src/infrastructure/services/session_liveness_gauges.rs`).
+/// The two MUST agree so the dashboard's per-row badge count
+/// matches the gauge's aggregate — one source of truth here.
+///
+/// 5 min feels responsive without over-fluctuating with
+/// tab-open-then-close blips. Deliberately hardcoded, not an
+/// env var — see `docs/plan/sessions.md` §"Config surface" for
+/// the reasoning.
+pub const ONLINE_WINDOW: Duration = Duration::from_secs(5 * 60);
+
/// Authenticated-caller context — the caller's identity + session-
/// bound signals a service method might key off. Constructed at the
/// handler boundary from `AuthUser` and passed through unchanged;
@@ -53,6 +69,17 @@ pub struct SessionSummaryDto {
pub user_id: Uuid,
pub created_at: DateTime,
pub expires_at: DateTime,
+ /// Wall-clock time this session was last observed serving an
+ /// authenticated request. Moved forward per request by the
+ /// in-process [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker)
+ /// via a batched UPDATE every 30 s — so this value trails the
+ /// true "last seen" by at most one flush interval on a running
+ /// server. On DB read it always converges after a graceful
+ /// shutdown flush. Distinct from `created_at`: that only moves
+ /// on session rotation (silent refresh), so its resolution is
+ /// capped at the access-token TTL. The admin table renders a
+ /// "last seen X ago" column off this field.
+ pub last_seen_at: DateTime,
pub ip_address: Option,
pub user_agent: Option,
/// `true` iff the session is DPoP-bound. Rendered as a lock icon
@@ -68,7 +95,24 @@ pub struct SessionSummaryDto {
pub is_revoked: bool,
/// Whether this row is currently usable — `!revoked && expires_at > now()`.
/// Kept server-side so the SPA doesn't drift if the browser clock is off.
+ /// **Distinct from [`is_online`](Self::is_online)** — this is a
+ /// *lifecycle* signal (row still has authority), that one is a
+ /// *presence* signal (a request landed on it lately).
pub is_active: bool,
+ /// Whether the session was actually observed serving a request in the
+ /// last [`ONLINE_WINDOW`] (5 min). Presence signal, orthogonal to
+ /// [`is_active`](Self::is_active): a session may be active-and-online
+ /// (green dot in the admin table), active-and-idle (no dot, "last
+ /// seen 12 min ago"), or non-active-and-offline (expired / revoked
+ /// rows are never online). Derived server-side against
+ /// [`ONLINE_WINDOW`] so the row-level badge stays consistent with
+ /// the `oxicloud_sessions_online[_users]` Prometheus aggregates.
+ ///
+ /// Guaranteed `false` for revoked / expired rows — those short-
+ /// circuit before the recency check so a revoked row that happened
+ /// to receive a request in its final second before revocation
+ /// doesn't confusingly render "Online" post-revocation.
+ pub is_online: bool,
// NOTE: no `oidc_sid` / `oidc_sid_prefix` field. The IdP-emitted
// sid identifies the row's upstream session and stays server-side
// (used by Back-Channel Logout matching). Exposing even a prefix
@@ -104,23 +148,40 @@ impl SessionSummaryDto {
pub fn from_session(s: Session, caller_jkt: Option<&str>) -> Self {
let is_revoked = s.is_revoked();
let is_expired = s.is_expired();
+ let is_active = !is_revoked && !is_expired;
let jkt = s.dpop_jkt().map(|s| s.to_owned());
let dpop_jkt_prefix = jkt.as_ref().map(|t| t.chars().take(8).collect::());
let is_current = match (jkt.as_deref(), caller_jkt) {
(Some(row), Some(caller)) => row == caller,
_ => false,
};
+ // Presence check gated on lifecycle — a revoked or expired
+ // row's `last_seen_at` may still be fresh (the last request
+ // that arrived just before revocation), but calling it
+ // "Online" post-revocation would confuse an admin reading
+ // the panel. Short-circuit on !is_active.
+ let online_cutoff = match chrono::Duration::from_std(ONLINE_WINDOW) {
+ Ok(d) => Utc::now() - d,
+ // Cast can only fail on a Duration too large for i64
+ // milliseconds; not reachable with our 5 min constant.
+ // Fall back to "never online" rather than panic — a
+ // wrong badge is fixable, a request-path panic is not.
+ Err(_) => DateTime::::MAX_UTC,
+ };
+ let is_online = is_active && s.last_seen_at() > online_cutoff;
Self {
id: s.id(),
user_id: s.user_id(),
created_at: s.created_at(),
expires_at: s.expires_at(),
+ last_seen_at: s.last_seen_at(),
ip_address: s.ip_address().map(str::to_owned),
user_agent: s.user_agent().map(str::to_owned),
is_bound: jkt.is_some(),
dpop_jkt_prefix,
is_revoked,
- is_active: !is_revoked && !is_expired,
+ is_active,
+ is_online,
origin: s.origin(),
is_current,
}
@@ -166,6 +227,7 @@ mod tests {
Some(sid.to_string()),
None,
crate::domain::entities::session::SessionOrigin::Oidc,
+ Utc::now(),
)
}
@@ -241,6 +303,121 @@ mod tests {
assert!(dto.is_active);
}
+ #[test]
+ fn dto_exposes_last_seen_at() {
+ // Regression: the admin table renders "last seen X ago"
+ // straight off this field, and clients that build
+ // dashboards off the session API rely on it too. Guards
+ // against a struct field being removed / renamed silently.
+ let s = base(false, None);
+ let expected = s.last_seen_at();
+ let dto = SessionSummaryDto::from(s);
+ assert_eq!(dto.last_seen_at, expected);
+ let json = serde_json::to_string(&dto).unwrap();
+ assert!(
+ json.contains("\"last_seen_at\""),
+ "wire shape must include `last_seen_at`: {json}"
+ );
+ }
+
+ /// A freshly-minted, unbound, unrevoked session ships with
+ /// `last_seen_at = Utc::now()` from `Session::new`, so it
+ /// MUST render as online. This is the green-dot happy path
+ /// the admin panel keys off — regression here means the
+ /// dashboard misses every currently-active session.
+ #[test]
+ fn dto_is_online_when_last_seen_is_fresh() {
+ let dto = SessionSummaryDto::from(base(false, None));
+ assert!(dto.is_online, "fresh session must be online: {dto:?}");
+ assert!(dto.is_active);
+ }
+
+ /// A session whose `last_seen_at` is older than the
+ /// [`ONLINE_WINDOW`] MUST render as offline even when the
+ /// row is otherwise Active — that's the whole point of the
+ /// presence vs lifecycle split. Constructed via `from_raw`
+ /// so we can stamp a stale timestamp deterministically.
+ #[test]
+ fn dto_is_not_online_when_last_seen_is_stale() {
+ let stale = Utc::now() - chrono::Duration::hours(1);
+ let s = Session::from_raw(
+ Uuid::new_v4(),
+ Uuid::new_v4(),
+ "rt".to_string(),
+ Utc::now() + Duration::days(30),
+ None,
+ None,
+ stale,
+ false,
+ Uuid::new_v4(),
+ None,
+ None,
+ None,
+ SessionOrigin::Password,
+ stale,
+ );
+ let dto = SessionSummaryDto::from(s);
+ assert!(!dto.is_online, "1h-idle session must not be online");
+ assert!(dto.is_active, "stale-but-alive session stays active");
+ }
+
+ /// Anti-confusion guard: a revoked row whose `last_seen_at`
+ /// happens to be fresh (the last request that landed just
+ /// before revocation) must NOT surface as "Online" — an admin
+ /// reading the panel post-revocation expects the green dot
+ /// gone. `is_online` short-circuits on `!is_active`.
+ #[test]
+ fn dto_is_not_online_when_revoked_even_if_fresh() {
+ let dto = SessionSummaryDto::from(base(true, None));
+ assert!(dto.is_revoked);
+ assert!(!dto.is_active);
+ assert!(
+ !dto.is_online,
+ "revoked-but-fresh row must never render as online",
+ );
+ }
+
+ /// Same anti-confusion guard for expiry: a session that's
+ /// past `expires_at` but whose last request landed in the
+ /// last 5 min must not surface as online.
+ #[test]
+ fn dto_is_not_online_when_expired_even_if_fresh() {
+ let past = Utc::now() - Duration::days(1);
+ let s = Session::from_raw(
+ Uuid::new_v4(),
+ Uuid::new_v4(),
+ "rt".to_string(),
+ past, // expires_at in the past
+ None,
+ None,
+ past,
+ false,
+ Uuid::new_v4(),
+ None,
+ None,
+ None,
+ SessionOrigin::Password,
+ Utc::now(), // last_seen_at fresh
+ );
+ let dto = SessionSummaryDto::from(s);
+ assert!(!dto.is_active, "expired session is not active");
+ assert!(
+ !dto.is_online,
+ "expired-but-fresh row must never render as online",
+ );
+ }
+
+ #[test]
+ fn fresh_session_has_last_seen_equal_to_created_at() {
+ // The DB default is `NOW()` and `Session::new` mirrors
+ // that with `Utc::now()` for BOTH columns — so a
+ // freshly-minted session immediately counts as "recently
+ // active" for the liveness gauges rather than showing up
+ // as long-idle for the first flush interval.
+ let s = base(false, None);
+ assert_eq!(s.created_at(), s.last_seen_at());
+ }
+
#[test]
fn from_raw_expired_session_is_not_active() {
let past = Utc::now() - Duration::days(1);
@@ -258,6 +435,7 @@ mod tests {
None,
None,
crate::domain::entities::session::SessionOrigin::Unknown,
+ past,
);
let dto = SessionSummaryDto::from(s);
assert!(!dto.is_active);
diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs
index cb8564fa..e7f2deff 100644
--- a/src/application/ports/auth_ports.rs
+++ b/src/application/ports/auth_ports.rs
@@ -63,6 +63,14 @@ pub struct TokenClaims {
/// The DPoP middleware reads it from the already-validated token
/// (no DB round trip) to enforce "bound session → proof required".
pub dpop_jkt: Option,
+ /// Session identifier — the `auth.sessions.id` this access token
+ /// was minted for. Read by the auth middleware to stamp
+ /// per-session liveness via [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker)
+ /// with no DB round trip. `None` for tokens minted by builds
+ /// that predate the `sid` claim (backward compat during rollout;
+ /// harmless — the missing sid just means no stamp fires, and
+ /// the token still authenticates normally).
+ pub sid: Option,
}
/// Port for JWT token operations.
@@ -81,6 +89,7 @@ pub trait TokenServicePort: Send + Sync + 'static {
fn generate_access_token(
&self,
user: &User,
+ session_id: Option,
dpop_jkt: Option<&str>,
) -> Result;
diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs
index 6bd62e07..e042dcee 100644
--- a/src/application/services/auth_application_service.rs
+++ b/src/application/services/auth_application_service.rs
@@ -1269,21 +1269,17 @@ impl AuthApplicationService {
None => None,
};
- // Generate tokens using the injected token service. The
- // access token carries the `cnf.jkt` binding when present,
- // so the DPoP middleware can enforce "bound → proof required"
- // straight from the already-validated JWT — no session-row
- // lookup on the hot path.
- let access_token = self
- .token_service
- .generate_access_token(&user, validated_jkt.as_deref())?;
-
- let refresh_token = self.token_service.generate_refresh_token();
-
+ // Construct the session FIRST so its id is available to the
+ // token mint below — the `sid` claim lets the auth middleware
+ // stamp per-session liveness with no DB round trip. Order
+ // was reversed as part of the `last_seen_at` wiring
+ // (`docs/plan/sessions.md`).
+ //
// Save session — new login starts a new token family. DPoP
// binding is set at INSERT time and immutable thereafter (see
// `docs/plan/dpop.md` — a mutable bind would let an attacker
// downgrade a bound session by re-binding to their own key).
+ let refresh_token = self.token_service.generate_refresh_token();
let mut session = Session::new(
user.id(),
refresh_token.clone(),
@@ -1293,6 +1289,18 @@ impl AuthApplicationService {
Uuid::new_v4(),
origin,
);
+
+ // Generate tokens using the injected token service. The
+ // access token carries the `cnf.jkt` binding when present,
+ // so the DPoP middleware can enforce "bound → proof required"
+ // straight from the already-validated JWT — no session-row
+ // lookup on the hot path. `sid` correlates the token to the
+ // session row constructed just above.
+ let access_token = self.token_service.generate_access_token(
+ &user,
+ Some(session.id()),
+ validated_jkt.as_deref(),
+ )?;
if let Some(jkt) = validated_jkt {
// Success-path audit — records the bind so operators can
// correlate a session_id in the panel with the exact moment
@@ -1562,8 +1570,9 @@ impl AuthApplicationService {
// thread `dpop_jkt` into a GET body. Session is minted
// unbound; the SPA calls `POST /api/auth/dpop/bind`
// post-redirect to bind it (see Gate 3). Token accordingly
- // ships without `cnf.jkt`.
- let access_token = self.token_service.generate_access_token(&user, None)?;
+ // ships without `cnf.jkt`. Session constructed first so
+ // its id can feed the token's `sid` claim — see the login
+ // path above for the rationale.
let refresh_token = self.token_service.generate_refresh_token();
let session = Session::new(
user.id(),
@@ -1574,6 +1583,9 @@ impl AuthApplicationService {
Uuid::new_v4(),
crate::domain::entities::session::SessionOrigin::MagicLink,
);
+ let access_token =
+ self.token_service
+ .generate_access_token(&user, Some(session.id()), None)?;
self.session_storage.create_session(session).await?;
tracing::info!(
@@ -1715,16 +1727,12 @@ impl AuthApplicationService {
));
}
- // Generate new tokens. Inherit the DPoP binding from the
- // parent session so the refreshed access token carries the
- // same `cnf.jkt` — otherwise every refresh would silently
- // downgrade to unbound and the next request would 401 under
- // Gate 9 enforcement (see Gate 7).
- let access_token = self
- .token_service
- .generate_access_token(&user, session.dpop_jkt())?;
- let new_refresh_token = self.token_service.generate_refresh_token();
-
+ // Rotate the session first so the new row's id is available
+ // to the token mint below — the `sid` claim tracks the
+ // freshly-inserted row, not the revoked parent. Order
+ // reversed as part of the `last_seen_at` wiring
+ // (`docs/plan/sessions.md`).
+ //
// New session inherits the family_id so reuse of any ancestor triggers
// full-family revocation. Revoking the old session and inserting the
// new one happen in ONE transaction (`rotate_session`) — this path
@@ -1738,6 +1746,7 @@ impl AuthApplicationService {
// refresh silently downgrade the session to unbound, and every
// subsequent request would fail DPoP verification once required
// mode enforces per-session binding.
+ let new_refresh_token = self.token_service.generate_refresh_token();
let mut new_session = Session::new(
user.id(),
new_refresh_token.clone(),
@@ -1770,6 +1779,16 @@ impl AuthApplicationService {
new_session = new_session.with_oidc_sid(sid.to_string());
}
+ // Mint the access token AFTER the new session is fully
+ // configured — the `sid` claim points at the new row's id,
+ // and `cnf.jkt` inherits from the parent so DPoP proof
+ // enforcement (Gate 9) still holds across the rotation.
+ let access_token = self.token_service.generate_access_token(
+ &user,
+ Some(new_session.id()),
+ session.dpop_jkt(),
+ )?;
+
self.session_storage
.rotate_session(session.id(), new_session)
.await?;
@@ -4608,8 +4627,8 @@ impl AuthApplicationService {
// through the browser's redirect chain. Session is minted
// unbound; the SPA calls `POST /api/auth/dpop/bind` post-
// redirect to bind it (see Gate 3). Token accordingly ships
- // without `cnf.jkt`.
- let access_token = self.token_service.generate_access_token(&user, None)?;
+ // without `cnf.jkt`. Session constructed first so its id
+ // feeds the token's `sid` claim.
let refresh_token = self.token_service.generate_refresh_token();
let mut session = Session::new(
@@ -4630,6 +4649,11 @@ impl AuthApplicationService {
if let Some(sid) = claims.sid.as_ref() {
session = session.with_oidc_sid(sid.clone());
}
+ // Mint AFTER session is fully configured so `sid` claim
+ // aligns with the row about to be inserted.
+ let access_token =
+ self.token_service
+ .generate_access_token(&user, Some(session.id()), None)?;
self.session_storage.create_session(session).await?;
let force_password_change = self.read_force_password_change(user.id()).await;
diff --git a/src/application/services/device_auth_service.rs b/src/application/services/device_auth_service.rs
index 8e0a455e..8e10751e 100644
--- a/src/application/services/device_auth_service.rs
+++ b/src/application/services/device_auth_service.rs
@@ -180,10 +180,12 @@ impl DeviceAuthService {
// clients that don't run WebCrypto — always unbound (`None`
// for the `dpop_jkt` param), which the DPoP middleware exempts
// from proof requirements. See `docs/plan/dpop.md` Gate 9.
- let access_token = self.token_service.generate_access_token(&user, None)?;
+ //
+ // Session constructed first so its id feeds the token's
+ // `sid` claim — the auth middleware uses this to stamp
+ // per-session liveness without a DB round trip
+ // (`docs/plan/sessions.md`).
let refresh_token = self.token_service.generate_refresh_token();
-
- // Persist refresh token as a session
let session = Session::new(
user_id,
refresh_token.clone(),
@@ -193,6 +195,9 @@ impl DeviceAuthService {
Uuid::new_v4(),
crate::domain::entities::session::SessionOrigin::Device,
);
+ let access_token =
+ self.token_service
+ .generate_access_token(&user, Some(session.id()), None)?;
self.session_storage.create_session(session).await?;
// Store tokens on the device code entity
diff --git a/src/common/di.rs b/src/common/di.rs
index 5a6fb9f8..9f3fc5b7 100644
--- a/src/common/di.rs
+++ b/src/common/di.rs
@@ -2126,6 +2126,38 @@ impl AppServiceFactory {
let mut core = core;
core.zip_service = Some(zip_service);
+ // Session liveness tracker — spawns its own 30 s flush
+ // loop at construction. Only built when auth (and thus
+ // sessions) exist; when auth is off this is `None` and
+ // the middleware never calls it. Uses the maintenance
+ // pool so background flushes don't compete with
+ // request-serving connections. See
+ // [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker).
+ let last_seen_tracker = if auth_services.is_some() {
+ Some(
+ crate::infrastructure::services::last_seen_tracker::LastSeenTracker::start(
+ maintenance_pool.clone(),
+ ),
+ )
+ } else {
+ None
+ };
+
+ // Session-liveness Prometheus poller — three COUNT(*) reads
+ // every 30 s, publishing `oxicloud_sessions_active`,
+ // `_active_users`, `_total_non_revoked`. Only spawned when
+ // the Prometheus recorder is installed (i.e.,
+ // `OXICLOUD_METRICS_LISTEN` is set) — without the recorder
+ // the `metrics::gauge!(...)` calls are no-ops and the
+ // periodic PG hits would be pure waste. Requires auth for
+ // the same reason as `last_seen_tracker`: no sessions to
+ // count without it.
+ if auth_services.is_some() && self.config.metrics_listen.is_some() {
+ crate::infrastructure::services::session_liveness_gauges::spawn(
+ maintenance_pool.clone(),
+ );
+ }
+
// 9. Assemble final AppState
let mut app_state = AppState {
core,
@@ -2158,6 +2190,7 @@ impl AppServiceFactory {
people_service,
storage_usage_service,
grant_cleanup_service,
+ last_seen_tracker,
calendar_service: None,
calendar_use_case: None,
addressbook_use_case: None,
@@ -2952,6 +2985,16 @@ pub struct AppState {
pub grant_cleanup_service: Option<
Arc,
>,
+ /// Per-session liveness tracker — the auth middleware calls
+ /// `stamp(session_id)` after every successful token validation,
+ /// and a background loop flushes the DashMap to `auth.sessions.
+ /// last_seen_at` every 30 s (batched UNNEST UPDATE). `None`
+ /// when auth is disabled — nothing to track. See
+ /// [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker)
+ /// for the contract and `docs/plan/sessions.md` for the design.
+ pub last_seen_tracker: Option<
+ Arc,
+ >,
pub calendar_service: Option>,
pub calendar_use_case: Option>,
pub addressbook_use_case: Option>,
diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs
index 7e30653a..cf2b10aa 100644
--- a/src/domain/entities/session.rs
+++ b/src/domain/entities/session.rs
@@ -97,6 +97,16 @@ pub struct Session {
/// construction so a callsite can't forget to record it (the
/// admin sessions panel filters on this).
origin: SessionOrigin,
+ /// Wall-clock time the session was last observed serving an
+ /// authenticated request. Set to `created_at` at construction so
+ /// a freshly-minted session immediately counts as "recently
+ /// active" for the liveness gauges; moved forward in batches by
+ /// [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker)
+ /// via a per-30 s UNNEST-based UPDATE, so per-request writes
+ /// stay in-process. Distinct from `created_at` — that only moves
+ /// on session rotation (silent refresh), so its resolution is
+ /// capped at the access-token TTL. See `docs/plan/sessions.md`.
+ last_seen_at: DateTime,
}
impl Session {
@@ -129,6 +139,7 @@ impl Session {
oidc_sid: None,
dpop_jkt: None,
origin,
+ last_seen_at: now,
}
}
@@ -180,6 +191,7 @@ impl Session {
oidc_sid: Option,
dpop_jkt: Option,
origin: SessionOrigin,
+ last_seen_at: DateTime,
) -> Self {
Self {
id,
@@ -195,6 +207,7 @@ impl Session {
oidc_sid,
dpop_jkt,
origin,
+ last_seen_at,
}
}
@@ -258,6 +271,10 @@ impl Session {
pub fn origin(&self) -> SessionOrigin {
self.origin
}
+
+ pub fn last_seen_at(&self) -> DateTime {
+ self.last_seen_at
+ }
}
#[cfg(test)]
@@ -311,6 +328,7 @@ mod tests {
None,
Some("thumbprint-xyz".to_string()),
SessionOrigin::Unknown,
+ Utc::now(),
);
assert_eq!(s.dpop_jkt(), Some("thumbprint-xyz"));
}
diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs
index b4c638be..336b8e80 100644
--- a/src/infrastructure/repositories/pg/session_pg_repository.rs
+++ b/src/infrastructure/repositories/pg/session_pg_repository.rs
@@ -117,7 +117,7 @@ impl SessionRepository for SessionPgRepository {
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
- oidc_id_token, oidc_sid, dpop_jkt, origin
+ oidc_id_token, oidc_sid, dpop_jkt, origin, last_seen_at
FROM auth.sessions
WHERE id = $1
"#,
@@ -141,6 +141,7 @@ impl SessionRepository for SessionPgRepository {
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
+ row.get("last_seen_at"),
))
}
@@ -155,7 +156,7 @@ impl SessionRepository for SessionPgRepository {
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
- oidc_id_token, oidc_sid, dpop_jkt, origin
+ oidc_id_token, oidc_sid, dpop_jkt, origin, last_seen_at
FROM auth.sessions
WHERE refresh_token = $1
"#,
@@ -179,6 +180,7 @@ impl SessionRepository for SessionPgRepository {
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
+ row.get("last_seen_at"),
))
}
@@ -192,7 +194,7 @@ impl SessionRepository for SessionPgRepository {
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
- oidc_id_token, oidc_sid, dpop_jkt, origin
+ oidc_id_token, oidc_sid, dpop_jkt, origin, last_seen_at
FROM auth.sessions
WHERE user_id = $1
ORDER BY created_at DESC
@@ -220,6 +222,7 @@ impl SessionRepository for SessionPgRepository {
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
+ row.get("last_seen_at"),
)
})
.collect();
@@ -248,7 +251,7 @@ impl SessionRepository for SessionPgRepository {
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
- oidc_id_token, oidc_sid, dpop_jkt, origin
+ oidc_id_token, oidc_sid, dpop_jkt, origin, last_seen_at
FROM auth.sessions
WHERE ($1::uuid IS NULL OR user_id = $1)
AND ($2 OR (revoked = false AND expires_at > NOW()))
@@ -281,6 +284,7 @@ impl SessionRepository for SessionPgRepository {
row.get("oidc_sid"),
row.get("dpop_jkt"),
crate::domain::entities::session::SessionOrigin::from_wire(row.get("origin")),
+ row.get("last_seen_at"),
)
})
.collect();
diff --git a/src/infrastructure/services/jwt_service.rs b/src/infrastructure/services/jwt_service.rs
index 9f8385a2..939a37f5 100644
--- a/src/infrastructure/services/jwt_service.rs
+++ b/src/infrastructure/services/jwt_service.rs
@@ -55,6 +55,17 @@ struct JwtClaims {
/// Serialised as `{"cnf": {"jkt": "..."}}` to match RFC 9449.
#[serde(skip_serializing_if = "Option::is_none")]
pub cnf: Option,
+ /// OIDC-style `sid` claim (RFC 8417 §4.1) — carries the
+ /// `auth.sessions.id` this access token was minted for so the
+ /// auth middleware can stamp per-session liveness without a DB
+ /// round trip. `None` on tokens minted by pre-`sid` builds so
+ /// deserialisation stays backward-compatible during rollout.
+ /// Kept as `String` on the wire (Uuid parses at the port
+ /// boundary) so a malformed value fails at token-decode time
+ /// with a clear parse error instead of poisoning the field
+ /// silently.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub sid: Option,
}
/// RFC 9449 §5 confirmation-key wrapper. Only the `jkt` member is
@@ -72,6 +83,17 @@ impl From for TokenClaims {
// signed always carries a UUID `sub`; nil is a safe sentinel the
// middleware rejects. See benches/ROUND14.md §A3.
let sub_id = uuid::Uuid::parse_str(&claims.sub).unwrap_or_else(|_| uuid::Uuid::nil());
+ // Parse `sid` at the boundary — same amortization rationale
+ // as `sub_id` above, and gives us a clean `Option` in
+ // `TokenClaims`. A parse failure (mint-time bug or hand-
+ // crafted claim) drops the sid to `None`; the middleware
+ // then simply skips the stamp — token still authenticates.
+ // Legitimate tokens minted by this codebase always carry a
+ // valid Uuid, so this only masks external drift.
+ let sid = claims
+ .sid
+ .as_deref()
+ .and_then(|s| uuid::Uuid::parse_str(s).ok());
TokenClaims {
sub_id,
sub: claims.sub,
@@ -82,6 +104,7 @@ impl From for TokenClaims {
email: claims.email,
role: claims.role,
dpop_jkt: claims.cnf.map(|c| c.jkt),
+ sid,
}
}
}
@@ -196,6 +219,7 @@ impl TokenServicePort for JwtTokenService {
fn generate_access_token(
&self,
user: &User,
+ session_id: Option,
dpop_jkt: Option<&str>,
) -> Result {
let now = Utc::now().timestamp();
@@ -219,6 +243,7 @@ impl TokenServicePort for JwtTokenService {
cnf: dpop_jkt.map(|jkt| CnfClaim {
jkt: jkt.to_string(),
}),
+ sid: session_id.map(|id| id.to_string()),
};
// Log JWT claims for debugging
@@ -331,7 +356,7 @@ mod tests {
let user = create_test_user();
let token = service
- .generate_access_token(&user, None)
+ .generate_access_token(&user, Some(Uuid::new_v4()), None)
.expect("Should generate token");
let claims = service
@@ -370,7 +395,7 @@ mod tests {
let user = create_test_user();
let token = service
- .generate_access_token(&user, None)
+ .generate_access_token(&user, Some(Uuid::new_v4()), None)
.expect("Should generate token");
// First call: cache miss — performs full HMAC verification
@@ -397,7 +422,7 @@ mod tests {
86400,
);
let token = service
- .generate_access_token(&create_test_user(), None)
+ .generate_access_token(&create_test_user(), Some(Uuid::new_v4()), None)
.expect("Should generate token");
// Miss populates the cache; hit must hand back the very same
@@ -425,4 +450,46 @@ mod tests {
let (hits, _misses) = service.cache_stats();
assert_eq!(hits, 0, "Invalid tokens should never produce cache hits");
}
+
+ /// Regression for the `sid` claim wiring — the auth middleware
+ /// stamps per-session liveness by reading this exact field. If
+ /// the mint stops setting the claim or the port stops parsing
+ /// it, every `LastSeenTracker::stamp` call goes silent and the
+ /// Prometheus gauges freeze at zero.
+ #[test]
+ fn access_token_round_trips_session_id_as_sid_claim() {
+ let service = JwtTokenService::new(
+ "test_secret_key_at_least_32_bytes_long".to_string(),
+ 3600,
+ 86400,
+ );
+ let user = create_test_user();
+ let session_id = Uuid::new_v4();
+ let token = service
+ .generate_access_token(&user, Some(session_id), None)
+ .expect("Should generate token");
+ let claims = service.validate_token(&token).expect("Should validate");
+ assert_eq!(claims.sid, Some(session_id));
+ }
+
+ /// Backward-compatibility guard: a mint call with `None`
+ /// omits the `sid` claim entirely (matches the pre-`sid`
+ /// on-wire shape), and the validated claims surface `None`
+ /// on the port. The middleware's `if let (Some(sid), ...)`
+ /// then simply skips the stamp — critical during rollout
+ /// where old tokens are still in flight.
+ #[test]
+ fn access_token_without_session_id_omits_sid_claim() {
+ let service = JwtTokenService::new(
+ "test_secret_key_at_least_32_bytes_long".to_string(),
+ 3600,
+ 86400,
+ );
+ let user = create_test_user();
+ let token = service
+ .generate_access_token(&user, None, None)
+ .expect("Should generate token");
+ let claims = service.validate_token(&token).expect("Should validate");
+ assert_eq!(claims.sid, None);
+ }
}
diff --git a/src/infrastructure/services/last_seen_tracker.rs b/src/infrastructure/services/last_seen_tracker.rs
new file mode 100644
index 00000000..577b8a05
--- /dev/null
+++ b/src/infrastructure/services/last_seen_tracker.rs
@@ -0,0 +1,239 @@
+//! Per-session liveness tracker — the hot path of the "how many
+//! sessions are active right now?" observation loop.
+//!
+//! **Contract.** Every authenticated request calls
+//! [`LastSeenTracker::stamp`] with the session id it resolved. The
+//! call is O(1) — a DashMap upsert of `(session_id → Utc::now())` —
+//! and hits no I/O. The map data structure IS the dedup: 100
+//! requests against the same session in a flush window contribute
+//! ONE row to the batched UPDATE with the latest timestamp.
+//!
+//! A background task ([`flush_loop`](Self::flush_loop), spawned at
+//! construction) drains the map every 30 s and issues one
+//! `UPDATE ... FROM UNNEST($1::uuid[], $2::timestamptz[])` covering
+//! every distinct session_id observed in the window. The
+//! `greatest(s.last_seen_at, t.seen_at)` guard makes the write
+//! idempotent under any retry / race / clock skew — replaying the
+//! same batch never moves the column backward.
+//!
+//! **Failure model.** A flush that hits a transient PG error does
+//! NOT drop the accumulated set — the map is not cleared until the
+//! UPDATE succeeds. Next tick overlays new activity on the retry
+//! set and the whole thing gets flushed together. Bounded loss
+//! window under a hard crash is one flush interval; graceful
+//! shutdown calls [`flush_now`](Self::flush_now) synchronously (see
+//! `main.rs`) so rolling restarts drop nothing.
+//!
+//! **Non-goals.** No per-session locking, no ordering guarantees
+//! across sessions, no back-pressure on the flusher (the loop
+//! swallows errors and keeps ticking). The workload is
+//! observation-only — losing a stamp under contention is a
+//! correctness no-op, the next request re-stamps.
+//!
+//! See `docs/plan/sessions.md` for the full design (why DashMap
+//! over Mutex, why not NOTIFY/LISTEN today, migration
+//! path to a multi-instance cluster).
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use chrono::{DateTime, Utc};
+use dashmap::DashMap;
+use sqlx::PgPool;
+use uuid::Uuid;
+
+/// Cadence of the batched UPDATE. Hardcoded — 30 s balances DB
+/// write load against gauge freshness (the Prometheus scrape
+/// interval is typically 15 s, so at worst two scrapes see the
+/// same value before the next flush). Deliberately NOT exposed as
+/// an env var — tuning it is a deployment-shape question we've
+/// never had to answer in practice.
+const FLUSH_INTERVAL: Duration = Duration::from_secs(30);
+
+/// In-process session-liveness tracker. See [module docs](self) for
+/// the full contract; the two entry points are:
+///
+/// - [`stamp`](Self::stamp) — called from the auth middleware on
+/// every authenticated request.
+/// - [`flush_now`](Self::flush_now) — called from the graceful-
+/// shutdown handler.
+///
+/// The periodic flush task is spawned on the tokio runtime by
+/// [`start`](Self::start) at construction. The struct keeps no
+/// handle to it — the task holds the `Arc` and observes the
+/// runtime shutting down naturally.
+pub struct LastSeenTracker {
+ /// (session_id → last observed time). DashMap's sharded locking
+ /// parallelises writes across distinct session_ids — different
+ /// users' requests never contend.
+ seen: DashMap>,
+ /// Maintenance pool — the tracker is a background writer and
+ /// must not compete with request-serving connections.
+ pool: Arc,
+}
+
+impl LastSeenTracker {
+ /// Construct + spawn the flush loop. Returns the shared
+ /// handle; callers store it on `AppState` and pass it to the
+ /// auth middleware.
+ ///
+ /// The background task lives for the runtime's lifetime — no
+ /// cancellation handle is exposed because there is no
+ /// mid-process reason to stop tracking (a stopped flusher is
+ /// indistinguishable from a wedged one, and both are bugs).
+ /// Graceful shutdown calls [`flush_now`](Self::flush_now)
+ /// separately BEFORE the runtime tears down.
+ pub fn start(pool: Arc) -> Arc {
+ let this = Arc::new(Self {
+ seen: DashMap::new(),
+ pool,
+ });
+ tokio::spawn(this.clone().flush_loop());
+ this
+ }
+
+ /// Record that `session_id` was observed serving a request
+ /// right now. Overwrites any prior stamp for the same session
+ /// in the current window — the flusher uses the latest value.
+ ///
+ /// O(1) DashMap upsert. No I/O. Never fails.
+ pub fn stamp(&self, session_id: Uuid) {
+ self.seen.insert(session_id, Utc::now());
+ }
+
+ /// Drain the accumulated stamps and write them in one batched
+ /// UPDATE. Idempotent — the `greatest(...)` guard means
+ /// replaying the same batch (or overlapping batches from a
+ /// retry) never moves the column backward.
+ ///
+ /// Errors are surfaced to the caller so `flush_loop`'s
+ /// warn-and-continue policy is a deliberate choice made in one
+ /// place, and the shutdown flusher in `main.rs` can decide
+ /// whether to log or panic.
+ ///
+ /// On PG error the accumulated set is NOT cleared — the next
+ /// tick retries with fresh activity overlaid.
+ pub async fn flush_now(&self) -> Result {
+ if self.seen.is_empty() {
+ return Ok(0);
+ }
+
+ // Drain into two parallel vectors — one UNNEST arg each.
+ // `retain(|_,_| false)` clears every shard in-place; the
+ // pull-and-drop order doesn't matter (we upserted the
+ // latest wins per key already).
+ let mut ids: Vec = Vec::with_capacity(self.seen.len());
+ let mut seen_at: Vec> = Vec::with_capacity(self.seen.len());
+ for entry in self.seen.iter() {
+ ids.push(*entry.key());
+ seen_at.push(*entry.value());
+ }
+
+ let result = sqlx::query(
+ r#"
+ UPDATE auth.sessions AS s
+ SET last_seen_at = greatest(s.last_seen_at, t.seen_at)
+ FROM UNNEST($1::uuid[], $2::timestamptz[]) AS t(id, seen_at)
+ WHERE s.id = t.id
+ "#,
+ )
+ .bind(&ids)
+ .bind(&seen_at)
+ .execute(&*self.pool)
+ .await?;
+
+ // Only clear the drained keys on success. A key inserted
+ // BETWEEN our copy above and the clear below survives
+ // (retain drops only those whose value we already flushed,
+ // by timestamp equality). Same-key re-stamp with a newer
+ // timestamp gets kept for the next flush.
+ let flushed: std::collections::HashMap> =
+ ids.iter().copied().zip(seen_at.iter().copied()).collect();
+ self.seen
+ .retain(|k, v| flushed.get(k).is_none_or(|ts| ts != v));
+
+ let updated = result.rows_affected() as usize;
+ tracing::debug!(
+ target: "oxicloud::sessions",
+ batched = ids.len(),
+ updated,
+ "last_seen flush",
+ );
+ Ok(updated)
+ }
+
+ /// The periodic drain loop. Runs forever; every failed flush
+ /// is logged at WARN and the accumulated set is preserved for
+ /// the next tick.
+ async fn flush_loop(self: Arc) {
+ let mut ticker = tokio::time::interval(FLUSH_INTERVAL);
+ // Skip the "first tick fires immediately" behaviour — the
+ // map is empty at spawn time, so a same-tick flush is
+ // wasted work.
+ ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
+ ticker.tick().await;
+
+ loop {
+ ticker.tick().await;
+ if let Err(err) = self.flush_now().await {
+ tracing::warn!(
+ target: "oxicloud::sessions",
+ error = %err,
+ "last_seen flush failed; will retry next tick",
+ );
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Ten stamps of the same session_id must collapse to ONE
+ /// entry with the newest timestamp — the whole point of the
+ /// DashMap-as-dedup pattern. Guards against a future refactor
+ /// that swaps to an append-only channel and doubles the DB
+ /// write rate.
+ #[test]
+ fn stamps_dedup_by_session_id() {
+ // No pool needed — we're only exercising the map. Build
+ // the tracker directly without spawning the loop.
+ let seen = DashMap::new();
+ let session = Uuid::new_v4();
+
+ for _ in 0..10 {
+ seen.insert(session, Utc::now());
+ }
+
+ assert_eq!(seen.len(), 1);
+ }
+
+ /// Latest-wins semantics: two stamps for the same session
+ /// leave the newer timestamp in place, matching the flusher's
+ /// `greatest(...)` guard so a request that beats the flush
+ /// keeps its more recent stamp.
+ #[test]
+ fn stamp_keeps_latest_timestamp() {
+ let seen: DashMap> = DashMap::new();
+ let session = Uuid::new_v4();
+
+ let t1 = Utc::now();
+ seen.insert(session, t1);
+ let t2 = t1 + chrono::Duration::seconds(5);
+ seen.insert(session, t2);
+
+ assert_eq!(*seen.get(&session).unwrap(), t2);
+ }
+
+ /// Distinct sessions never collide — sharded map, no dedup
+ /// across keys.
+ #[test]
+ fn different_sessions_are_independent() {
+ let seen: DashMap> = DashMap::new();
+ for _ in 0..100 {
+ seen.insert(Uuid::new_v4(), Utc::now());
+ }
+ assert_eq!(seen.len(), 100);
+ }
+}
diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs
index b86e7560..7eeab9c5 100644
--- a/src/infrastructure/services/mod.rs
+++ b/src/infrastructure/services/mod.rs
@@ -27,6 +27,7 @@ pub mod folders_consistency_service;
pub mod grant_cleanup_service;
pub mod image_transcode_service;
pub mod jwt_service;
+pub mod last_seen_tracker;
pub mod local_blob_backend;
pub mod local_fs_mount_provider;
pub mod login_lockout_service;
@@ -51,6 +52,7 @@ pub mod retry_blob_backend;
pub mod s3_blob_backend;
pub mod search_index;
pub mod session_cleanup_service;
+pub mod session_liveness_gauges;
pub mod share_unlock_cookie;
pub mod smtp_email_sender;
pub mod swappable_blob_backend;
diff --git a/src/infrastructure/services/session_liveness_gauges.rs b/src/infrastructure/services/session_liveness_gauges.rs
new file mode 100644
index 00000000..cc6f0cfe
--- /dev/null
+++ b/src/infrastructure/services/session_liveness_gauges.rs
@@ -0,0 +1,169 @@
+//! Prometheus session-liveness gauges — periodic polling of
+//! `auth.sessions` to publish three gauges the `/metrics` scraper
+//! reads:
+//!
+//! - `oxicloud_sessions_online` — non-revoked rows observed in the
+//! last [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW).
+//! **Per-session count**, not per-user — one user with three
+//! devices contributes three.
+//! - `oxicloud_sessions_online_users` — DISTINCT `user_id` behind
+//! those online sessions. The multi-device factor is exactly
+//! `sessions_online / sessions_online_users`.
+//! - `oxicloud_sessions_total_non_revoked` — long-tail total,
+//! including mobile clients still holding a refresh token they
+//! haven't used in weeks. Useful sanity signal on the dashboard.
+//!
+//! **Naming — "online" vs "active".** The word "active" is already
+//! spoken for by the session *lifecycle* (Active | Expired |
+//! Revoked in the admin panel). Presence (recently-seen) is
+//! orthogonal and uses "online" throughout the UI, DTO
+//! (`SessionSummaryDto::is_online`), and these gauges — so a
+//! dashboard graph and a per-row green-dot badge have the same
+//! label root. Terminology decided 2026-08-18; see
+//! `docs/plan/sessions.md`.
+//!
+//! **Cadence.** Poller ticks every [`POLL_INTERVAL`] (30 s). Three
+//! `COUNT(*)` reads on the maintenance pool per tick — negligible
+//! load on tens-of-thousands-of-rows tables thanks to the partial
+//! index `idx_sessions_last_seen_at` (partial on `revoked = FALSE`,
+//! which every query below filters on).
+//!
+//! **When it runs.** Spawned from DI only when auth is enabled AND
+//! `OXICLOUD_METRICS_LISTEN` is set (recorder installed). Without
+//! the recorder, `metrics::gauge!(...)` is a no-op — spawning
+//! anyway would still hit PG every 30 s for values nobody reads.
+//!
+//! See `docs/plan/sessions.md` for the full design.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use sqlx::PgPool;
+
+use crate::application::dtos::session_dto::ONLINE_WINDOW;
+
+/// Poll cadence. Matches the [`LastSeenTracker`](super::last_seen_tracker)
+/// flush cadence so the gauges converge one tick after the tracker
+/// flushes — no need to sync the two.
+const POLL_INTERVAL: Duration = Duration::from_secs(30);
+
+/// Spawn the session-liveness poller. Detached — the task lives
+/// for the runtime's lifetime; there's no mid-process reason to
+/// stop reporting gauges.
+///
+/// Emits an initial poll on spawn so the very first `/metrics`
+/// scrape after boot returns real values instead of the recorder's
+/// zero-initialised default.
+pub fn spawn(maintenance_pool: Arc) {
+ tokio::spawn(async move {
+ // Immediate first tick — a scraper hitting `/metrics` in
+ // the first 30 s otherwise sees `oxicloud_sessions_online
+ // 0` even on a busy server. Warmup query is cheap.
+ if let Err(err) = poll_once(&maintenance_pool).await {
+ tracing::warn!(
+ target: "oxicloud::sessions",
+ error = %err,
+ "initial session-liveness poll failed",
+ );
+ }
+
+ let mut ticker = tokio::time::interval(POLL_INTERVAL);
+ ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
+ // Consume the first tick — `interval` fires immediately on
+ // creation and we've already done the warmup above.
+ ticker.tick().await;
+
+ loop {
+ ticker.tick().await;
+ if let Err(err) = poll_once(&maintenance_pool).await {
+ tracing::warn!(
+ target: "oxicloud::sessions",
+ error = %err,
+ "session-liveness poll failed; keeping last-known gauge values",
+ );
+ }
+ }
+ });
+ tracing::info!(
+ target: "oxicloud::sessions",
+ poll_interval_secs = POLL_INTERVAL.as_secs(),
+ online_window_secs = ONLINE_WINDOW.as_secs(),
+ "📊 session-liveness gauges spawned",
+ );
+}
+
+/// One poll cycle. Three lightweight `COUNT` reads → three gauge
+/// updates. Errors propagate to the caller (loop logs + retries
+/// next tick; gauges keep their last-known value in the interim,
+/// which is the honest thing to publish — a temporary PG blip is
+/// not a "sessions dropped to zero" event).
+async fn poll_once(pool: &PgPool) -> Result<(), sqlx::Error> {
+ // NOTE: `ONLINE_WINDOW` is a Duration; PG expects the interval
+ // in seconds via `make_interval` (portable across sqlx driver
+ // versions). Casting once at bind time is cheaper than an
+ // `INTERVAL '$1 seconds'` string interp and keeps the query
+ // parameterised.
+ let online_secs: f64 = ONLINE_WINDOW.as_secs_f64();
+
+ let online: i64 = sqlx::query_scalar(
+ r#"
+ SELECT COUNT(*) FROM auth.sessions
+ WHERE revoked = FALSE
+ AND last_seen_at > NOW() - make_interval(secs => $1)
+ "#,
+ )
+ .bind(online_secs)
+ .fetch_one(pool)
+ .await?;
+
+ let online_users: i64 = sqlx::query_scalar(
+ r#"
+ SELECT COUNT(DISTINCT user_id) FROM auth.sessions
+ WHERE revoked = FALSE
+ AND last_seen_at > NOW() - make_interval(secs => $1)
+ "#,
+ )
+ .bind(online_secs)
+ .fetch_one(pool)
+ .await?;
+
+ let total_non_revoked: i64 = sqlx::query_scalar(
+ r#"
+ SELECT COUNT(*) FROM auth.sessions WHERE revoked = FALSE
+ "#,
+ )
+ .fetch_one(pool)
+ .await?;
+
+ // metrics-exporter-prometheus takes f64 gauges; the raw COUNT
+ // fits into f64 precisely up to 2^53, well past any realistic
+ // session-row count. `describe_gauge!` is called once at first
+ // emission and cached in the recorder — the second/third tick
+ // just updates the value.
+ metrics::describe_gauge!(
+ "oxicloud_sessions_online",
+ "Non-revoked sessions observed in the last ONLINE_WINDOW."
+ );
+ metrics::gauge!("oxicloud_sessions_online").set(online as f64);
+
+ metrics::describe_gauge!(
+ "oxicloud_sessions_online_users",
+ "Distinct users behind sessions observed in the last ONLINE_WINDOW."
+ );
+ metrics::gauge!("oxicloud_sessions_online_users").set(online_users as f64);
+
+ metrics::describe_gauge!(
+ "oxicloud_sessions_total_non_revoked",
+ "Total non-revoked sessions regardless of last-seen recency."
+ );
+ metrics::gauge!("oxicloud_sessions_total_non_revoked").set(total_non_revoked as f64);
+
+ tracing::debug!(
+ target: "oxicloud::sessions",
+ online,
+ online_users,
+ total_non_revoked,
+ "session-liveness gauges updated",
+ );
+ Ok(())
+}
diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs
index 7f2a70ec..a2f2f6a1 100644
--- a/src/interfaces/middleware/auth.rs
+++ b/src/interfaces/middleware/auth.rs
@@ -229,6 +229,18 @@ pub async fn auth_middleware(
request.extensions_mut().insert(current_user);
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
+ // Bump per-session liveness for the
+ // Prometheus gauges. O(1) DashMap upsert
+ // — no I/O on this hot path. The `sid`
+ // claim is `None` on tokens minted by
+ // pre-`sid` builds, in which case the
+ // stamp is skipped entirely — no
+ // fallback lookup, no round-trip.
+ if let (Some(sid), Some(tracker)) =
+ (claims.sid, state.last_seen_tracker.as_ref())
+ {
+ tracker.stamp(sid);
+ }
return Ok(next.run(request).await);
}
Err(e) => {
@@ -353,6 +365,15 @@ pub async fn auth_middleware(
request.extensions_mut().insert(CookieAuthenticated);
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
+ // Cookie-auth branch stamps the same
+ // way as the Bearer branch above —
+ // see that site for the O(1) /
+ // no-DB rationale.
+ if let (Some(sid), Some(tracker)) =
+ (claims.sid, state.last_seen_tracker.as_ref())
+ {
+ tracker.stamp(sid);
+ }
return Ok(next.run(request).await);
}
LiveRole::Revoked => {
diff --git a/src/main.rs b/src/main.rs
index c25fcc0c..7d936460 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1409,17 +1409,89 @@ async fn run() -> Result<(), Box> {
let listener = tokio::net::TcpListener::from_std(socket.into())?;
+ // Grab shutdown-hook handles BEFORE the router consumes
+ // app_state below. Currently: only the session `LastSeenTracker`
+ // needs a final synchronous flush on graceful shutdown so
+ // rolling restarts don't lose the last flush interval of
+ // liveness stamps. Future services with shutdown obligations
+ // add their handles here and chain their flushes into
+ // [`shutdown_signal`] alongside this one.
+ let last_seen_tracker = app_state.last_seen_tracker.clone();
+
// Provide the fully-built state to the router
let app = app.with_state(app_state);
// TCP_NODELAY is inherited from the listening socket on Linux,
// so every accepted connection already has Nagle disabled.
+ //
+ // `with_graceful_shutdown` waits for SIGTERM / SIGINT, then
+ // stops accepting new connections, drains in-flight requests,
+ // and runs the async block below. The session tracker flush
+ // fires AFTER draining so it captures any last-second requests
+ // that landed while shutdown propagates.
axum::serve(
listener,
app.into_make_service_with_connect_info::(),
)
+ .with_graceful_shutdown(async move {
+ shutdown_signal().await;
+ if let Some(tracker) = last_seen_tracker {
+ match tracker.flush_now().await {
+ Ok(n) => tracing::info!(
+ target: "oxicloud::sessions",
+ flushed = n,
+ "last-seen final flush before shutdown",
+ ),
+ Err(err) => tracing::warn!(
+ target: "oxicloud::sessions",
+ error = %err,
+ "last-seen final flush failed; up to one flush interval of \
+ liveness data may have been lost",
+ ),
+ }
+ }
+ })
.await?;
tracing::info!("Server shutdown completed");
Ok(())
}
+
+/// Block until the process receives SIGINT (Ctrl-C) or SIGTERM
+/// (systemd / docker stop / K8s pod eviction). Returns once EITHER
+/// arrives — no distinction between them at the caller: a signal
+/// is a signal, drain and exit.
+///
+/// On non-Unix (Windows), the `terminate` arm is a never-resolving
+/// future so only Ctrl-C works — which matches how Windows expects
+/// service shutdown to be signalled anyway.
+async fn shutdown_signal() {
+ let ctrl_c = async {
+ if let Err(err) = tokio::signal::ctrl_c().await {
+ tracing::warn!("failed to install Ctrl-C handler: {err}");
+ }
+ };
+
+ #[cfg(unix)]
+ let terminate = async {
+ match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
+ Ok(mut s) => {
+ s.recv().await;
+ }
+ Err(err) => {
+ tracing::warn!("failed to install SIGTERM handler: {err}");
+ // Fall through to a pending future so tokio::select! doesn't
+ // spin — Ctrl-C is still armed.
+ std::future::pending::<()>().await;
+ }
+ }
+ };
+
+ #[cfg(not(unix))]
+ let terminate = std::future::pending::<()>();
+
+ tokio::select! {
+ _ = ctrl_c => tracing::info!("SIGINT received, initiating graceful shutdown"),
+ _ = terminate => tracing::info!("SIGTERM received, initiating graceful shutdown"),
+ }
+}
From 20495355162dc2a9046e583357e7809f40443e68 Mon Sep 17 00:00:00 2001
From: Edouard Vanbelle
Date: Wed, 19 Aug 2026 10:11:33 +0200
Subject: [PATCH 003/144] feat(session): admin UI showing online sessions
---
frontend/src/lib/api/types.ts | 15 ++++
.../src/routes/admin/[[tab]]/+page.svelte | 70 +++++++++++++++++++
frontend/static/locales/en.json | 4 ++
frontend/static/locales/fr.json | 4 ++
4 files changed, 93 insertions(+)
diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts
index 253ec029..c99f2909 100644
--- a/frontend/src/lib/api/types.ts
+++ b/frontend/src/lib/api/types.ts
@@ -761,12 +761,27 @@ export interface SessionSummary {
user_id: string;
created_at: string;
expires_at: string;
+ /** Wall-clock (RFC 3339) of the last authenticated request the
+ * server observed on this session. Trails the true value by at
+ * most the tracker's flush interval (30 s) on a running server;
+ * converges after graceful shutdown. Populates the "last seen X
+ * ago" tooltip on the presence dot. */
+ last_seen_at: string;
ip_address: string | null;
user_agent: string | null;
is_bound: boolean;
dpop_jkt_prefix: string | null;
is_revoked: boolean;
is_active: boolean;
+ /** Presence signal — `true` when the server observed a request on
+ * this session within the last 5 minutes AND the row is `is_active`
+ * (never `true` on revoked / expired rows). Renders as a filled
+ * green dot in the Status column; `false` on an otherwise-active
+ * row renders as an outlined idle dot with a "last seen X ago"
+ * tooltip. Distinct from `is_active`: that's a lifecycle signal,
+ * this is a presence signal. Derived server-side against the
+ * same window that drives `oxicloud_sessions_online[_users]`. */
+ is_online: boolean;
/** How this session was minted. `unknown` covers pre-migration
* rows and any origin the SPA doesn't yet render. Server enum
* is populated at INSERT (see `Session::new`) and copied on
diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte
index 037e4636..532f0dcc 100644
--- a/frontend/src/routes/admin/[[tab]]/+page.svelte
+++ b/frontend/src/routes/admin/[[tab]]/+page.svelte
@@ -3082,6 +3082,36 @@
{t('admin.sessions.expired', 'expired')}
{:else}
+
+
{t('admin.sessions.active', 'active')}
@@ -4572,6 +4602,46 @@
text-transform: uppercase;
}
+ /* Presence dot in the sessions-table Status column — filled green
+ when the row is `is_online` (a request landed in the last 5 min),
+ outlined grey when the row is active-but-idle. Only rendered on
+ active rows: a revoked-but-recently-seen row must never flash
+ green post-revocation (see the markup guard `{#if s.is_active}`).
+ The dot sits BEFORE the `active` badge with a small gap, so the
+ Status cell reads left-to-right as `● active` when online and
+ `○ active` when idle.
+
+ Tokens: `--color-success-alt` / `--color-success-border` for the
+ filled fill is the same green used by `.badge--active`, keeping
+ the presence signal visually consistent with the lifecycle one
+ without stealing the badge's own colour treatment. Grey border
+ for the idle state uses the neutral `--color-border` token so
+ both themes (light + dark, driven by `light-dark(...)`) get a
+ readable contrast. Fixed 8px / 8px sizing — the dot is a signal,
+ not a click target, so relative units would over-scale on
+ larger UI densities. */
+ .presence-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: var(--space-1);
+ vertical-align: middle;
+ /* No border on the filled state, so both variants render at
+ the same 8×8 footprint (the outlined variant's 1px border
+ is inset via box-sizing: border-box below). */
+ box-sizing: border-box;
+ }
+
+ .presence-dot--online {
+ background: var(--color-success-alt);
+ }
+
+ .presence-dot--idle {
+ background: transparent;
+ border: 1px solid var(--color-border);
+ }
+
/* External / grant-only account marker. Sibling of `.badge--user`
in the same cell so the two stack horizontally; the accent
colour reuses `--color-warning-*` because "external" is the
diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json
index 304e0b2e..88557131 100644
--- a/frontend/static/locales/en.json
+++ b/frontend/static/locales/en.json
@@ -1136,6 +1136,10 @@
"revoked": "revoked",
"expired": "expired",
"active": "active",
+ "online": "online",
+ "idle": "idle",
+ "presence_online_tooltip": "Online — last seen {{ago}}",
+ "presence_idle_tooltip": "Idle — last seen {{ago}}",
"revoke": "Revoke",
"empty": "No sessions match the current filter.",
"revoke_self_confirm": "⚠️ This is YOUR current session. Revoking it will log YOU out immediately and you'll have to sign back in. Continue?",
diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json
index 165f0ad2..998c1e4a 100644
--- a/frontend/static/locales/fr.json
+++ b/frontend/static/locales/fr.json
@@ -1269,6 +1269,10 @@
"revoked": "révoquée",
"expired": "expirée",
"active": "actif",
+ "online": "en ligne",
+ "idle": "inactif",
+ "presence_online_tooltip": "En ligne — vue {{ago}}",
+ "presence_idle_tooltip": "Inactif — vue {{ago}}",
"revoke": "Révoquer",
"empty": "Aucune session ne correspond au filtre actuel.",
"revoke_self_confirm": "⚠️ Il s'agit de VOTRE session actuelle. La révoquer vous déconnectera immédiatement et vous devrez vous reconnecter. Continuer ?",
From 543a1a88eb5881992dd6a35057cf979bed148657 Mon Sep 17 00:00:00 2001
From: Edouard Vanbelle
Date: Wed, 19 Aug 2026 22:58:09 +0200
Subject: [PATCH 004/144] feat(admin > users): UI: correct oidc badge
---
examples/bench_round10_micro.rs | 1 +
frontend/src/routes/admin/[[tab]]/+page.svelte | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/examples/bench_round10_micro.rs b/examples/bench_round10_micro.rs
index 8b884d8a..a346d777 100644
--- a/examples/bench_round10_micro.rs
+++ b/examples/bench_round10_micro.rs
@@ -159,6 +159,7 @@ fn section_identity(iters: u64) {
email: Arc::from("alice.longname@example.com"),
role: "user".to_string(),
dpop_jkt: None,
+ sid: None,
});
let (bn, ba) = measure("BEFORE String clones + role to_string", iters, || {
diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte
index 532f0dcc..63243dfe 100644
--- a/frontend/src/routes/admin/[[tab]]/+page.svelte
+++ b/frontend/src/routes/admin/[[tab]]/+page.svelte
@@ -2758,8 +2758,8 @@
-->
{#if isOidcUser(u)}
-
- {u.federation_issuer}
+
+ oidc
{/if}
{#if u.has_password}
From 8cd25d7e0fe4015796bdd6c4bc88c0d459921ac7 Mon Sep 17 00:00:00 2001
From: Edouard Vanbelle
Date: Fri, 21 Aug 2026 13:49:43 +0200
Subject: [PATCH 005/144] security(RUSTSEC-2026-0258): update h2 crate
and ignore alert for aws dependency, risk of DoS is null with AWS/S3)
---
.cargo/audit.toml | 24 ++++++++++++++++++++++++
Cargo.lock | 34 +++++++++++++++++-----------------
2 files changed, 41 insertions(+), 17 deletions(-)
diff --git a/.cargo/audit.toml b/.cargo/audit.toml
index 7a63df25..f27f13b1 100644
--- a/.cargo/audit.toml
+++ b/.cargo/audit.toml
@@ -23,6 +23,30 @@ ignore = [
"RUSTSEC-2026-0098",
"RUSTSEC-2026-0099",
+ # h2 0.3.27 — RUSTSEC-2026-0258 "unbounded empty DATA frames"
+ # (GHSA-q83h-524g-xf6h). Transitive via aws-smithy-http-client 1.1.12
+ # → hyper 0.14.32 → h2 0.3.27. The patched line is 0.4.16+, but hyper
+ # 0.14's `h2 = "0.3"` requirement pins us to the 0.3.x branch which
+ # will not receive a backport — real fix requires aws-smithy-http-client
+ # to migrate to hyper 1.x (which our other h2 copy — 0.4.16, already
+ # bumped — is on). The 0.4.x copy is fixed via `cargo update`; this
+ # ignore covers only the 0.3.x chain.
+ #
+ # Severity: low (advisory's own classification). Attack is empty-DATA-
+ # frame flooding by a malicious HTTP/2 peer → memory pressure or panic.
+ # In this codebase h2 0.3.x runs strictly on the CLIENT side of AWS
+ # SDK requests to S3 endpoints. Exploitation requires either
+ # compromising AWS S3 (out-of-scope) or MitM with a valid TLS cert
+ # for the configured S3 host (bigger problem than the DoS). No
+ # data-integrity or auth impact; panic path contained by
+ # request-level unwind.
+ #
+ # Un-ignore trigger: aws-smithy-http-client releases a version that
+ # switches to hyper 1.x (checkable with `cargo tree -i h2@0.3` — the
+ # command returns no rows once the chain is gone). Track upstream at
+ # https://github.com/smithy-lang/smithy-rs/issues (search "hyper 1").
+ "RUSTSEC-2026-0258",
+
# instant unmaintained — transitive via azure_core 0.21.0 (latest available).
# No direct security impact; no upgrade path exists.
"RUSTSEC-2024-0384",
diff --git a/Cargo.lock b/Cargo.lock
index 2ee0cc0f..1156a1ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -160,7 +160,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -171,7 +171,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -601,7 +601,7 @@ dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2 0.3.27",
- "h2 0.4.13",
+ "h2 0.4.16",
"http 0.2.12",
"http 1.4.0",
"http-body 0.4.6",
@@ -2267,7 +2267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -2916,9 +2916,9 @@ dependencies = [
[[package]]
name = "h2"
-version = "0.4.13"
+version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -3197,7 +3197,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2 0.4.13",
+ "h2 0.4.16",
"http 1.4.0",
"http-body 1.0.1",
"httparse",
@@ -3285,7 +3285,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2 0.6.4",
+ "socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -3603,7 +3603,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -4318,7 +4318,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5140,7 +5140,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.40",
- "socket2 0.6.4",
+ "socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5177,7 +5177,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2 0.6.4",
+ "socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
]
@@ -5651,7 +5651,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -6174,7 +6174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -6686,7 +6686,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -7016,7 +7016,7 @@ dependencies = [
"axum",
"base64 0.22.1",
"bytes",
- "h2 0.4.13",
+ "h2 0.4.16",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
@@ -8154,7 +8154,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
From cc3be1ec38702150af4cc33f842598060c8c1579 Mon Sep 17 00:00:00 2001
From: Edouard Vanbelle
Date: Fri, 21 Aug 2026 14:27:42 +0200
Subject: [PATCH 006/144] refactor: apply clippy recos for rustc 1.98.0
---
clippy.toml | 28 +++++++++++++++++++
examples/bench_round14_queries.rs | 8 ++++--
.../repositories/pg/face_pg_repository.rs | 13 +++++++--
3 files changed, 45 insertions(+), 4 deletions(-)
create mode 100644 clippy.toml
diff --git a/clippy.toml b/clippy.toml
new file mode 100644
index 00000000..a59d2fd9
--- /dev/null
+++ b/clippy.toml
@@ -0,0 +1,28 @@
+# Clippy configuration overrides. Kept minimal — each entry documents
+# what it's for and when it should be revisited.
+
+# `clippy::result_large_err` — raise the "big Err variant" ceiling to
+# 512 bytes.
+#
+# Rationale: axum handler signatures shaped as
+# `Result` (or `AppError` variants
+# that wrap `axum::response::Response`) naturally exceed the default
+# 128-byte threshold. `Response` carries a `HeaderMap` (~256 B inline)
+# + status + body + extensions; a handful of handlers land in that
+# range without doing anything wrong. Fighting the lint per-handler
+# with `#[allow]` on every one is churn for zero runtime benefit —
+# these Results are constructed on the stack once per request and
+# never nested in a hot inner loop.
+#
+# 512 B keeps the lint's protective value: it still fires on genuinely
+# oversized Err variants (embedded `Vec` blobs, avatar payloads,
+# large enum aggregates) that WOULD be worth boxing.
+#
+# Revisit if:
+# * A future refactor slims axum Response OR extracts a small error
+# enum with an IntoResponse impl across the handler layer — then
+# drop this override back to the default 128.
+# * A specific handler exceeds 512 B and clippy re-fires — deal with
+# that handler individually (boxed error / small enum) rather than
+# raising the ceiling further.
+large-error-threshold = 512
diff --git a/examples/bench_round14_queries.rs b/examples/bench_round14_queries.rs
index c71e58cb..4fd21a62 100644
--- a/examples/bench_round14_queries.rs
+++ b/examples/bench_round14_queries.rs
@@ -43,9 +43,13 @@ fn stats(mut s: Vec) -> (f64, f64, f64) {
/// Mirror of `face_pg_repository::bytes_to_embedding` — the per-face
/// `Vec` decode the BEFORE path pays for a column it never reads.
+/// Kept byte-for-byte identical to the shipped path so the benchmark
+/// measures apples-to-apples; `as_chunks` swap matches the source.
fn bytes_to_embedding(b: &[u8]) -> Vec {
- b.chunks_exact(4)
- .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
+ b.as_chunks::<4>()
+ .0
+ .iter()
+ .map(|c| f32::from_le_bytes(*c))
.collect()
}
diff --git a/src/infrastructure/repositories/pg/face_pg_repository.rs b/src/infrastructure/repositories/pg/face_pg_repository.rs
index a9a4e12b..260c82df 100644
--- a/src/infrastructure/repositories/pg/face_pg_repository.rs
+++ b/src/infrastructure/repositories/pg/face_pg_repository.rs
@@ -47,8 +47,17 @@ fn embedding_to_bytes(e: &[f32]) -> Vec {
}
fn bytes_to_embedding(b: &[u8]) -> Vec {
- b.chunks_exact(4)
- .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
+ // `as_chunks::<4>` (stable since Rust 1.88) hands back `&[[u8; 4]]`
+ // typed at the array level, so the closure gets a `&[u8; 4]` and
+ // the `[c[0], c[1], c[2], c[3]]` array-copy dance from the old
+ // `chunks_exact(4)` shape collapses to a plain deref. Any trailing
+ // bytes that aren't a multiple of 4 land in `.1` and are dropped
+ // — same semantics as `chunks_exact` which iterated only the
+ // aligned prefix.
+ b.as_chunks::<4>()
+ .0
+ .iter()
+ .map(|c| f32::from_le_bytes(*c))
.collect()
}
From ec70b21c6e7483368613baeddb2218b69c924b46 Mon Sep 17 00:00:00 2001
From: Edouard Vanbelle
Date: Fri, 21 Aug 2026 14:06:54 +0200
Subject: [PATCH 007/144] refactor(User): clear separation PublicUserDto,
FullUserDto, SelfUserDto
---
docs/plan/userdto-refactor.md | 416 ++++++++++++++++++
src/application/dtos/user_dto.rs | 256 +++++++++++
src/domain/repositories/user_repository.rs | 45 ++
.../repositories/pg/user_pg_repository.rs | 31 +-
4 files changed, 747 insertions(+), 1 deletion(-)
create mode 100644 docs/plan/userdto-refactor.md
diff --git a/docs/plan/userdto-refactor.md b/docs/plan/userdto-refactor.md
new file mode 100644
index 00000000..17fe98aa
--- /dev/null
+++ b/docs/plan/userdto-refactor.md
@@ -0,0 +1,416 @@
+# UserDto Refactor — Three-Layer Split (Public / Full / Self)
+
+Establish three DTO shapes for representing a user on the wire, each
+with a single unambiguous audience, composed hierarchically so the
+overlap between audiences is defined ONCE:
+
+- **`PublicUserDto`** — public identity. What any authenticated caller
+ may see about *another* user. Returned by `/api/users/{id}`, share
+ responses, group members, magic-link invitees, recipient enrichment.
+- **`FullUserDto`** = `PublicUserDto` + all fields that BOTH an admin
+ (viewing another user) AND the subject themselves (viewing
+ themselves) may see. Returned as `Vec` by
+ `/api/admin/users`. Closest DTO to the underlying `auth.users` row.
+- **`SelfUserDto`** = `FullUserDto` + self-only preferences,
+ session-scoped flags, and caller-scoped permissions. Returned by
+ `/api/auth/me` and by the login / refresh / OIDC / magic-link auth
+ response.
+
+Composition (`FullUserDto.user: PublicUserDto`,
+`SelfUserDto.full: FullUserDto`) means the public-identity contract
+has ONE definition; the overlap between admin's view and self's view
+is another single definition. Adding a new field naturally finds its
+level:
+
+- Useful to any authenticated caller? → `PublicUserDto`.
+- Useful only to admin (about another user) and the subject
+ themselves? → `FullUserDto`.
+- Meaningful only to the caller viewing themselves? → `SelfUserDto`.
+
+Companion of `docs/plan/sessions.md` (which introduced `is_online`
+and motivated widening the DTO for presence). Same principle: pick
+the audience first, structure the DTO around it, don't let the same
+field mean different things on different endpoints.
+
+## Why now — the problems this fixes
+
+Today's single `UserDto` conflates three audiences. Symptoms:
+
+1. **The "quiet lie"**. `UserDto::has_password` is populated only by
+ `/api/auth/me`; every other emitter (`From`) leaves it
+ `false`. A share-recipient DTO on the wire says
+ `has_password: false` unconditionally, which an attacker
+ scraping share responses could misread as "this user is
+ passwordless" when the truth is "we didn't fill this field in
+ for you". Same pattern for `force_password_change` and
+ `is_dpop_bound`. See `src/application/dtos/user_dto.rs:134-138`
+ for the explicit disclaimer — the convention exists precisely
+ because the field placement is wrong.
+
+2. **Private signals leak by default**. `last_login_at`,
+ `notify_on_share`, `ui_preferences`, `preferred_locale`,
+ `federation_kind`, `email_verified_at`, and
+ `storage_used_bytes` all ride on `UserDto` and are returned to
+ any authenticated caller who can see a given user. Group
+ members can see when their peers last logged in, which IdP they
+ federate with, and how full their disks are. None of this is
+ information a share picker or a member listing needs.
+
+3. **`AdminUserSummaryDto` duplicates a chunk of `UserDto` verbatim**
+ (id / username / email / role / quotas / last_login_at / active /
+ federation_* / is_external), then adds three admin-only fields
+ (has_password / opaque_registered / opaque_migrated). The two
+ shapes drift naturally as new fields are added — no compile-time
+ guarantee they stay in sync.
+
+4. **N+1 in the admin panel**. Because `AdminUserSummaryDto` doesn't
+ include `image`, the admin users table fires `/api/users/{id}`
+ per row so `UserVignette` can render the avatar. Composition
+ (`FullUserDto.user.image`) lets the admin listing seed the SPA's
+ per-user cache from the list rows directly.
+
+## Target shapes
+
+### `PublicUserDto` — public identity (9 fields)
+
+Applied rule: "would a share picker / group member listing /
+recipient enrichment need this? if no, it doesn't belong here."
+
+```rust
+pub struct PublicUserDto {
+ pub id: String,
+ pub username: Option,
+ pub email: String,
+ pub role: String, // sharee UI renders admin badge
+ pub image: Option, // avatar
+ pub is_external: bool, // external badge
+ pub given_name: Option, // social identity
+ pub family_name: Option, // social identity
+ pub is_online: bool, // presence — social signal
+}
+```
+
+Every existing UserDto emitter site (`From`, share responses,
+group members, magic-link invitees, sharee-vignette lookup)
+returns this slim shape. All private signals below vanish from
+those wire paths.
+
+### `FullUserDto` — admin's view of anyone + self's view of self (13 extras)
+
+The fields the SUBJECT themselves may know about themselves that
+an ADMIN may also know about the subject. Composed on top of
+`PublicUserDto`. This is the DTO closest to the underlying
+`auth.users` row.
+
+```rust
+pub struct FullUserDto {
+ /// Public identity — same set any authenticated caller can see.
+ pub user: PublicUserDto,
+ /// IdP linkage. Which SSO provider a peer uses is a soft
+ /// org-affiliation leak; not needed by share pickers.
+ pub federation_kind: Option,
+ pub federation_issuer: Option,
+ /// Subject's own locale preference. Only THEY or an admin
+ /// managing them needs this — other callers use their own.
+ pub preferred_locale: Option,
+ /// Email-verification stamp. Trust signal — meaningful to admin
+ /// (auditing verification status) and to self (own record), but
+ /// not to a share picker rendering a vignette.
+ pub email_verified_at: Option>,
+ /// Row bookkeeping — not rendered on any non-admin surface today.
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+ /// Activity signal — private.
+ pub last_login_at: Option>,
+ /// Account-active flag — private (a deactivated user couldn't
+ /// reach `/me` anyway, but admin needs to see it).
+ pub active: bool,
+ /// Storage quotas — personal financials. Admin manages others';
+ /// self sees own.
+ pub storage_quota_bytes: i64,
+ pub storage_used_bytes: i64,
+ /// Auth capability set — has_password / OPAQUE flags. Kept off
+ /// public identity because per-user auth adoption leaks through
+ /// directory endpoints.
+ pub has_password: bool,
+ pub opaque_registered: bool,
+ pub opaque_migrated: bool,
+}
+```
+
+### `SelfUserDto` — /api/auth/me (5 extras)
+
+Everything the caller may see about themselves that no other
+caller (not even an admin) needs to see: pure self-scoped state.
+
+```rust
+pub struct SelfUserDto {
+ /// Full profile. Every field an admin would see about you is
+ /// here — same shape as one row of /api/admin/users.
+ pub full: FullUserDto,
+ /// Opaque UI preferences bag — my own UI state. Cross-device
+ /// via PATCH /api/auth/me/profile.
+ pub ui_preferences: serde_json::Value,
+ /// Whether I want share-notification emails.
+ pub notify_on_share: bool,
+ /// Session-scoped: my current session carries a DPoP thumbprint.
+ /// SPA skips a redundant /api/auth/dpop/bind on load when true.
+ pub is_dpop_bound: bool,
+ /// Admin-set temp-password gate — SPA nav guard blocks everything
+ /// but /change-password until this flips back.
+ pub force_password_change: bool,
+ /// Caller-scoped permission: can I edit my own avatar? False for
+ /// OIDC users whose avatar comes from the IdP. Only meaningful
+ /// when caller == subject; nonsense on any other DTO.
+ pub can_edit_image: bool,
+}
+```
+
+## Endpoint mapping
+
+| Endpoint | Old shape | New shape |
+|---|---|---|
+| `/api/auth/me` | `UserDto` (fat) | `SelfUserDto` |
+| `/api/auth/login` / `/refresh` / OIDC callback / magic-link redemption | `AuthResponseDto { user: UserDto }` | `AuthResponseDto { user: SelfUserDto }` |
+| `/api/admin/users` | `Vec` | `Vec` |
+| `/api/users/{id}` | `UserDto` (fat) | `PublicUserDto` (9 fields) |
+| Share responses / group members / magic-link invitees / recipient enrichment | `UserDto` (fat) | `PublicUserDto` |
+
+**Login response ships `SelfUserDto`, not `PublicUserDto`.** The SPA
+needs `has_password`, `ui_preferences`, `is_dpop_bound`, and
+`force_password_change` immediately post-login to avoid a UI race
+with the first `/me` fetch. Same rationale for refresh and
+OIDC/magic-link callback: the SPA's post-auth state must be
+complete in one round trip.
+
+## Backend callsite inventory
+
+**DTO layer** (`src/application/dtos/user_dto.rs`):
+
+- Replace `UserDto` with `PublicUserDto` (renamed AND slimmed —
+ same rename forces every consumer to consciously pick the new
+ shape rather than silently losing fields).
+- Add `FullUserDto` and `SelfUserDto`.
+- Delete `AdminUserSummaryDto` (superseded by `FullUserDto`).
+- `impl From for PublicUserDto` — the entry point. Maps 1:1
+ to `User`'s public identity accessors.
+- `FullUserDto::build(user: User, flags: UserDerivedFlags)` —
+ wraps a `PublicUserDto` plus the DB-computed booleans not on
+ `User` (`has_password`, `opaque_registered`, `opaque_migrated`,
+ `is_online`). Every other FullUserDto field comes from `User`
+ directly. Not a `From` impl because the second argument is
+ needed and Rust's `From` is single-arg.
+- `SelfUserDto::build(full: FullUserDto, session_ctx: SessionContext)`
+ — helper taking a FullUserDto plus caller context (session's
+ DPoP-bound flag, admin-set force-password-change flag). Same
+ reason as FullUserDto's builder — not a `From` impl.
+
+**Repository layer**
+(`src/infrastructure/repositories/pg/user_pg_repository.rs`):
+
+- **Delete `UserListEntry`** — the narrow projection was a perf
+ optimization; Path B decision supersedes it.
+- `list_users` returns `Vec<(User, UserDerivedFlags)>` where
+ `UserDerivedFlags` is a small named struct in
+ `domain/repositories/user_repository.rs`:
+
+ ```rust
+ /// DB-computed booleans about a user that aren't fields on the
+ /// `User` entity itself — either derived from column presence
+ /// (`password_hash IS NOT NULL`) or from a cross-table lookup
+ /// (`auth.sessions.last_seen_at` for `is_online`). Companion
+ /// to `User` on the list projection: the repo computes both,
+ /// the application layer packs them into `FullUserDto`.
+ pub struct UserDerivedFlags {
+ pub has_password: bool,
+ pub opaque_registered: bool,
+ pub opaque_migrated: bool,
+ pub is_online: bool,
+ }
+ ```
+
+ Not "admin-only" — every field ends up on `FullUserDto`, which
+ both admin AND self read. The name reflects "derived from the
+ DB row, not intrinsic to the User entity".
+- SELECT widens to include `image` (previously narrowed away
+ per ROUND12 §Q1) + the `EXISTS(...)` scalar for `is_online`,
+ bound with `ONLINE_WINDOW.as_secs_f64()` via
+ `make_interval(secs => $N)` — same pattern as
+ `session_liveness_gauges.rs:104-114`, single source of truth,
+ no SQL literal.
+
+**Handler / service layer**:
+
+- `/api/auth/me` handler — builds `SelfUserDto` from
+ `(User, UserDerivedFlags, session_context)`. The
+ `UserDerivedFlags` for /me comes from a reuse of the
+ list-repo path scoped to `WHERE id = $me` or a new small
+ dedicated query (implementer's call — either works).
+- `AuthResponseDto` shape follows — `user: SelfUserDto` field.
+- Every login/refresh/OIDC/magic-link path that mints an
+ `AuthResponseDto` computes the same SelfUserDto.
+- Admin service `list_users_admin` — returns `Vec`.
+- `/api/users/{id}` handler — returns `PublicUserDto`. Every
+ other public consumer stays on `PublicUserDto`.
+
+## Frontend callsite inventory
+
+**Type changes** (`frontend/src/lib/api/types.ts`):
+
+- Rename `User` interface → `PublicUser` and slim to match new
+ DTO (9 fields).
+- Add `FullUser` interface — `{ user: PublicUser, federation_kind, ... }`.
+- Add `SelfUser` interface — `{ full: FullUser, ui_preferences, ... }`.
+- Delete `AdminUser` (replaced by `FullUser`).
+
+**Store changes**:
+
+- `lib/stores/session.svelte.ts` — reads /me, must handle SelfUser
+ shape. Recommendation: keep a derived `session.me: SelfUser` for
+ the full record and shorthand accessors:
+ `session.user: PublicUser` = `session.me.full.user`,
+ `session.full: FullUser` = `session.me.full`.
+ Existing `session.user.username` calls keep working via the
+ shorthand; new self-only reads go through `session.me.foo` or
+ `session.full.foo`.
+
+**Component changes**:
+
+- Profile / change-password / DPoP-bind pages — reads
+ `session.me.has_password`, `session.me.is_dpop_bound`,
+ `session.me.can_edit_image`, `session.full.preferred_locale`,
+ etc.
+- `routes/admin/[[tab]]/+page.svelte` users table — every
+ `u.username` → `u.user.username`, every `u.last_login_at` /
+ `u.active` / `u.has_password` stays top-level (FullUserDto
+ fields). Also **seed `resolveUser` cache with `u.user`** in the
+ load path — kills the N+1 that motivated widening the query.
+- Admin sessions table's `UserVignette` — no change, `user_id`
+ passed through unchanged; the users-table cache seed above
+ satisfies the vignette lookup on cross-table navigation.
+- `lib/composables/useOwnerCache.ts` /
+ `lib/api/endpoints/users.ts` — `resolveUser` returns
+ `PublicUser`. No signature change; the return shape only gets
+ smaller. Add a `seedUser(u: PublicUser)` export so the admin
+ table can prime the cache.
+
+## Phasing
+
+Each step compiles standalone; each is a reasonable review chunk.
+
+1. **Introduce the new DTOs** — add `PublicUserDto`, `FullUserDto`,
+ and `SelfUserDto` alongside the existing `UserDto`. Don't
+ change `UserDto` yet. Compiles; no behaviour change.
+2. **Widen repo projection** — add `is_online` (via EXISTS
+ subquery) and `image` back to `list_users` SELECT. Introduce
+ `AdminExtras` struct. `UserListEntry` still exists but is now
+ redundant (fields also available on `User`).
+3. **Migrate the emitter sites** — `/api/auth/me`,
+ login/refresh/OIDC/magic-link, admin service. Each now builds
+ the new nested shape. Old `UserDto` still ships every field.
+4. **Rename `UserDto` → `PublicUserDto` and slim** — remove the
+ moved fields. The Rust compiler flags every remaining consumer
+ that reads a removed field; those either move to `.full.foo` /
+ `.user.foo` (embedded) or promote themselves to a Self/Full
+ DTO.
+5. **Delete `UserListEntry` + `AdminUserSummaryDto`** — dead after
+ the cutover.
+6. **Frontend** — rename types (`User` → `PublicUser`), add
+ `FullUser` / `SelfUser`, update session store, all consumers.
+ Seed `resolveUser` cache from admin table.
+7. **Regenerate OpenAPI** — `cargo run --bin generate-openapi`
+ picks up the new schemas; the shrunken `PublicUserDto` schema
+ documents the new contract.
+8. **Delete obsolete doc comments** — `has_password` /
+ `is_dpop_bound` / `force_password_change` comments on the old
+ UserDto explaining "populated only by `/me`" become obsolete
+ (the field structurally can't exist on non-self emitters).
+
+## Wire-shape breaking changes
+
+All in-repo consumers (backend + SPA) migrate in the same commit.
+External consumers: none today — `/api/admin/users` is
+admin-panel-only, `/me` is SPA-only, share/group endpoints are
+SPA-only. Ship as one clean break; skip a `?shape=v2` deprecation
+window.
+
+Every removed field from a public UserDto path (share responses,
+group members, magic-link invitees, `/api/users/{id}`) is a
+deliberate leak reduction, not a regression. Any FE consumer that
+was reading e.g. `sharee.has_password` was reading a "quiet lie"
+anyway (always `false`).
+
+## Testing
+
+**Backend**:
+
+- Round-trip tests for each new DTO type (already have for
+ UserDto; extend to PublicUserDto + FullUserDto + SelfUserDto).
+- **Structural quarantine tests** —
+ `self_user_dto_does_not_leak_ui_preferences_via_public_paths`:
+ serialize a `SelfUserDto`, assert `ui_preferences` appears
+ ONLY at top level, not inside `.full.user` or `.full`. Same for
+ `FullUserDto` — `has_password` at top level of `FullUserDto`,
+ not inside `.user`.
+- Update every service test that constructs `UserDto` fixtures.
+
+**Frontend**:
+
+- The TS type system catches every consumer that reads a removed
+ field. `npm run check` surfaces the whole blast radius on the
+ first pass — no new test infrastructure needed.
+- Add one Vitest integration on the admin users table asserting
+ the presence dot renders AND `/api/users/{id}` is NOT called
+ per row (checks `apiFetch` mock call count).
+
+**Wire-shape guard**:
+
+- Hurl test hitting `/api/users/{id}` as a non-admin caller,
+ asserting the response does NOT contain moved fields
+ (`has_password`, `last_login_at`, `notify_on_share`,
+ `ui_preferences`, `storage_used_bytes`, `federation_kind`,
+ `preferred_locale`, `email_verified_at`, `created_at`, etc.).
+ Anti-regression guard for the whole point of this refactor.
+
+## Non-goals
+
+- **Reworking the `User` domain entity** — this refactor is
+ DTO-shape only. The entity keeps all its fields.
+- **Visibility-rule changes on `/api/users/{id}`** — who can see
+ whom stays as-is; only the field set narrows.
+- **Splitting other DTOs** — `SessionSummaryDto`, `FileDto`, etc.
+ Same principle would apply, but each is a separate design call.
+- **Moving avatars out of the row** — planned separately. This
+ refactor keeps `image` on `PublicUserDto` so the admin-panel
+ N+1 fix survives.
+- **Flattening the nested shape via `#[serde(flatten)]`** — the
+ three-level wire shape (`me.full.user.username`) is slightly
+ deeper than a flat DTO would be, but the structural quarantine
+ is worth the cost. Reconsider if consumer readability suffers.
+
+## Open questions
+
+1. **Wire nesting depth on `/me`**: `me.full.user.username` is 3
+ levels. Acceptable? Alternative: `#[serde(flatten)]` on
+ FullUserDto and SelfUserDto so the wire is flat
+ (`me.username`, `me.has_password`, `me.ui_preferences` all
+ at top level), while keeping structural quarantine at compile
+ time only. Simpler for FE consumers, loses runtime
+ introspectability (a receiver can't tell which fields are
+ public vs full vs self from the shape). Recommendation: ship
+ nested; revisit if FE readability suffers.
+2. **`created_at` / `updated_at` on FullUserDto** — not rendered
+ anywhere currently. Keep for compat unless there's a
+ compelling reason to drop.
+
+## Memory notes to update on landing
+
+- Extend `project_sessions_last_seen_at_shipped` with a "led to"
+ pointer at this refactor.
+- New note `project_userdto_three_layer_split` — captures the
+ PublicUserDto / FullUserDto / SelfUserDto pattern + the
+ decision rule ("would any authenticated caller need this?
+ PublicUserDto. would self+admin? FullUserDto. self only?
+ SelfUserDto.").
+- Delete the `AdminUserSummaryDto` and `UserListEntry`
+ references in earlier memory notes.
diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs
index 9147fafa..3d88b344 100644
--- a/src/application/dtos/user_dto.rs
+++ b/src/application/dtos/user_dto.rs
@@ -288,6 +288,262 @@ impl From for UserDto {
}
}
+// ────────────────────────────────────────────────────────────────────────
+// Three-layer user DTO family — see docs/plan/userdto-refactor.md.
+//
+// `PublicUserDto` — public identity. Every authenticated caller may see it.
+// Returned by /api/users/{id}, share responses, group
+// members, magic-link invitees, recipient enrichment.
+// `FullUserDto` — `{ user: PublicUserDto, ...admin+self extras }`.
+// Returned as `Vec` by /api/admin/users;
+// embedded in `SelfUserDto`. Closest DTO to the
+// `auth.users` row.
+// `SelfUserDto` — `{ full: FullUserDto, ...self-only extras }`. Returned
+// by /api/auth/me and by every AuthResponseDto path.
+//
+// The fat `UserDto` above is being phased out — the three types will replace
+// it and its emitter sites migrate one at a time. Kept temporarily so this
+// PR compiles at every checkpoint; deleted at the end of the refactor.
+// ────────────────────────────────────────────────────────────────────────
+
+/// Public identity — what any authenticated caller may see about ANOTHER
+/// user. Returned by `/api/users/{id}` and everywhere a user is
+/// referenced by another surface (share responses, group members,
+/// magic-link invitees, recipient enrichment).
+///
+/// This is the audience-narrowest DTO: adding a field here means every
+/// authenticated caller can see it about every visible user. Fields that
+/// are meaningful only to the subject themselves (preferences, session
+/// state) or only to an admin (auth adoption signals) belong on
+/// [`SelfUserDto`] or [`FullUserDto`] respectively.
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct PublicUserDto {
+ pub id: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub username: Option,
+ pub email: String,
+ /// Role string ("admin" | "user"). Kept public because the sharee /
+ /// group-member vignette renders an admin badge.
+ pub role: String,
+ /// Avatar payload (base64 data-URI up to 512 KiB). Public so a share
+ /// picker can render the recipient's face directly. Will move to a
+ /// dedicated avatar endpoint in a future refactor — this shape is
+ /// transitional.
+ pub image: Option,
+ /// `true` for grant-only external recipients (magic-link, OIDC-only,
+ /// future OCM federated). Renders the "external" badge on the vignette.
+ pub is_external: bool,
+ /// Optional first/given name. Social identity.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub given_name: Option,
+ /// Optional last/family name. Social identity.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub family_name: Option,
+ /// Presence signal — TRUE when the server observed a request on any
+ /// of this user's non-revoked sessions within the last
+ /// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW)
+ /// (5 min). Sourced from an EXISTS subquery when the DTO is built
+ /// from a list-projection path; single-user endpoints that don't
+ /// enrich presence ship `false`.
+ #[serde(default)]
+ pub is_online: bool,
+}
+
+/// Full user record — public identity + all fields BOTH an admin
+/// (viewing another user) AND the subject themselves may see. Returned
+/// as `Vec` by `/api/admin/users`; embedded in
+/// [`SelfUserDto`] for `/api/auth/me`.
+///
+/// This is the DTO closest to the underlying `auth.users` row. Adding a
+/// field here means an admin looking at any user can see it, and the
+/// subject themselves can see it in their `/me` response — but the field
+/// stays off the public [`PublicUserDto`] surface.
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct FullUserDto {
+ /// Public identity — same set every authenticated caller sees.
+ pub user: PublicUserDto,
+ /// Which trust chain minted this user's federation identity —
+ /// `"oidc" | "ocm" | "magic_link"` — or `None` for pure local users.
+ /// Kept off `PublicUserDto` because a peer's federation kind is a
+ /// soft org-affiliation leak; only self + admin need it.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub federation_kind: Option,
+ /// The authority that minted this user's `federation_subject` —
+ /// issuer URL for OIDC (id_token `iss` claim), peer domain for OCM,
+ /// `None` for local users. Same rationale as `federation_kind`.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub federation_issuer: Option,
+ /// Subject's own locale preference. Only THEY or an admin managing
+ /// them needs this — other callers use their own locale.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub preferred_locale: Option,
+ /// When the user first demonstrated control of their email. Trust
+ /// signal — meaningful to admin (auditing verification status) and
+ /// to self (own record), but not to a share picker rendering a
+ /// vignette.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub email_verified_at: Option>,
+ /// Row bookkeeping.
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+ /// Activity signal — private to the subject; admin sees it too.
+ pub last_login_at: Option>,
+ /// Account-active flag — a deactivated user couldn't reach `/me`
+ /// anyway, but admin needs to see it.
+ pub active: bool,
+ /// Storage quotas — personal financials. Admin manages others';
+ /// self sees own.
+ pub storage_quota_bytes: i64,
+ pub storage_used_bytes: i64,
+ /// TRUE when the account has a server-verifiable password
+ /// (`password_hash IS NOT NULL`). Kept off `PublicUserDto` because
+ /// per-user auth adoption leaks through directory endpoints.
+ pub has_password: bool,
+ /// TRUE when the user has an OPAQUE envelope on file.
+ pub opaque_registered: bool,
+ /// TRUE when the user has completed ≥1 successful OPAQUE login.
+ /// Distinct from `opaque_registered`: an admin can invalidate the
+ /// envelope leaving the user registered=false but with historical
+ /// migrated=true.
+ pub opaque_migrated: bool,
+}
+
+/// Self view — everything the caller may see about themselves.
+/// Returned by `/api/auth/me` and by every `AuthResponseDto` path
+/// (login / refresh / OIDC callback / magic-link redemption).
+///
+/// Composed on top of [`FullUserDto`] so `/me` and `/admin/users` share
+/// the SAME "full profile" contract for the fields both need — new
+/// self+admin-visible fields go on `FullUserDto` and both endpoints get
+/// them together. Fields here are pure self-scoped state: preferences,
+/// session-scoped flags, and caller-scoped permissions.
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct SelfUserDto {
+ /// Full profile — same shape as one row of `/api/admin/users`.
+ pub full: FullUserDto,
+ /// Opaque UI preferences bag — my own UI state. Cross-device store
+ /// for pure UI toggles (view mode, sidebar collapse, hide dotfiles,
+ /// …). The server never inspects the contents. Always present on
+ /// the wire; empty bag is `{}`, never `null`.
+ pub ui_preferences: serde_json::Value,
+ /// Whether I want share-notification emails.
+ pub notify_on_share: bool,
+ /// Session-scoped: my current session carries a DPoP thumbprint.
+ /// SPA reads this on `session.load()` to skip a redundant
+ /// `POST /api/auth/dpop/bind` when the session is already bound.
+ pub is_dpop_bound: bool,
+ /// Admin-set temp-password gate — SPA nav guard blocks everything
+ /// but `/change-password` until this flips back. Cleared by a
+ /// successful `POST /api/auth/change-password`.
+ pub force_password_change: bool,
+ /// Caller-scoped permission: can I edit my own avatar? `false` for
+ /// OIDC users whose avatar comes from the IdP. Only meaningful when
+ /// caller == subject; nonsense on any other DTO.
+ pub can_edit_image: bool,
+}
+
+impl From for PublicUserDto {
+ fn from(user: User) -> Self {
+ let role = format!("{}", user.role());
+ let p = user.into_parts();
+ Self {
+ id: p.id.to_string(),
+ username: p.username,
+ email: p.email,
+ role,
+ image: p.image,
+ is_external: p.is_external,
+ given_name: p.given_name,
+ family_name: p.family_name,
+ // Single-user paths that don't enrich presence ship `false`.
+ // List projections (admin users, sharees enriched with
+ // presence) build via FullUserDto::build below, which
+ // overrides this from UserDerivedFlags.
+ is_online: false,
+ }
+ }
+}
+
+impl FullUserDto {
+ /// Construct a `FullUserDto` from a `User` entity plus the DB-derived
+ /// flags the entity doesn't carry (`has_password`, OPAQUE flags,
+ /// `is_online`). Both are typically produced together by the users
+ /// list repo projection.
+ ///
+ /// Not a `From` impl because it takes two arguments; not a `From
+ /// <(User, UserDerivedFlags)>` because that reads awkwardly at
+ /// callsites — `FullUserDto::build(user, flags)` is clearer.
+ pub fn build(
+ user: User,
+ flags: crate::domain::repositories::user_repository::UserDerivedFlags,
+ ) -> Self {
+ let role = format!("{}", user.role());
+ let p = user.into_parts();
+ Self {
+ user: PublicUserDto {
+ id: p.id.to_string(),
+ username: p.username,
+ email: p.email,
+ role,
+ image: p.image,
+ is_external: p.is_external,
+ given_name: p.given_name,
+ family_name: p.family_name,
+ is_online: flags.is_online,
+ },
+ federation_kind: p.federation_kind.map(|k| k.as_str().to_string()),
+ federation_issuer: p.federation_issuer,
+ preferred_locale: p.preferred_locale,
+ email_verified_at: p.email_verified_at,
+ created_at: p.created_at,
+ updated_at: p.updated_at,
+ last_login_at: p.last_login_at,
+ active: p.active,
+ storage_quota_bytes: p.storage_quota_bytes,
+ storage_used_bytes: p.storage_used_bytes,
+ has_password: flags.has_password,
+ opaque_registered: flags.opaque_registered,
+ opaque_migrated: flags.opaque_migrated,
+ }
+ }
+}
+
+impl SelfUserDto {
+ /// Assemble the `/me` response from a `FullUserDto` plus the two
+ /// session-scoped booleans that can't be derived from `User` alone:
+ /// the caller's DPoP-binding state (from the JWT `cnf.jkt` claim)
+ /// and the admin-set force-password-change flag (from the auth
+ /// service's cache).
+ ///
+ /// The other self-only fields (`ui_preferences`, `notify_on_share`,
+ /// `can_edit_image`) come from `User` and are read off the entity
+ /// before it's moved into the FullUserDto; this method takes those
+ /// as explicit parameters so the caller can decide when to read
+ /// them (typically at the same point they read the DPoP-binding
+ /// state).
+ pub fn build(
+ full: FullUserDto,
+ ui_preferences: serde_json::Value,
+ notify_on_share: bool,
+ is_dpop_bound: bool,
+ force_password_change: bool,
+ can_edit_image: bool,
+ ) -> Self {
+ Self {
+ full,
+ ui_preferences,
+ notify_on_share,
+ is_dpop_bound,
+ force_password_change,
+ can_edit_image,
+ }
+ }
+}
+
+// ────────────────────────────────────────────────────────────────────────
+// End of three-layer user DTO family.
+// ────────────────────────────────────────────────────────────────────────
+
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct LoginDto {
/// Identifier the user typed. Accepts BOTH a username (no `@`) and
diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs
index 7bcb4148..3adaddc4 100644
--- a/src/domain/repositories/user_repository.rs
+++ b/src/domain/repositories/user_repository.rs
@@ -69,6 +69,51 @@ pub struct UserListEntry {
/// file without having actually logged in via OPAQUE yet (e.g.
/// admin cleared the envelope, silent-migration hasn't re-run).
pub opaque_migrated: bool,
+ /// Optional avatar payload (base64, up to 512 KiB per row). Included
+ /// on the admin list projection so the SPA can seed its per-user
+ /// `resolveUser` cache from the list row and skip the follow-up
+ /// `/api/users/{id}` fetch UserVignette would otherwise trigger.
+ /// The narrow-projection concern that motivated omitting this
+ /// column originally is retired by that cache-seeding path — the
+ /// bytes now do useful work per page load instead of being
+ /// discarded. Deferred: moving avatar storage out of the row
+ /// entirely (planned refactor); this shape is transitional.
+ pub image: Option,
+ /// Presence signal — TRUE when the server observed a request on
+ /// any of this user's non-revoked sessions within the last
+ /// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW)
+ /// (5 min). Populated via an `EXISTS(...)` subquery on
+ /// `auth.sessions` in the list projection — the partial index
+ /// `idx_sessions_last_seen_at WHERE revoked = FALSE` covers the
+ /// scan, so per-row cost is ~μs. Surfaces to the FE via
+ /// `UserDto::is_online` so both `/api/users/{id}` and the admin
+ /// listing carry it, and the admin table renders a green/grey
+ /// presence dot next to each vignette.
+ pub is_online: bool,
+}
+
+/// DB-computed booleans about a user that aren't fields on the
+/// [`User`](crate::domain::entities::user::User) entity itself —
+/// either derived from column presence (`password_hash IS NOT NULL`)
+/// or from a cross-table lookup (`auth.sessions.last_seen_at` for
+/// `is_online`). Companion to `User` on the list projection: the
+/// repo computes both, the application layer packs them into
+/// [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto).
+///
+/// Not "admin-only" — every field ends up on `FullUserDto`, which
+/// both admin AND self read. The name reflects "derived from the DB
+/// row, not intrinsic to the User entity".
+///
+/// See `docs/plan/userdto-refactor.md` for the phasing that
+/// introduces this type; it will replace [`UserListEntry`] once the
+/// list repo is switched from narrow projection to
+/// `Vec<(User, UserDerivedFlags)>` (P6 of the refactor).
+#[derive(Debug, Clone, Copy)]
+pub struct UserDerivedFlags {
+ pub has_password: bool,
+ pub opaque_registered: bool,
+ pub opaque_migrated: bool,
+ pub is_online: bool,
}
// Conversion from UserRepositoryError to DomainError
diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs
index 6feb033e..06b0fd47 100644
--- a/src/infrastructure/repositories/pg/user_pg_repository.rs
+++ b/src/infrastructure/repositories/pg/user_pg_repository.rs
@@ -858,6 +858,8 @@ impl UserRepository for UserPgRepository {
bool,
bool,
bool,
+ Option,
+ bool,
),
>(
// Auth-credential columns projected as booleans via `IS NOT
@@ -871,6 +873,21 @@ impl UserRepository for UserPgRepository {
// federation_kind / federation_issuer, the SPA derives the
// full "capability set" per user (password / OPAQUE / SSO /
// passwordless).
+ //
+ // `image` is projected too — the previous narrow projection
+ // (ROUND12 §Q1 / ROUND13 §Q1) discarded up to 512 KiB per
+ // row because the admin table never rendered it. That's now
+ // reversed: the SPA seeds its per-user `resolveUser` cache
+ // from these rows to kill the N+1 `/api/users/{id}` fetches
+ // UserVignette would otherwise trigger.
+ //
+ // `is_online` uses an EXISTS scalar subquery against
+ // `auth.sessions` — the partial index
+ // `idx_sessions_last_seen_at WHERE revoked = FALSE` covers
+ // the lookup, so per-row cost is ~μs. The window comes from
+ // `application::dtos::session_dto::ONLINE_WINDOW` (bound as
+ // `$4` seconds), same single-source-of-truth pattern the
+ // `session_liveness_gauges` module uses.
r#"
SELECT
id, username, email, role::text,
@@ -879,7 +896,14 @@ impl UserRepository for UserPgRepository {
federation_kind, federation_issuer, is_external,
(password_hash IS NOT NULL) AS has_password,
(opaque_envelope IS NOT NULL) AS opaque_registered,
- (opaque_migrated_at IS NOT NULL) AS opaque_migrated
+ (opaque_migrated_at IS NOT NULL) AS opaque_migrated,
+ image,
+ EXISTS (
+ SELECT 1 FROM auth.sessions s
+ WHERE s.user_id = auth.users.id
+ AND s.revoked = FALSE
+ AND s.last_seen_at > NOW() - make_interval(secs => $4)
+ ) AS is_online
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC, id DESC
@@ -889,6 +913,7 @@ impl UserRepository for UserPgRepository {
.bind(limit)
.bind(offset)
.bind(include_external)
+ .bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64())
.fetch_all(self.pool.as_ref())
.await
.map_err(Self::map_sqlx_error)?;
@@ -911,6 +936,8 @@ impl UserRepository for UserPgRepository {
has_password,
opaque_registered,
opaque_migrated,
+ image,
+ is_online,
)| UserListEntry {
id,
username,
@@ -930,6 +957,8 @@ impl UserRepository for UserPgRepository {
has_password,
opaque_registered,
opaque_migrated,
+ image,
+ is_online,
},
)
.collect())
From d17b3b6bd3f42a204a048a1868a87599fee47414 Mon Sep 17 00:00:00 2001
From: Edouard Vanbelle
Date: Fri, 21 Aug 2026 15:42:22 +0200
Subject: [PATCH 008/144] refactor(User): wire /api/auth/me to SelfUserDto and
/api/admin/users to FullUserDto
---
src/application/dtos/user_dto.rs | 7 +-
src/application/ports/auth_ports.rs | 30 +++
.../services/auth_application_service.rs | 130 +++++++++--
src/domain/repositories/user_repository.rs | 37 +++
.../repositories/pg/user_pg_repository.rs | 220 ++++++++++++++++++
src/interfaces/api/handlers/admin_handler.rs | 13 +-
src/interfaces/api/handlers/auth_handler.rs | 67 ++++--
.../api/handlers/magic_link_handler.rs | 2 +-
8 files changed, 461 insertions(+), 45 deletions(-)
diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs
index 3d88b344..7c8feaa6 100644
--- a/src/application/dtos/user_dto.rs
+++ b/src/application/dtos/user_dto.rs
@@ -681,7 +681,12 @@ impl UpdateProfileDto {
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AuthResponseDto {
- pub user: UserDto,
+ /// Full self view — identical shape to `/api/auth/me`. Every
+ /// login / refresh / OIDC-callback / magic-link redemption ships
+ /// this so the SPA's post-auth state matches its post-`/me` state
+ /// (no UI race between `AuthResponseDto` and the first `/me`
+ /// fetch). See `docs/plan/userdto-refactor.md` § Endpoint mapping.
+ pub user: SelfUserDto,
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs
index e7f2deff..dc75e315 100644
--- a/src/application/ports/auth_ports.rs
+++ b/src/application/ports/auth_ports.rs
@@ -123,6 +123,36 @@ pub trait UserStoragePort: Send + Sync + 'static {
/// Gets a user by ID
async fn get_user_by_id(&self, id: Uuid) -> Result;
+ /// Fetch the full `User` + [`UserDerivedFlags`] in one query. See
+ /// [`UserRepository::get_user_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::get_user_with_derived_flags)
+ /// for the contract and the rationale for the single-query shape.
+ async fn get_user_with_derived_flags(
+ &self,
+ id: Uuid,
+ ) -> Result<
+ (
+ User,
+ crate::domain::repositories::user_repository::UserDerivedFlags,
+ ),
+ DomainError,
+ >;
+
+ /// Paginated admin user listing with derived flags. See
+ /// [`UserRepository::list_users_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::list_users_with_derived_flags)
+ /// for the contract and rationale.
+ async fn list_users_with_derived_flags(
+ &self,
+ limit: i64,
+ offset: i64,
+ include_external: bool,
+ ) -> Result<
+ Vec<(
+ User,
+ crate::domain::repositories::user_repository::UserDerivedFlags,
+ )>,
+ DomainError,
+ >;
+
/// Batch-loads users by id. Order is unspecified; missing ids are
/// silently dropped. Used by group-recipient expansion in
/// `RecipientNotificationService` to avoid N+1 lookups when notifying
diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs
index e042dcee..7c8a70aa 100644
--- a/src/application/services/auth_application_service.rs
+++ b/src/application/services/auth_application_service.rs
@@ -1,6 +1,6 @@
use crate::application::dtos::user_dto::{
- AdminUserSummaryDto, AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto,
- RegisterDto, UpgradeToInternalDto, UserDto,
+ AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, RefreshTokenDto, RegisterDto,
+ SelfUserDto, UpgradeToInternalDto, UserDto,
};
use crate::application::ports::auth_ports::{
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
@@ -1320,12 +1320,16 @@ impl AuthApplicationService {
session = session.with_dpop_jkt(jkt);
}
+ // Build the SelfUserDto BEFORE `session` moves into
+ // `create_session` — the builder reads `session.dpop_jkt()`.
+ let user_id = user.id();
+ let user_dto = self.build_self_user_dto(user_id, &session).await?;
self.session_storage.create_session(session).await?;
// Authentication response
- let force_password_change = self.read_force_password_change(user.id()).await;
+ let force_password_change = self.read_force_password_change(user_id).await;
Ok(AuthResponseDto {
- user: UserDto::from(user),
+ user: user_dto,
access_token,
refresh_token,
token_type: "Bearer".to_string(),
@@ -1334,6 +1338,45 @@ impl AuthApplicationService {
})
}
+ /// Assemble a `SelfUserDto` for the given user + the session that
+ /// mints them. Called by every `AuthResponseDto` path
+ /// (login / refresh / OIDC / magic-link) so the wire shape stays
+ /// consistent across login flavours and matches what `/api/auth/me`
+ /// would return.
+ ///
+ /// Costs one wide SELECT (`get_user_with_derived_flags`) even when
+ /// the caller already has a `User` in hand — acceptable because
+ /// `/login`, `/refresh`, and the OIDC/magic-link callbacks are
+ /// not hot inner loops. In exchange the composition stays uniform
+ /// across all four callsites and OPAQUE / `is_online` flags land
+ /// on the wire without a second lookup at each site.
+ ///
+ /// `is_dpop_bound` is derived from the session's own DPoP
+ /// thumbprint — the session was just constructed, so this reads
+ /// exactly the binding that will govern subsequent requests.
+ async fn build_self_user_dto(
+ &self,
+ user_id: Uuid,
+ session: &crate::domain::entities::session::Session,
+ ) -> Result {
+ let (user, flags) =
+ UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await?;
+ let can_edit_image = !user.is_oidc_user();
+ let ui_preferences = user.ui_preferences().clone();
+ let notify_on_share = user.notify_on_share();
+ let force_password_change = self.read_force_password_change(user_id).await;
+ let is_dpop_bound = session.dpop_jkt().is_some();
+ let full = FullUserDto::build(user, flags);
+ Ok(SelfUserDto::build(
+ full,
+ ui_preferences,
+ notify_on_share,
+ is_dpop_bound,
+ force_password_change,
+ can_edit_image,
+ ))
+ }
+
/// Read `force_password_change_at_next_login` for the given user,
/// with fail-open semantics on repo error (returns `false` and
/// logs a warn). Every callsite that builds an `AuthResponseDto`
@@ -1586,22 +1629,29 @@ impl AuthApplicationService {
let access_token =
self.token_service
.generate_access_token(&user, Some(session.id()), None)?;
+ // Snapshot fields still needed for logging + DTO before
+ // `session` and `user` are consumed by the storage call and
+ // the DTO builder below.
+ let user_id = user.id();
+ let user_display = user.display_for_audit().to_string();
+ let is_external = user.is_external();
+ let user_dto = self.build_self_user_dto(user_id, &session).await?;
self.session_storage.create_session(session).await?;
tracing::info!(
target: "audit",
event = "magic_link.redeemed",
- user_id = %user.id(),
- username = %user.display_for_audit(),
- is_external = user.is_external(),
+ user_id = %user_id,
+ username = %user_display,
+ is_external = is_external,
resource_kind = ?mlt.resource_kind(),
resource_id = ?mlt.resource_id(),
cross_browser_confirmed = cross_browser_confirmed,
);
- let force_password_change = self.read_force_password_change(user.id()).await;
+ let force_password_change = self.read_force_password_change(user_id).await;
let auth = AuthResponseDto {
- user: UserDto::from(user),
+ user: user_dto,
access_token,
refresh_token,
token_type: "Bearer".to_string(),
@@ -1789,6 +1839,12 @@ impl AuthApplicationService {
session.dpop_jkt(),
)?;
+ // Build the SelfUserDto before `new_session` is consumed by
+ // the rotate call — the builder reads `session.dpop_jkt()`
+ // to compute `is_dpop_bound`.
+ let user_id = user.id();
+ let user_dto = self.build_self_user_dto(user_id, &new_session).await?;
+
self.session_storage
.rotate_session(session.id(), new_session)
.await?;
@@ -1798,9 +1854,9 @@ impl AuthApplicationService {
// initial login. The SPA's post-refresh flow (silent, on
// its own timer) can then route the user to change-password
// without waiting for an explicit re-login.
- let force_password_change = self.read_force_password_change(user.id()).await;
+ let force_password_change = self.read_force_password_change(user_id).await;
Ok(AuthResponseDto {
- user: UserDto::from(user),
+ user: user_dto,
access_token,
refresh_token: new_refresh_token,
token_type: "Bearer".to_string(),
@@ -2871,6 +2927,26 @@ impl AuthApplicationService {
UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await
}
+ /// Load the full `User` entity + `UserDerivedFlags` for the given
+ /// id in ONE query. See
+ /// [`UserRepository::get_user_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::get_user_with_derived_flags)
+ /// for the shape and the SELECT that drives it. Used by
+ /// `/api/auth/me` to build a `SelfUserDto` and by future admin
+ /// single-user views to build a `FullUserDto` without paying two
+ /// round-trips.
+ pub async fn get_user_with_derived_flags(
+ &self,
+ user_id: Uuid,
+ ) -> Result<
+ (
+ crate::domain::entities::user::User,
+ crate::domain::repositories::user_repository::UserDerivedFlags,
+ ),
+ DomainError,
+ > {
+ UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await
+ }
+
/// Login-style identifier lookup: dispatches on `@` in the input
/// (email path when present, username path when not), identical
/// to `login()`'s dispatch. Exposed so the OPAQUE login handler
@@ -3145,22 +3221,30 @@ impl AuthApplicationService {
Ok(users.into_iter().map(UserDto::from).collect())
}
- /// Admin-only compact listing. The detail endpoint retains the complete
- /// [`UserDto`]; this path projects only what the management table renders so
- /// PostgreSQL never detoasts or transfers avatars/preferences for a page.
+ /// Admin-only user listing. Returns `Vec` — same
+ /// `FullUserDto` shape [`SelfUserDto`] embeds, so the FE reads
+ /// admin table rows and `/me` responses through identical field
+ /// paths. Includes the avatar (`user.image`) and presence
+ /// (`user.is_online`) so the admin table renders the vignette +
+ /// green dot without per-row follow-up fetches to
+ /// `/api/users/{id}` (the N+1 that motivated the widening — see
+ /// `docs/plan/userdto-refactor.md` § N+1).
pub async fn list_user_summaries_including_external_with_perms(
&self,
authorization: &A,
caller_id: Uuid,
limit: i64,
offset: i64,
- ) -> Result, DomainError> {
+ ) -> Result, DomainError> {
self.require_admin_caller(authorization, caller_id).await?;
- let users = self
+ let rows = self
.user_storage
- .list_user_summaries(limit, offset, true)
+ .list_users_with_derived_flags(limit, offset, true)
.await?;
- Ok(users.into_iter().map(AdminUserSummaryDto::from).collect())
+ Ok(rows
+ .into_iter()
+ .map(|(user, flags)| FullUserDto::build(user, flags))
+ .collect())
}
/// Service-layer gate for administrator-scoped user-directory operations.
@@ -4654,11 +4738,17 @@ impl AuthApplicationService {
let access_token =
self.token_service
.generate_access_token(&user, Some(session.id()), None)?;
+ // Build the SelfUserDto before `session` is consumed by the
+ // storage call — the builder reads `session.dpop_jkt()`
+ // (None here since OIDC callbacks land unbound and the SPA
+ // finishes binding post-redirect).
+ let user_id = user.id();
+ let user_dto = self.build_self_user_dto(user_id, &session).await?;
self.session_storage.create_session(session).await?;
- let force_password_change = self.read_force_password_change(user.id()).await;
+ let force_password_change = self.read_force_password_change(user_id).await;
let auth_response = AuthResponseDto {
- user: UserDto::from(user),
+ user: user_dto,
access_token,
refresh_token,
token_type: "Bearer".to_string(),
diff --git a/src/domain/repositories/user_repository.rs b/src/domain/repositories/user_repository.rs
index 3adaddc4..9bc6bec0 100644
--- a/src/domain/repositories/user_repository.rs
+++ b/src/domain/repositories/user_repository.rs
@@ -139,6 +139,20 @@ pub trait UserRepository: Send + Sync + 'static {
/// Gets a user by ID
async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult;
+ /// Fetch the full `User` entity + the [`UserDerivedFlags`] in a
+ /// single query. Used by `/api/auth/me` and future admin single-user
+ /// views — anywhere the caller needs both the row itself AND the
+ /// derived booleans (`has_password`, OPAQUE flags, `is_online`) to
+ /// build a [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto)
+ /// or [`SelfUserDto`](crate::application::dtos::user_dto::SelfUserDto).
+ /// Single query is cheaper than `get_user_by_id` + separate lookups
+ /// for OPAQUE state + `is_online`; the EXISTS subquery is cheap
+ /// thanks to the partial index `idx_sessions_last_seen_at`.
+ async fn get_user_with_derived_flags(
+ &self,
+ id: Uuid,
+ ) -> UserRepositoryResult<(User, UserDerivedFlags)>;
+
/// Batch-loads a set of users by id, preserving no particular order
/// and silently skipping ids that don't match any row. Caller is
/// responsible for de-duplicating the input vec. Returns an empty
@@ -200,6 +214,12 @@ pub trait UserRepository: Send + Sync + 'static {
/// Lists the columns needed by compact user-management tables. Unlike
/// [`Self::list_users`], this never fetches password hashes, OIDC subjects,
/// avatars, names, locale state, or UI preferences.
+ ///
+ /// **Deprecated** — [`Self::list_users_with_derived_flags`] supersedes
+ /// this: it returns the full `User` entity + [`UserDerivedFlags`] so
+ /// the application layer can build [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto)
+ /// directly. Kept only until P6 of `docs/plan/userdto-refactor.md`
+ /// removes `UserListEntry` + the last remaining caller.
async fn list_user_summaries(
&self,
limit: i64,
@@ -207,6 +227,23 @@ pub trait UserRepository: Send + Sync + 'static {
include_external: bool,
) -> UserRepositoryResult>;
+ /// Paginated admin user listing — full `User` entity + the derived
+ /// booleans (`has_password`, OPAQUE flags, `is_online`) in one wide
+ /// SELECT. Called by the admin service to build
+ /// `Vec` for `/api/admin/users` without paying two
+ /// round-trips per row (once for User, once for derived flags).
+ ///
+ /// Same `include_external` semantics as [`Self::list_users`]:
+ /// admin management UI passes `true`; every other caller passes
+ /// `false` so external / grant-only users stay off internal-user
+ /// surfaces.
+ async fn list_users_with_derived_flags(
+ &self,
+ limit: i64,
+ offset: i64,
+ include_external: bool,
+ ) -> UserRepositoryResult>;
+
/// Searches users by username or email (SQL ILIKE) with a limit.
/// See [`list_users`] for the meaning of `include_external`.
async fn search_users(
diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs
index 06b0fd47..f8d50fda 100644
--- a/src/infrastructure/repositories/pg/user_pg_repository.rs
+++ b/src/infrastructure/repositories/pg/user_pg_repository.rs
@@ -388,6 +388,90 @@ impl UserRepository for UserPgRepository {
))
}
+ async fn get_user_with_derived_flags(
+ &self,
+ id: Uuid,
+ ) -> UserRepositoryResult<(
+ User,
+ crate::domain::repositories::user_repository::UserDerivedFlags,
+ )> {
+ // Same column set as `get_user_by_id` plus the three IS-NOT-NULL
+ // derivations for auth-capability flags AND the EXISTS scalar
+ // for `is_online`. The `interval` argument is bound as `$2`
+ // (seconds, `ONLINE_WINDOW.as_secs_f64()`) via
+ // `make_interval(secs => $2)` — same pattern as
+ // `session_liveness_gauges.rs`. Partial index
+ // `idx_sessions_last_seen_at WHERE revoked = FALSE` covers the
+ // EXISTS scan, so per-row cost is ~μs.
+ let row = sqlx::query(
+ r#"
+ SELECT
+ id, username, email, password_hash, role::text as role_text,
+ storage_quota_bytes, storage_used_bytes,
+ created_at, updated_at, last_login_at, active,
+ federation_kind, federation_issuer, federation_subject, image, is_external,
+ given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
+ ui_preferences,
+ (password_hash IS NOT NULL) AS has_password,
+ (opaque_envelope IS NOT NULL) AS opaque_registered,
+ (opaque_migrated_at IS NOT NULL) AS opaque_migrated,
+ EXISTS (
+ SELECT 1 FROM auth.sessions s
+ WHERE s.user_id = auth.users.id
+ AND s.revoked = FALSE
+ AND s.last_seen_at > NOW() - make_interval(secs => $2)
+ ) AS is_online
+ FROM auth.users
+ WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64())
+ .fetch_one(&*self.pool)
+ .await
+ .map_err(Self::map_sqlx_error)?;
+
+ let role_str: Option = row.try_get("role_text").unwrap_or(None);
+ let role = match role_str.as_deref() {
+ Some("admin") => UserRole::Admin,
+ _ => UserRole::User,
+ };
+
+ let user = User::from_data_full(
+ row.get("id"),
+ row.get("username"),
+ row.get("email"),
+ row.get("password_hash"),
+ role,
+ row.get("storage_quota_bytes"),
+ row.get("storage_used_bytes"),
+ row.get("created_at"),
+ row.get("updated_at"),
+ row.get("last_login_at"),
+ row.get("active"),
+ row.get::