Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate auth, sso, embed to slices sso (State<Config>, callback +PgPool; SSO protocol `state` string untouched), embed (State<PgPool> + State<Config>; fetch_item_embed_context helper narrowed), auth logout_handler (PgPool + AppCaches) and validate_username (PgPool). auth login_handler/passkey_auth_start/passkey_auth_finish kept on State<AppState> (call the shared &AppState auth::maybe_send_login_notification helper and use state.webauthn). No behavior change; auth 82 + login 20 + sso 3 + embed 18 + join 9 + lockout 4 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-14 12:12 UTC
Commit: ea707db99f7a1d44a1d085807fa1006f5db91a52
Parent: 10d71a8
5 files changed, +53 insertions, -40 deletions
@@ -21,8 +21,9 @@ use crate::{
21 21 error::{AppError, Result, ResultExt},
22 22 helpers::{is_htmx_request, rate_limiter_ms, rate_limiter_per_sec, spawn_email},
23 23 templates::*,
24 - AppState,
24 + AppCaches, AppState,
25 25 };
26 + use sqlx::PgPool;
26 27 use webauthn_rs::prelude::*;
27 28
28 29 /// Pre-computed Argon2id hash for timing-safe user-not-found responses.
@@ -292,7 +293,8 @@ async fn login_handler(
292 293 /// Log out the current user and redirect to the landing page.
293 294 #[tracing::instrument(skip_all, name = "auth::logout")]
294 295 async fn logout_handler(
295 - State(state): State<AppState>,
296 + State(db): State<PgPool>,
297 + State(caches): State<AppCaches>,
296 298 session: Session,
297 299 ) -> Result<impl IntoResponse> {
298 300 // Clean up tracking row before flushing session. We require the
@@ -303,10 +305,10 @@ async fn logout_handler(
303 305 let session_user = session.get::<crate::auth::SessionUser>("user").await.ok().flatten();
304 306 if let Ok(Some(tracking_id)) = session.get::<UserSessionId>(SESSION_TRACKING_KEY).await {
305 307 if let Some(ref u) = session_user
306 - && let Err(e) = db::sessions::delete_session_by_id(&state.db, tracking_id, u.id).await {
308 + && let Err(e) = db::sessions::delete_session_by_id(&db, tracking_id, u.id).await {
307 309 tracing::warn!(tracking_id = %tracking_id, error = ?e, "failed to delete session tracking row on logout");
308 310 }
309 - state.caches.session_cache.remove(&tracking_id);
311 + caches.session_cache.remove(&tracking_id);
310 312 }
311 313 logout_user(&session).await?;
312 314 Ok(Redirect::to("/"))
@@ -328,7 +330,7 @@ pub struct ValidateUsernameForm {
328 330 /// Check username availability and format, returning an HTMX status snippet.
329 331 #[tracing::instrument(skip_all, name = "auth::validate_username")]
330 332 async fn validate_username(
331 - State(state): State<AppState>,
333 + State(db): State<PgPool>,
332 334 Form(form): Form<ValidateUsernameForm>,
333 335 ) -> crate::error::Result<Html<String>> {
334 336 // Count characters, not bytes — Username::new uses chars().count() too,
@@ -373,7 +375,7 @@ async fn validate_username(
373 375 // Treat a DB error as "unavailable, retry" rather than "available". Failing
374 376 // open here previously let users proceed past a transient lookup error and
375 377 // hit a confusing signup-side rejection or race.
376 - match db::users::get_user_by_username(&state.db, &username).await {
378 + match db::users::get_user_by_username(&db, &username).await {
377 379 Ok(Some(_)) => Ok(Html(UsernameStatusTemplate { available: false }.render_string()?)),
378 380 Ok(None) => Ok(Html(UsernameStatusTemplate { available: true }.render_string()?)),
379 381 Err(e) => {
@@ -8,10 +8,11 @@ use axum::{
8 8 use serde::Deserialize;
9 9
10 10 use crate::{
11 + config::Config,
11 12 db::{self, ItemId},
12 13 error::{AppError, Result},
13 - AppState,
14 14 };
15 + use sqlx::PgPool;
15 16
16 17 /// Shared context fetched for all item embeds.
17 18 struct ItemEmbedContext {
@@ -29,8 +30,8 @@ struct ItemEmbedContext {
29 30 has_audio: bool,
30 31 }
31 32
32 - async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result<ItemEmbedContext> {
33 - let item = db::items::get_item_by_id(&state.db, item_id)
33 + async fn fetch_item_embed_context(db: &PgPool, config: &Config, item_id: ItemId) -> Result<ItemEmbedContext> {
34 + let item = db::items::get_item_by_id(db, item_id)
34 35 .await?
35 36 .ok_or(AppError::NotFound)?;
36 37
@@ -38,11 +39,11 @@ async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result<I
38 39 return Err(AppError::NotFound);
39 40 }
40 41
41 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
42 + let project = db::projects::get_project_by_id(db, item.project_id)
42 43 .await?
43 44 .ok_or(AppError::NotFound)?;
44 45
45 - let user = db::users::get_user_by_id(&state.db, project.user_id)
46 + let user = db::users::get_user_by_id(db, project.user_id)
46 47 .await?
47 48 .ok_or(AppError::NotFound)?;
48 49
@@ -61,7 +62,7 @@ async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result<I
61 62 (crate::formatting::format_price(item.price_cents), "Buy".to_string())
62 63 };
63 64
64 - let purchase_url = format!("{}/buy/{}", state.config.host_url, item_id);
65 + let purchase_url = format!("{}/buy/{}", config.host_url, item_id);
65 66
66 67 let description_excerpt = item.description.as_deref()
67 68 .unwrap_or("")
@@ -100,10 +101,11 @@ pub(super) fn set_embed_headers(response: &mut Response) {
100 101 #[tracing::instrument(skip_all, name = "embed::item_button")]
101 102 /// GET /embed/i/{item_id}/button
102 103 pub(super) async fn item_button(
103 - State(state): State<AppState>,
104 + State(db): State<PgPool>,
105 + State(config): State<Config>,
104 106 Path(item_id): Path<ItemId>,
105 107 ) -> Result<Response> {
106 - let ctx = fetch_item_embed_context(&state, item_id).await?;
108 + let ctx = fetch_item_embed_context(&db, &config, item_id).await?;
107 109
108 110 let mut response = crate::templates::EmbedItemButtonTemplate {
109 111 title: ctx.title,
@@ -127,15 +129,16 @@ pub(super) struct CardQuery {
127 129 #[tracing::instrument(skip_all, name = "embed::item_card")]
128 130 /// GET /embed/i/{item_id}/card
129 131 pub(super) async fn item_card(
130 - State(state): State<AppState>,
132 + State(db): State<PgPool>,
133 + State(config): State<Config>,
131 134 Path(item_id): Path<ItemId>,
132 135 Query(query): Query<CardQuery>,
133 136 ) -> Result<Response> {
134 - let ctx = fetch_item_embed_context(&state, item_id).await?;
137 + let ctx = fetch_item_embed_context(&db, &config, item_id).await?;
135 138 let layout = query.layout.as_deref().unwrap_or("vertical");
136 139 let is_horizontal = layout == "horizontal";
137 140
138 - let profile_url = format!("{}/u/{}", state.config.host_url, ctx.creator_username);
141 + let profile_url = format!("{}/u/{}", config.host_url, ctx.creator_username);
139 142
140 143 let mut response = crate::templates::EmbedItemCardTemplate {
141 144 title: ctx.title,
@@ -160,10 +163,11 @@ pub(super) async fn item_card(
160 163 /// Audio preview player embed. Returns 404 for non-audio items.
161 164 #[tracing::instrument(skip_all, name = "embed::item_player")]
162 165 pub(super) async fn item_player(
163 - State(state): State<AppState>,
166 + State(db): State<PgPool>,
167 + State(config): State<Config>,
164 168 Path(item_id): Path<ItemId>,
165 169 ) -> Result<Response> {
166 - let ctx = fetch_item_embed_context(&state, item_id).await?;
170 + let ctx = fetch_item_embed_context(&db, &config, item_id).await?;
167 171
168 172 if !ctx.has_audio {
169 173 return Err(AppError::NotFound);
@@ -171,7 +175,7 @@ pub(super) async fn item_player(
171 175
172 176 // Preview URL would be /embed/i/{item_id}/preview.mp3 once ffmpeg generation is built.
173 177 // For now, link to the item page — the player embed is a stub until preview generation ships.
174 - let preview_url = format!("{}/api/stream/{}", state.config.host_url, item_id);
178 + let preview_url = format!("{}/api/stream/{}", config.host_url, item_id);
175 179
176 180 let mut response = crate::templates::EmbedItemPlayerTemplate {
177 181 title: ctx.title,
@@ -6,24 +6,26 @@ use axum::{
6 6 };
7 7
8 8 use crate::{
9 + config::Config,
9 10 db::{self},
10 11 error::{AppError, Result},
11 - AppState,
12 12 };
13 + use sqlx::PgPool;
13 14
14 15 use super::item::set_embed_headers;
15 16
16 17 #[tracing::instrument(skip_all, name = "embed::project_card")]
17 18 /// GET /embed/p/{project_slug}/card
18 19 pub(super) async fn project_card(
19 - State(state): State<AppState>,
20 + State(db): State<PgPool>,
21 + State(config): State<Config>,
20 22 Path(project_slug): Path<String>,
21 23 ) -> Result<Response> {
22 - let project = db::projects::get_public_project_by_slug_str(&state.db, &project_slug)
24 + let project = db::projects::get_public_project_by_slug_str(&db, &project_slug)
23 25 .await?
24 26 .ok_or(AppError::NotFound)?;
25 27
26 - let user = db::users::get_user_by_id(&state.db, project.user_id)
28 + let user = db::users::get_user_by_id(&db, project.user_id)
27 29 .await?
28 30 .ok_or(AppError::NotFound)?;
29 31
@@ -31,7 +33,7 @@ pub(super) async fn project_card(
31 33 return Err(AppError::NotFound);
32 34 }
33 35
34 - let items = db::items::get_public_items_by_project(&state.db, project.id).await?;
36 + let items = db::items::get_public_items_by_project(&db, project.id).await?;
35 37 let item_count = items.len();
36 38
37 39 let description_excerpt: String = project.description.as_deref()
@@ -40,8 +42,8 @@ pub(super) async fn project_card(
40 42 .take(150)
41 43 .collect();
42 44
43 - let project_url = format!("{}/p/{}", state.config.host_url, project_slug);
44 - let profile_url = format!("{}/u/{}", state.config.host_url, user.username);
45 + let project_url = format!("{}/p/{}", config.host_url, project_slug);
46 + let profile_url = format!("{}/u/{}", config.host_url, user.username);
45 47 let creator_name = user.display_name.as_deref().unwrap_or(&user.username);
46 48 let category_label = project.project_type.label();
47 49
@@ -6,21 +6,23 @@ use axum::{
6 6 };
7 7
8 8 use crate::{
9 + config::Config,
9 10 db::{self, Username},
10 11 error::{AppError, Result},
11 - AppState,
12 12 };
13 + use sqlx::PgPool;
13 14
14 15 use super::item::set_embed_headers;
15 16
16 17 #[tracing::instrument(skip_all, name = "embed::tip_button")]
17 18 /// GET /embed/u/{username}/tip
18 19 pub(super) async fn tip_button(
19 - State(state): State<AppState>,
20 + State(db): State<PgPool>,
21 + State(config): State<Config>,
20 22 Path(username): Path<String>,
21 23 ) -> Result<Response> {
22 24 let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
23 - let user = db::users::get_user_by_username(&state.db, &username)
25 + let user = db::users::get_user_by_username(&db, &username)
24 26 .await?
25 27 .ok_or(AppError::NotFound)?;
26 28
@@ -29,7 +31,7 @@ pub(super) async fn tip_button(
29 31 }
30 32
31 33 let display_name = user.display_name.as_deref().unwrap_or(&user.username).to_string();
32 - let tip_url = format!("{}/u/{}/tip", state.config.host_url, user.username);
34 + let tip_url = format!("{}/u/{}/tip", config.host_url, user.username);
33 35
34 36 let mut response = crate::templates::EmbedTipButtonTemplate {
35 37 display_name,
@@ -26,10 +26,12 @@ use tower_sessions::Session;
26 26
27 27 use crate::{
28 28 auth::{login_user, track_session, SessionUser},
29 + config::Config,
29 30 db::{self, UserId},
30 31 error::{AppError, Result},
31 32 AppState,
32 33 };
34 + use sqlx::PgPool;
33 35
34 36 const SSO_STATE_KEY: &str = "sso_state";
35 37 const SSO_VERIFIER_KEY: &str = "sso_pkce_verifier";
@@ -43,8 +45,8 @@ fn b64url(data: &[u8]) -> String {
43 45 /// Generates PKCE + state, stashes them in the session, and redirects to the
44 46 /// provider's authorize endpoint. No-op (404-ish redirect home) when SSO is off.
45 47 #[tracing::instrument(skip_all, name = "sso::login")]
46 - async fn sso_login(State(state): State<AppState>, session: Session) -> Result<Response> {
47 - let Some(sso) = state.config.sso.as_ref() else {
48 + async fn sso_login(State(config): State<Config>, session: Session) -> Result<Response> {
49 + let Some(sso) = config.sso.as_ref() else {
48 50 // SSO not configured — nothing to delegate to.
49 51 return Ok(Redirect::to("/login").into_response());
50 52 };
@@ -63,7 +65,7 @@ async fn sso_login(State(state): State<AppState>, session: Session) -> Result<Re
63 65 session.insert(SSO_STATE_KEY, &state_param).await.map_err(|e| AppError::Internal(e.into()))?;
64 66 session.insert(SSO_VERIFIER_KEY, &verifier).await.map_err(|e| AppError::Internal(e.into()))?;
65 67
66 - let redirect_uri = format!("{}/sso/callback", state.config.host_url);
68 + let redirect_uri = format!("{}/sso/callback", config.host_url);
67 69 let authorize = format!(
68 70 "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256",
69 71 sso.provider_url,
@@ -91,12 +93,13 @@ struct TokenResponse {
91 93 /// GET /sso/callback — provider redirected back with `code` + `state`.
92 94 #[tracing::instrument(skip_all, name = "sso::callback")]
93 95 async fn sso_callback(
94 - State(state): State<AppState>,
96 + State(db): State<PgPool>,
97 + State(config): State<Config>,
95 98 session: Session,
96 99 headers: HeaderMap,
97 100 Query(q): Query<CallbackQuery>,
98 101 ) -> Result<Response> {
99 - let Some(sso) = state.config.sso.as_ref() else {
102 + let Some(sso) = config.sso.as_ref() else {
100 103 return Ok(Redirect::to("/login").into_response());
101 104 };
102 105
@@ -125,7 +128,7 @@ async fn sso_callback(
125 128 }
126 129
127 130 // Exchange the code at the provider's token endpoint.
128 - let redirect_uri = format!("{}/sso/callback", state.config.host_url);
131 + let redirect_uri = format!("{}/sso/callback", config.host_url);
129 132 let resp = crate::helpers::HTTP_CLIENT
130 133 .post(format!("{}/oauth/token", sso.provider_url))
131 134 .timeout(std::time::Duration::from_secs(10))
@@ -161,7 +164,7 @@ async fn sso_callback(
161 164 };
162 165
163 166 // Map the verified provider user id onto our mirrored account.
164 - let db_user = match db::users::get_user_by_id(&state.db, token.user_id).await? {
167 + let db_user = match db::users::get_user_by_id(&db, token.user_id).await? {
165 168 Some(u) => u,
166 169 None => return fail("This account can't sign in here."),
167 170 };
@@ -170,9 +173,9 @@ async fn sso_callback(
170 173 }
171 174
172 175 let user_id = db_user.id;
173 - let session_user = SessionUser::from_db_user(db_user, &state.db, state.config.admin_user_id).await;
176 + let session_user = SessionUser::from_db_user(db_user, &db, config.admin_user_id).await;
174 177 login_user(&session, session_user).await?;
175 - track_session(&session, &state.db, user_id, &headers).await?;
178 + track_session(&session, &db, user_id, &headers).await?;
176 179
177 180 Ok(Redirect::to("/").into_response())
178 181 }