• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divviup / divviup-api / 25874010908

14 May 2026 05:09PM UTC coverage: 58.423% (+1.2%) from 57.254%
25874010908

push

github

web-flow
Migrate from Trillium [part 8]: remove Trillium server, serve Axum directly (#2251)

Axum is now the primary HTTP listener. The Trillium server, which was a pure pass-through proxy since Part 7C, is removed from the request path.

Production changes:
- `build_app(Config) -> BuiltApp` replaces `DivviupApi` for production use
- `bin.rs` rewritten: Axum serves directly, monitoring server is a separate Axum router, graceful shutdown via tokio signals + CancellationToken
- `Queue` uses `CancellationToken` instead of Trillium's `Stopper`/`CloneCounterObserver`
- `telemetry.rs` and `trace.rs` handlers converted from Trillium to Axum
- `Config` gains `listen_address` field (from HOST/PORT env vars, default [::]:8080)
- Replace the Trillium-based static asset handler (`trillium-static-compiled` + `OriginRouter`) with an Axum middleware using `tower-http`'s `ServeDir` and `ServeFile`.

The middleware intercepts requests whose `Host` (or `X-Forwarded-Host`) matches the configured `app_url` and serves the React SPA with appropriate `cache-control` headers (`max-age=1year` for `/assets/*`, `no-cache` for everything else). Unmatched hosts pass through to the API routes.

Dead code removal:
- Deleted: `handler/logger.rs`, `handler/opentelemetry.rs`, `axum_proxy` test
- Removed `FromConn` impls from: `Db`, `User`, `PermissionsActor`, `AccountBearerToken`
- Removed Trillium `Handler` impls from: `Db`, `ErrorHandler`, `CorsHeaders`, `ReplaceMimeTypes`, `SessionStore`
- Unified `PermissionsActor::is_allowed`/`if_allowed` to use `http::Method`
- Removed deps: `trillium-compression`, `trillium-conn-id`, `trillium-forwarding`, `trillium-opentelemetry`, `trillium-prometheus`, `trillium-redirect`, `trillium-sessions`, `trillium-cookies`, `async-session`
- Added deps: `cookie` (key-expansion feature, for session key derivation)

`DivviupApi` is kept as a thin test-only shim that spawns Axum on IPv6 Localhost and proxies via the existing `AxumProxy`, preserving test-support co... (continued)

112 of 227 new or added lines in 14 files covered. (49.34%)

8 existing lines in 4 files now uncovered.

4335 of 7420 relevant lines covered (58.42%)

63.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

52.17
/src/handler/session_store.rs
1
use crate::{config::SessionSecrets, entity::session, Db};
2
use async_trait::async_trait;
3
use sea_orm::{
4
    sea_query::{any, OnConflict},
5
    ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter,
6
};
7
use std::collections::HashMap;
8
use time::{Duration, OffsetDateTime};
9
use tower_sessions::{
10
    cookie::{Key, SameSite},
11
    service::SignedCookie,
12
    session::{Id as TowerSessionId, Record},
13
    session_store as tower_store, Expiry, SessionManagerLayer,
14
};
15

16
/// Cookie name used by the session middleware.
17
pub const SESSION_COOKIE_NAME: &str = "divviup.sid";
18

19
/// Database-backed session store for [`tower-sessions`].
20
///
21
/// Sessions are stored in the `sessions` table. Expired sessions are
22
/// cleaned up by the [`SessionCleanup`](crate::queue::SessionCleanup)
23
/// queue job rather than by the store itself.
24
#[derive(Debug, Clone)]
25
pub struct TowerSessionStore {
26
    db: Db,
27
}
28

29
impl TowerSessionStore {
30
    pub fn new(db: Db) -> Self {
277✔
31
        Self { db }
277✔
32
    }
277✔
33
}
34

35
/// Build the Axum-side [`SessionManagerLayer`].
36
///
37
/// `tower-sessions` 0.15 accepts only a single signing key; there is no
38
/// `with_older_secrets` equivalent. Older secrets in the config are
39
/// parsed but ignored — see <https://github.com/divviup/divviup-api/issues/2252>.
40
pub fn axum_session_layer(
277✔
41
    db: Db,
277✔
42
    secrets: &SessionSecrets,
277✔
43
) -> SessionManagerLayer<TowerSessionStore, SignedCookie> {
277✔
44
    if !secrets.older.is_empty() {
277✔
NEW
45
        tracing::warn!(
×
NEW
46
            count = secrets.older.len(),
×
47
            "SESSION_SECRETS contains older keys that are ignored — \
48
             session cookie rotation is not yet supported, see \
49
             https://github.com/divviup/divviup-api/issues/2252"
50
        );
51
    }
277✔
52

53
    // `cookie::Key::from` panics on keys shorter than 64 bytes. We only
54
    // guarantee 32, so derive a longer key from the configured secret.
55
    let key = Key::derive_from(&secrets.current);
277✔
56
    SessionManagerLayer::new(TowerSessionStore::new(db))
277✔
57
        .with_name(SESSION_COOKIE_NAME)
277✔
58
        .with_secure(true)
277✔
59
        .with_http_only(true)
277✔
60
        .with_same_site(SameSite::Lax)
277✔
61
        .with_path("/")
277✔
62
        .with_signed(key)
277✔
63
        .with_expiry(Expiry::OnInactivity(Duration::days(1)))
277✔
64
}
277✔
65

66
#[async_trait]
67
impl tower_store::SessionStore for TowerSessionStore {
68
    async fn save(&self, record: &Record) -> tower_store::Result<()> {
2✔
69
        let model = session::Model {
1✔
70
            id: record.id.to_string(),
1✔
71
            expiry: Some(record.expiry_date),
1✔
72
            data: serde_json::to_value(&record.data)
1✔
73
                .map_err(|e| tower_store::Error::Encode(e.to_string()))?,
1✔
74
        };
75

76
        session::Entity::insert(model.into_active_model())
1✔
77
            .on_conflict(
1✔
78
                OnConflict::column(session::Column::Id)
1✔
79
                    .update_columns([session::Column::Data, session::Column::Expiry])
1✔
80
                    .clone(),
1✔
81
            )
1✔
82
            .exec(&self.db)
1✔
83
            .await
1✔
84
            .map_err(|e| tower_store::Error::Backend(e.to_string()))?;
1✔
85

86
        Ok(())
1✔
87
    }
2✔
88

89
    async fn load(&self, session_id: &TowerSessionId) -> tower_store::Result<Option<Record>> {
×
90
        let model = session::Entity::find_by_id(session_id.to_string())
×
91
            .filter(any![
×
92
                session::Column::Expiry.is_null(),
×
93
                session::Column::Expiry.gt(OffsetDateTime::now_utc())
×
94
            ])
×
95
            .one(&self.db)
×
96
            .await
×
97
            .map_err(|e| tower_store::Error::Backend(e.to_string()))?;
×
98

99
        model
×
100
            .map(|m| {
×
101
                let data: HashMap<String, serde_json::Value> = serde_json::from_value(m.data)
×
102
                    .map_err(|e| tower_store::Error::Decode(e.to_string()))?;
×
103
                let id: TowerSessionId = m.id.parse().map_err(|e: base64::DecodeSliceError| {
×
104
                    tower_store::Error::Decode(e.to_string())
×
105
                })?;
×
106
                Ok(Record {
107
                    id,
×
108
                    data,
×
109
                    expiry_date: m.expiry.unwrap_or_else(|| {
×
110
                        // The DB allows null expiry, but tower-sessions requires a
111
                        // value. Use a far-future sentinel.
112
                        OffsetDateTime::now_utc() + time::Duration::weeks(52)
×
113
                    }),
×
114
                })
115
            })
×
116
            .transpose()
×
117
    }
×
118

119
    async fn delete(&self, session_id: &TowerSessionId) -> tower_store::Result<()> {
×
120
        session::Entity::delete_by_id(session_id.to_string())
×
121
            .exec(&self.db)
×
122
            .await
×
123
            .map_err(|e| tower_store::Error::Backend(e.to_string()))?;
×
124
        Ok(())
×
125
    }
×
126
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc