auth: make dev sign-in a feature, require both headers
A `dev` line in a deployed config would have let anyone in as the first user; as a cargo feature the bypass is absent from release builds, so the Fly demos now build their own image with it instead of running the release. Signing in without a slug renamed the user to their login prefix, so a missing X-User-Slug is now a 401 like a missing Remote-User.

Assisted-by: Claude Opus 5.5 via Claude Code
change zunxzzstpyvnqsxlwxwyppyppvmxlwoz
commit 7fd6b53e64d596e4eba7e29f3206adb86c547e4f
author Alpha Chen <alpha@kejadlen.dev>
date
parent slwluuyx
diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml
index 73514f8..10f7def 100644
--- a/.cargo/mutants.toml
+++ b/.cargo/mutants.toml
@@ -1,3 +1,5 @@
 # Skip ignored files when copying the tree: ramekin bind-mounts a
 # character device over the direnv file, which aborts the copy.
 gitignore = true
+# Mutate the dev-only code too.
+all_features = true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7553235..9b8853f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,7 +53,6 @@ jobs:
       packages: write # push container image to GHCR
     outputs:
       version: ${{ steps.meta.outputs.version }}
-      image: ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.version }}
     steps:
       - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
         with:
@@ -94,7 +93,7 @@ jobs:
     if: github.ref == 'refs/heads/main' && github.event_name == 'push'
     runs-on: ubuntu-latest
     permissions:
-      contents: read # checkout, for fly.toml
+      contents: read # checkout, for fly.toml and the build context
     environment:
       name: demo
       url: https://docket.demo.kejadlen.dev
@@ -119,9 +118,8 @@ jobs:
       - name: Create the app if it is not there yet
         run: flyctl status --app "$FLY_APP" >/dev/null 2>&1 || flyctl apps create "$FLY_APP" --org "$FLY_ORG"
 
-      # The image the build job just pushed, rather than a second build
-      # of the same commit: what the demo runs is then the release.
-      - name: Deploy the released image
-        env:
-          NEEDS_BUILD_OUTPUTS_IMAGE: ${{ needs.build.outputs.image }}
-        run: flyctl deploy --app "$FLY_APP" --config fly.toml --image "$NEEDS_BUILD_OUTPUTS_IMAGE"
+      # A build of its own from fly.toml, with the `dev` feature the
+      # release image leaves out. It still waits on the release, so the
+      # demo only runs commits that shipped.
+      - name: Deploy a demo build
+        run: flyctl deploy --app "$FLY_APP" --config fly.toml --remote-only
diff --git a/Cargo.toml b/Cargo.toml
index cc59094..261e3c9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,6 +7,12 @@ edition = "2024"
 name = "docket"
 path = "src/bin/docket/main.rs"
 
+[features]
+# Stands in for caddy-tailscale so the app runs locally against fixtures:
+# seeds an empty database and signs requests in as a user picked from the
+# menu. Never enable it in a deployed build.
+dev = []
+
 [dependencies]
 axum = "*"
 camino = "*"
diff --git a/Dockerfile b/Dockerfile
index cf9c05d..67588cf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,12 +19,15 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
     --mount=type=cache,target=/build/target \
     cargo chef cook --release --recipe-path recipe.json
 COPY . .
+# Cargo features to build with. Empty for the release image; the Fly demos
+# set `dev` (see fly.toml).
+ARG FEATURES=""
 # Copy the binary out of the cache mount so it survives into the runtime
 # stage.
 RUN --mount=type=cache,target=/usr/local/cargo/registry \
     --mount=type=cache,target=/usr/local/cargo/git/db \
     --mount=type=cache,target=/build/target \
-    cargo build --release --bin docket && \
+    cargo build --release --bin docket --features "$FEATURES" && \
     cp target/release/docket /build/docket
 
 # Runtime stage.
diff --git a/bin/coverage b/bin/coverage
index 631a322..c3b947c 100755
--- a/bin/coverage
+++ b/bin/coverage
@@ -22,7 +22,7 @@ main() {
     export LLVM_PROFILE_FILE="$TARGET_DIR/profraw/%p-%m.profraw"
 
     rm -rf "$TARGET_DIR"
-    cargo test --workspace --quiet
+    cargo test --workspace --all-features --quiet
 
     local report
     report=$(grcov "$TARGET_DIR/profraw" \
diff --git a/docket.kdl b/docket.kdl
index ec141c9..4d2d0d5 100644
--- a/docket.kdl
+++ b/docket.kdl
@@ -1,7 +1,6 @@
 // Docket's settings, read from this file (see `--help` for pointing
-// DOCKET_CONFIG elsewhere). This copy is the local-dev one: `dev` serves
-// fixture data without Tailscale, picking a user from the sidebar, and
-// seeds the database with it the first time. Delete the file to start over.
+// DOCKET_CONFIG elsewhere). This copy is the local-dev one; `just dev`
+// builds with the `dev` feature, which seeds the database with fixtures the
+// first time and stands in for Tailscale. Delete docket.db to start over.
 bind "127.0.0.1:3000"
 database "docket.db"
-dev
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 93d3d65..e63263e 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -20,8 +20,9 @@ A small self-hosted webapp for two people to jointly manage household email acco
 
 - Two app users.
 - Access is via Tailscale, which is the whole of authentication: anyone the tailnet lets reach Docket is a user, added on their first request. Docket keeps no list of people.
-- The app binds to localhost behind Caddy with [caddy-tailscale](https://github.com/tailscale/caddy-tailscale), which sets two headers from the tailnet identity: `Remote-User` (the full login, `{http.auth.user.id}`) identifies the user, and `X-User-Slug` carries a short name the Caddyfile maps from the login. Requests without a login are rejected.
-- A user's slug is what Docket shows for them. It follows the latest `X-User-Slug`, falls back to the part of the login before the `@`, and need not be unique.
+- The app binds to localhost behind Caddy with [caddy-tailscale](https://github.com/tailscale/caddy-tailscale), which sets two headers from the tailnet identity: `Remote-User` (the full login, `{http.auth.user.id}`) identifies the user, and `X-User-Slug` carries a short name the Caddyfile maps from the login. Requests missing either header are rejected.
+- A user's slug is what Docket shows for them. It follows the latest `X-User-Slug` and need not be unique.
+- For local work, the `dev` cargo feature stands in for the proxy: a middleware sets both headers for a user picked from a menu. Release builds leave it out, so no setting can turn it on in production.
 
 ## Credentials and accounts
 
diff --git a/fly.kdl b/fly.kdl
index 090bcc5..86a9d3b 100644
--- a/fly.kdl
+++ b/fly.kdl
@@ -1,11 +1,10 @@
 // Config for the two Fly demo apps, baked into the image at
 // /etc/docket.kdl (fly.toml points DOCKET_CONFIG at it). Binds wide
-// because Fly's edge routes to the machine, not to localhost. `dev` is
-// the whole of what makes them demos: fixture data instead of JMAP, and
-// a stand-in identity in place of the identity headers that Caddy
-// (caddy-tailscale) supplies in front of the real deployment. The database
-// sits on the machine's own disk, which a deploy replaces, so each deploy
-// starts from the fixtures again.
+// because Fly's edge routes to the machine, not to localhost. What makes
+// them demos is the `dev` feature fly.toml builds with: fixture data
+// instead of JMAP, and a stand-in identity in place of the identity
+// headers that Caddy (caddy-tailscale) supplies in front of the real
+// deployment. The database sits on the machine's own disk, which a deploy
+// replaces, so each deploy starts from the fixtures again.
 bind "0.0.0.0:3000"
 database "/tmp/docket.db"
-dev
diff --git a/fly.toml b/fly.toml
index ded0730..4293ce5 100644
--- a/fly.toml
+++ b/fly.toml
@@ -1,11 +1,12 @@
 # The one Fly config, for the two demo apps: the app main deploys (the
 # deploy job in .github/workflows/ci.yml) and the per-PR previews (the
 # review-app action in .github/workflows/preview.yml, which overrides
-# the app name with the PR's). Both read the config baked into the image
-# from fly.kdl: fixture data instead of JMAP, and a stand-in identity in
-# place of the identity headers that Caddy (caddy-tailscale) supplies in
-# front of the real deployment. No volume, so every deploy starts
-# from the fixtures again.
+# the app name with the PR's). Both build the image here with the `dev`
+# feature: fixture data instead of JMAP, and a stand-in identity in place
+# of the identity headers that Caddy (caddy-tailscale) supplies in front
+# of the real deployment. The release image leaves the feature out, so
+# the demos can't run it. No volume, so every deploy starts from the
+# fixtures again.
 #
 # The app name is Fly's, not GitHub's, so it has to be unique across all
 # of Fly and a taken one fails the deploy. ci.yml spells it twice more,
@@ -14,7 +15,8 @@
 app = 'docket-kejadlen'
 primary_region = 'sjc'
 
-[build]
+[build.args]
+FEATURES = 'dev'
 
 [env]
 DOCKET_CONFIG = '/etc/docket.kdl'
diff --git a/justfile b/justfile
index 0085625..b7c7736 100644
--- a/justfile
+++ b/justfile
@@ -4,10 +4,10 @@ fmt:
     cargo fmt --all
 
 check:
-    cargo check --workspace
+    cargo check --workspace --all-features
 
 clippy:
-    cargo clippy --workspace -- -D warnings
+    cargo clippy --workspace --all-features -- -D warnings
 
 coverage:
     ./bin/coverage
@@ -28,7 +28,7 @@ all: fmt clippy coverage
 # Serve the fixture-backed UI without Tailscale on http://127.0.0.1:3000,
 # restarting on changes.
 dev:
-    { fd -t f . src assets; echo Cargo.toml docket.kdl; } | entr -r cargo run
+    { fd -t f . src assets; echo Cargo.toml docket.kdl; } | entr -r cargo run --features dev
 
 install:
     cargo install --locked --path .
diff --git a/src/bin/docket/config.rs b/src/bin/docket/config.rs
index 9934def..809c5ca 100644
--- a/src/bin/docket/config.rs
+++ b/src/bin/docket/config.rs
@@ -22,10 +22,6 @@ pub struct Config {
     /// fronts it and supplies the user identity headers.
     pub bind: SocketAddr,
 
-    /// Serve without Tailscale: requests with no identity header act as a
-    /// user picked from the sidebar. For local work against fixtures only.
-    pub dev: bool,
-
     /// SQLite database file, created if missing. A relative path is
     /// relative to the working directory.
     pub database: Utf8PathBuf,
@@ -56,7 +52,6 @@ pub struct Args {
 fn parse(source: &str) -> Result<Config, Error> {
     let document = KdlDocument::parse(source)?;
     let mut bind = None;
-    let mut dev = None;
     let mut database = None;
 
     for node in document.nodes() {
@@ -70,15 +65,6 @@ fn parse(source: &str) -> Result<Config, Error> {
                 }
                 bind = Some(parse_bind(node)?);
             }
-            "dev" => {
-                if dev.is_some() {
-                    return Err(Error::Duplicate {
-                        name: "dev".to_owned(),
-                        span: node.name().span(),
-                    });
-                }
-                dev = Some(parse_dev(node)?);
-            }
             "database" => {
                 if database.is_some() {
                     return Err(Error::Duplicate {
@@ -99,7 +85,6 @@ fn parse(source: &str) -> Result<Config, Error> {
 
     Ok(Config {
         bind: bind.unwrap_or(DEFAULT_BIND),
-        dev: dev.unwrap_or(false),
         database: database.unwrap_or_else(|| Utf8PathBuf::from(DEFAULT_DATABASE)),
     })
 }
@@ -122,13 +107,6 @@ fn parse_database(node: &KdlNode) -> Result<Utf8PathBuf, Error> {
         .ok_or(Error::DatabaseValue { span: node.span() })
 }
 
-fn parse_dev(node: &KdlNode) -> Result<bool, Error> {
-    match single_value(node, "dev")? {
-        None => Ok(true),
-        Some(value) => value.as_bool().ok_or(Error::DevValue { span: node.span() }),
-    }
-}
-
 /// Returns the node's single positional value, or `None` when the node
 /// is bare. Everything else a KDL node can carry — properties, type
 /// annotations, child blocks, extra values — is rejected rather than
@@ -157,7 +135,7 @@ enum Error {
     #[error("unknown setting `{name}`")]
     #[diagnostic(
         code(docket::config::unknown),
-        help("known settings: `bind`, `database`, `dev`")
+        help("known settings: `bind`, `database`")
     )]
     Unknown {
         name: String,
@@ -204,13 +182,6 @@ enum Error {
         #[label]
         span: SourceSpan,
     },
-
-    #[error("dev takes #true or #false, or nothing at all (which means true)")]
-    #[diagnostic(code(docket::config::dev_value))]
-    DevValue {
-        #[label]
-        span: SourceSpan,
-    },
 }
 
 #[cfg(test)]
@@ -222,7 +193,6 @@ mod tests {
     fn empty_document_uses_defaults() {
         let config = parse("").unwrap();
         assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
-        assert!(!config.dev);
         assert_eq!(config.database, "docket.db");
     }
 
@@ -230,7 +200,6 @@ mod tests {
     fn comments_only_uses_defaults() {
         let config = parse("// nothing configured\n").unwrap();
         assert_eq!(config.bind.to_string(), "127.0.0.1:3000");
-        assert!(!config.dev);
     }
 
     #[test]
@@ -278,45 +247,22 @@ mod tests {
         ));
     }
 
-    #[test]
-    fn bare_dev_means_true() {
-        let config = parse("dev").unwrap();
-        assert!(config.dev);
-    }
-
-    #[test]
-    fn dev_accepts_explicit_booleans() {
-        assert!(parse("dev #true").unwrap().dev);
-        assert!(!parse("dev #false").unwrap().dev);
-    }
-
-    #[test]
-    fn dev_rejects_non_boolean_values() {
-        assert!(matches!(parse(r#"dev "yes""#), Err(Error::DevValue { .. })));
-        assert!(matches!(parse("dev 1"), Err(Error::DevValue { .. })));
-        assert!(matches!(parse("dev #null"), Err(Error::DevValue { .. })));
-        // Bare `true` never reaches us: KDL v2 rejects it at parse time.
-        assert!(matches!(parse("dev true"), Err(Error::Kdl(_))));
-    }
-
     #[test]
     fn settings_combine() {
-        let config = parse("bind \"0.0.0.0:3000\"\ndev\n").unwrap();
+        let config = parse("bind \"0.0.0.0:3000\"\ndatabase \"d.db\"\n").unwrap();
         assert_eq!(config.bind.to_string(), "0.0.0.0:3000");
-        assert!(config.dev);
+        assert_eq!(config.database, "d.db");
     }
 
     #[test]
     fn unknown_setting_is_rejected() {
         assert!(matches!(parse("bnd \"x\""), Err(Error::Unknown { .. })));
+        // Dev mode is a build feature now, not a setting.
+        assert!(matches!(parse("dev"), Err(Error::Unknown { .. })));
     }
 
     #[test]
     fn duplicate_setting_is_rejected() {
-        assert!(matches!(
-            parse("dev\ndev #false"),
-            Err(Error::Duplicate { .. })
-        ));
         let source = "bind \"127.0.0.1:1\"\nbind \"127.0.0.1:2\"";
         assert!(matches!(parse(source), Err(Error::Duplicate { .. })));
     }
@@ -324,11 +270,17 @@ mod tests {
     #[test]
     fn extra_syntax_is_rejected() {
         assert!(matches!(
-            parse("dev #true #false"),
+            parse(r#"database "a.db" "b.db""#),
+            Err(Error::Shape { .. })
+        ));
+        assert!(matches!(
+            parse(r#"database path="a.db""#),
+            Err(Error::Shape { .. })
+        ));
+        assert!(matches!(
+            parse(r#"database "a.db" { inner }"#),
             Err(Error::Shape { .. })
         ));
-        assert!(matches!(parse("dev on=#false"), Err(Error::Shape { .. })));
-        assert!(matches!(parse("dev { inner }"), Err(Error::Shape { .. })));
         assert!(matches!(
             parse(r#"bind address="127.0.0.1:3000""#),
             Err(Error::Shape { .. })
diff --git a/src/bin/docket/main.rs b/src/bin/docket/main.rs
index 057e2a8..6d625fe 100644
--- a/src/bin/docket/main.rs
+++ b/src/bin/docket/main.rs
@@ -21,11 +21,15 @@ async fn main() -> miette::Result<()> {
     // The fixtures' clock keeps their ages stable until JMAP sync brings
     // real mail.
     let store = docket::store::Store::open(&config.database, docket::fixtures::now())?;
-    if config.dev && store.is_empty()? {
+    #[cfg(feature = "dev")]
+    if store.is_empty()? {
         docket::fixtures::seed(&store)?;
         tracing::info!(database = %config.database, "seeded fixtures");
     }
-    let state = docket::routes::AppState::new(store, config.dev);
+    let state = docket::routes::AppState::new(store);
+    #[cfg(feature = "dev")]
+    let app = docket::dev::router(state);
+    #[cfg(not(feature = "dev"))]
     let app = docket::routes::router(state);
 
     let listener = TcpListener::bind(config.bind).await.into_diagnostic()?;
diff --git a/src/dev.rs b/src/dev.rs
new file mode 100644
index 0000000..d03852a
--- /dev/null
+++ b/src/dev.rs
@@ -0,0 +1,85 @@
+//! Local development without Tailscale, compiled in only with the `dev`
+//! feature. A middleware plays caddy-tailscale, setting the identity headers
+//! for a user picked from the menu, so requests take the same path they do
+//! in production.
+
+use axum::extract::{Request, State};
+use axum::http::header::{COOKIE, SET_COOKIE};
+use axum::http::{HeaderMap, HeaderValue};
+use axum::middleware::{self, Next};
+use axum::response::{IntoResponse, Redirect, Response};
+use axum::routing::post;
+use axum::{Form, Router};
+
+use crate::Error;
+use crate::routes::{
+    self, AppState, IDENTITY_HEADER, SLUG_HEADER, UserForm, header, safe_back, same_origin,
+};
+
+const COOKIE_NAME: &str = "docket_dev_user";
+
+/// The app's routes plus the user switch, with requests signed in as the
+/// user the switch chose, or the first user before it's been used.
+pub fn router(state: AppState) -> Router {
+    routes::router(state.clone())
+        .merge(
+            Router::new()
+                .route("/dev/user", post(switch_user))
+                .with_state(state.clone()),
+        )
+        .layer(middleware::from_fn_with_state(state, identify))
+}
+
+async fn identify(
+    State(state): State<AppState>,
+    mut req: Request,
+    next: Next,
+) -> Result<Response, Error> {
+    let store = &state.store;
+    let chosen = match cookie(req.headers(), COOKIE_NAME) {
+        Some(login) => store.user(login)?,
+        None => None,
+    };
+    let user = match chosen {
+        Some(user) => Some(user),
+        None => store.users()?.into_iter().next(),
+    };
+    // Like the proxy, never trust what the client sent.
+    let headers = req.headers_mut();
+    headers.remove(IDENTITY_HEADER);
+    headers.remove(SLUG_HEADER);
+    // The slug goes along too: signing in without one would rename the user.
+    if let Some(user) = user
+        && let (Ok(login), Ok(slug)) = (
+            HeaderValue::from_str(&user.login),
+            HeaderValue::from_str(&user.slug),
+        )
+    {
+        headers.insert(IDENTITY_HEADER, login);
+        headers.insert(SLUG_HEADER, slug);
+    }
+    Ok(next.run(req).await)
+}
+
+async fn switch_user(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<UserForm>,
+) -> Result<Response, Error> {
+    if !same_origin(&headers) {
+        return Err(Error::Forbidden("cross-site request"));
+    }
+    if state.store.user(&form.user)?.is_none() {
+        return Err(Error::NotFound("user"));
+    }
+    let cookie = format!("{COOKIE_NAME}={}; Path=/; SameSite=Lax", form.user);
+    Ok(([(SET_COOKIE, cookie)], Redirect::to(safe_back(&form.back))).into_response())
+}
+
+fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
+    header(headers, COOKIE.as_str())?
+        .split(';')
+        .filter_map(|pair| pair.trim().split_once('='))
+        .find(|(k, _)| *k == name)
+        .map(|(_, v)| v)
+}
diff --git a/src/fixtures.rs b/src/fixtures.rs
index c0a2170..f5534b4 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -130,7 +130,7 @@ pub fn store() -> Result<Store, Error> {
 
 /// Writes the fixtures into an empty database.
 pub fn seed(store: &Store) -> Result<(), Error> {
-    let users = [User::new(ALEX, Some("Alex")), User::new(SAM, Some("Sam"))];
+    let users = [User::new(ALEX, "Alex"), User::new(SAM, "Sam")];
     let accounts = [
         Account {
             slug: "household".into(),
diff --git a/src/lib.rs b/src/lib.rs
index 645588a..1868bc8 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,6 @@
 pub mod dates;
+#[cfg(feature = "dev")]
+pub mod dev;
 pub mod error;
 pub mod fixtures;
 pub mod lists;
diff --git a/src/model.rs b/src/model.rs
index 3c6e93a..833dcd7 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -74,16 +74,10 @@ pub struct User {
 }
 
 impl User {
-    /// The slug the proxy sent, or the part of the login before the `@`.
-    pub fn new(login: &str, slug: Option<&str>) -> Self {
-        let fallback = login.split_once('@').map_or(login, |(name, _)| name);
-        let slug = slug
-            .map(str::trim)
-            .filter(|s| !s.is_empty())
-            .unwrap_or(fallback);
+    pub fn new(login: &str, slug: &str) -> Self {
         Self {
             login: login.to_owned(),
-            slug: slug.to_owned(),
+            slug: slug.trim().to_owned(),
         }
     }
 }
@@ -171,13 +165,10 @@ mod tests {
     use super::*;
 
     #[test]
-    fn slugs_fall_back_to_the_login() {
-        let named = User::new("alex@example.com", Some(" Al "));
+    fn slugs_are_trimmed() {
+        let named = User::new("alex@example.com", " Al ");
         assert_eq!(named.login, "alex@example.com");
         assert_eq!(named.slug, "Al");
-        assert_eq!(User::new("alex@example.com", None).slug, "alex");
-        assert_eq!(User::new("alex@example.com", Some("  ")).slug, "alex");
-        assert_eq!(User::new("tagged-device", None).slug, "tagged-device");
     }
 
     #[test]
diff --git a/src/routes.rs b/src/routes.rs
index e10ddc6..bb89cf0 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -1,10 +1,10 @@
 use std::sync::Arc;
 
 use axum::extract::{FromRequestParts, OriginalUri, Path, Query, State as Extract};
-use axum::http::header::{CONTENT_TYPE, COOKIE, HOST, ORIGIN, SET_COOKIE};
+use axum::http::header::{CONTENT_TYPE, HOST, ORIGIN};
 use axum::http::request::Parts;
 use axum::http::{HeaderMap, Method};
-use axum::response::{IntoResponse, Redirect, Response};
+use axum::response::{IntoResponse, Redirect};
 use axum::routing::{get, post};
 use axum::{Form, Router};
 use maud::Markup;
@@ -19,28 +19,24 @@ use crate::views::{self, Page, ThreadView};
 
 // caddy-tailscale sets both from the tailnet identity, overwriting whatever
 // the client sent.
-const IDENTITY_HEADER: &str = "Remote-User";
-const SLUG_HEADER: &str = "X-User-Slug";
-const DEV_COOKIE: &str = "docket_dev_user";
+pub(crate) const IDENTITY_HEADER: &str = "Remote-User";
+pub(crate) const SLUG_HEADER: &str = "X-User-Slug";
 
 #[derive(Clone)]
 pub struct AppState {
     pub store: Arc<Store>,
-    /// Without Tailscale in front, fall back to a cookie-chosen user.
-    pub dev: bool,
 }
 
 impl AppState {
-    pub fn new(store: Store, dev: bool) -> Self {
+    pub fn new(store: Store) -> Self {
         Self {
             store: Arc::new(store),
-            dev,
         }
     }
 }
 
 pub fn router(state: AppState) -> Router {
-    let mut router = Router::new()
+    Router::new()
         .route("/health", get(health))
         .route("/", get(for_me))
         .route("/{lane}", get(lane))
@@ -52,11 +48,9 @@ pub fn router(state: AppState) -> Router {
         .route("/undo", post(undo))
         .route("/assets/gloss.css", get(gloss_css))
         .route("/assets/docket.css", get(docket_css))
-        .route("/assets/alpine.js", get(alpine_js));
-    if state.dev {
-        router = router.route("/dev/user", post(dev_user));
-    }
-    router.layer(TraceLayer::new_for_http()).with_state(state)
+        .route("/assets/alpine.js", get(alpine_js))
+        .layer(TraceLayer::new_for_http())
+        .with_state(state)
 }
 
 async fn health() -> &'static str {
@@ -64,7 +58,8 @@ async fn health() -> &'static str {
 }
 
 /// The requesting user, from the identity headers caddy-tailscale adds. Anyone
-/// Tailscale lets through is a user; the first request adds them.
+/// Tailscale lets through is a user; the first request adds them. Missing
+/// either header means the proxy isn't doing its job, so nobody gets in.
 struct Me(User);
 
 impl FromRequestParts<AppState> for Me {
@@ -76,31 +71,19 @@ impl FromRequestParts<AppState> for Me {
         if parts.method == Method::POST && !same_origin(&parts.headers) {
             return Err(Error::Forbidden("cross-site request"));
         }
-        let store = &state.store;
-        if let Some(login) = header(&parts.headers, IDENTITY_HEADER).filter(|l| !l.is_empty()) {
-            let slug = header(&parts.headers, SLUG_HEADER);
-            return Ok(Me(store.sign_in(login, slug)?));
-        }
-        if !state.dev {
+        let present = |name| header(&parts.headers, name).filter(|v| !v.trim().is_empty());
+        let (Some(login), Some(slug)) = (present(IDENTITY_HEADER), present(SLUG_HEADER)) else {
             return Err(Error::Unauthenticated);
-        }
-        let chosen = match cookie(&parts.headers, DEV_COOKIE) {
-            Some(login) => store.user(login)?,
-            None => None,
-        };
-        let user = match chosen {
-            Some(user) => Some(user),
-            None => store.users()?.into_iter().next(),
         };
-        user.map(Me).ok_or(Error::Unauthenticated)
+        Ok(Me(state.store.sign_in(login, slug)?))
     }
 }
 
-fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
+pub(crate) fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
     headers.get(name).and_then(|v| v.to_str().ok())
 }
 
-fn same_origin(headers: &HeaderMap) -> bool {
+pub(crate) fn same_origin(headers: &HeaderMap) -> bool {
     let Some(origin) = header(headers, ORIGIN.as_str()) else {
         return true;
     };
@@ -108,14 +91,6 @@ fn same_origin(headers: &HeaderMap) -> bool {
     origin_host.is_some() && origin_host == header(headers, HOST.as_str())
 }
 
-fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
-    header(headers, COOKIE.as_str())?
-        .split(';')
-        .filter_map(|pair| pair.trim().split_once('='))
-        .find(|(k, _)| *k == name)
-        .map(|(_, v)| v)
-}
-
 #[derive(Deserialize)]
 struct Selection {
     m: Option<MessageId>,
@@ -180,7 +155,6 @@ fn render(
         thread,
         flash: store.take_flash(&me.login),
         here,
-        dev: state.dev,
     }))
 }
 
@@ -205,7 +179,7 @@ fn open_thread(store: &Store, me: &User, id: MessageId) -> Result<ThreadView, Er
 }
 
 /// Only same-site paths, so a form can't bounce the browser elsewhere.
-fn safe_back(back: &str) -> &str {
+pub(crate) fn safe_back(back: &str) -> &str {
     if back.starts_with('/') && !back.starts_with("//") && !back.contains('\\') {
         back
     } else {
@@ -246,9 +220,9 @@ async fn set_folder(
 }
 
 #[derive(Deserialize)]
-struct UserForm {
-    user: String,
-    back: String,
+pub(crate) struct UserForm {
+    pub(crate) user: String,
+    pub(crate) back: String,
 }
 
 async fn toggle_assignee(
@@ -308,21 +282,6 @@ async fn undo(
     Ok(Redirect::to(safe_back(&form.back)))
 }
 
-async fn dev_user(
-    Extract(state): Extract<AppState>,
-    headers: HeaderMap,
-    Form(form): Form<UserForm>,
-) -> Result<Response, Error> {
-    if !same_origin(&headers) {
-        return Err(Error::Forbidden("cross-site request"));
-    }
-    if state.store.user(&form.user)?.is_none() {
-        return Err(Error::NotFound("user"));
-    }
-    let cookie = format!("{DEV_COOKIE}={}; Path=/; SameSite=Lax", form.user);
-    Ok(([(SET_COOKIE, cookie)], Redirect::to(safe_back(&form.back))).into_response())
-}
-
 async fn gloss_css() -> impl IntoResponse {
     (
         [(CONTENT_TYPE, "text/css; charset=utf-8")],
diff --git a/src/store.rs b/src/store.rs
index 7e14034..dea0cf8 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -135,7 +135,7 @@ impl Store {
 
     /// The user behind a request Tailscale identified, added on their first
     /// request. Their slug follows whatever the proxy sends.
-    pub fn sign_in(&self, login: &str, slug: Option<&str>) -> Result<User, Error> {
+    pub fn sign_in(&self, login: &str, slug: &str) -> Result<User, Error> {
         let user = User::new(login, slug);
         self.lock().conn.execute(
             "INSERT INTO users (login, slug) VALUES (?1, ?2)
@@ -910,16 +910,16 @@ mod tests {
     fn signing_in_adds_users_and_follows_their_slug() {
         let store = Store::open_in_memory(fixtures::now()).unwrap();
         assert!(store.is_empty().unwrap());
-        let pat = store.sign_in("pat@example.com", None).unwrap();
-        assert_eq!(pat, User::new("pat@example.com", None));
+        let pat = store.sign_in("pat@example.com", "pat").unwrap();
+        assert_eq!(pat, User::new("pat@example.com", "pat"));
         assert!(!store.is_empty().unwrap());
         assert_eq!(store.user("pat@example.com").unwrap(), Some(pat));
 
-        store.sign_in("pat@example.com", Some("Pat")).unwrap();
-        store.sign_in("pat@example.com", Some("Pat")).unwrap();
+        store.sign_in("pat@example.com", "Pat").unwrap();
+        store.sign_in("pat@example.com", "Pat").unwrap();
         assert_eq!(store.user("pat@example.com").unwrap().unwrap().slug, "Pat");
         // Slugs are only for show, so two logins may share one.
-        store.sign_in("pat@work.example", Some("Pat")).unwrap();
+        store.sign_in("pat@work.example", "Pat").unwrap();
         assert_eq!(store.users().unwrap().len(), 2);
     }
 
@@ -1042,7 +1042,7 @@ mod tests {
         let db_err = |r: Result<(), Error>| assert!(matches!(r, Err(Error::Db(_))), "{r:?}");
         db_err(store.edit(SAM, 4, Change::State(State::Do)));
         db_err(store.mark_read(SAM, 4));
-        db_err(store.sign_in("pat@example.com", None).map(|_| ()));
+        db_err(store.sign_in("pat@example.com", "pat").map(|_| ()));
         db_err(store.add_comment(SAM, 1, "hi"));
         db_err(store.import(|tx| tx.folder("Travel")));
         db_err(store.import(|tx| tx.message(&msg)));
diff --git a/src/views.rs b/src/views.rs
index 2f7f534..877a9fe 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -30,8 +30,6 @@ pub struct Page<'a> {
     pub flash: Option<Flash>,
     /// This page's URL, for forms to return to.
     pub here: &'a str,
-    /// Lets the viewer switch users without Tailscale, for fixtures.
-    pub dev: bool,
 }
 
 pub struct ThreadView {
@@ -174,7 +172,7 @@ fn whoami(p: &Page<'_>) -> Markup {
         span.type-label.who-login { (p.me.login) }
     };
     html! {
-        @if p.dev {
+        @if cfg!(feature = "dev") {
             div.who.ctrl x-data="{ open: false }" "@click.outside"="open = false" "@keydown.escape"="open = false" {
                 button.who-btn type="button" "@click"="open = !open" { (who) }
                 form.menu.up method="post" action="/dev/user" x-show="open" x-cloak {
diff --git a/tests/api.rs b/tests/api.rs
index 603ed07..0a90e38 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -1,14 +1,15 @@
 use std::net::SocketAddr;
 
+use axum::Router;
 use docket::routes::{AppState, router};
 use reqwest::{Client, StatusCode, redirect};
 use tokio::net::TcpListener;
 
 const SAM: &str = "sam@example.com";
 const ALEX: &str = "alex@example.com";
+const EVE: &str = "eve@example.com";
 
-async fn spawn_with(dev: bool) -> SocketAddr {
-    let app = router(AppState::new(docket::fixtures::store().unwrap(), dev));
+async fn serve(app: Router) -> SocketAddr {
     let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
     let addr = listener.local_addr().unwrap();
     tokio::spawn(async move {
@@ -18,7 +19,7 @@ async fn spawn_with(dev: bool) -> SocketAddr {
 }
 
 async fn spawn() -> SocketAddr {
-    spawn_with(false).await
+    serve(router(AppState::new(docket::fixtures::store().unwrap()))).await
 }
 
 fn client() -> Client {
@@ -28,10 +29,21 @@ fn client() -> Client {
         .unwrap()
 }
 
+/// What the proxy would send as `X-User-Slug`: "sam@example.com" is "Sam".
+fn slug(login: &str) -> String {
+    let name = login.split('@').next().unwrap_or(login);
+    let mut chars = name.chars();
+    chars
+        .next()
+        .map(|c| c.to_uppercase().chain(chars).collect())
+        .unwrap_or_default()
+}
+
 async fn get(addr: SocketAddr, login: &str, path: &str) -> (StatusCode, String) {
     let res = client()
         .get(format!("http://{addr}{path}"))
         .header("Remote-User", login)
+        .header("X-User-Slug", slug(login))
         .send()
         .await
         .unwrap();
@@ -47,6 +59,7 @@ async fn post(
     client()
         .post(format!("http://{addr}{path}"))
         .header("Remote-User", login)
+        .header("X-User-Slug", slug(login))
         .form(form)
         .send()
         .await
@@ -66,12 +79,29 @@ async fn health_returns_ok() {
 }
 
 #[tokio::test]
-async fn requires_an_identity() {
+async fn requires_both_identity_headers() {
     let addr = spawn().await;
     let res = reqwest::get(format!("http://{addr}/")).await.unwrap();
     assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
-    let (status, _) = get(addr, "", "/").await;
-    assert_eq!(status, StatusCode::UNAUTHORIZED);
+    for (login, slug) in [
+        (Some(EVE), None),
+        (None, Some("Eve")),
+        (Some(""), Some("Eve")),
+        (Some(EVE), Some(" ")),
+    ] {
+        let mut req = client().get(format!("http://{addr}/"));
+        if let Some(login) = login {
+            req = req.header("Remote-User", login);
+        }
+        if let Some(slug) = slug {
+            req = req.header("X-User-Slug", slug);
+        }
+        let res = req.send().await.unwrap();
+        assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "{login:?} {slug:?}");
+    }
+    // Nobody got added along the way.
+    let (_, body) = get(addr, ALEX, "/?m=4").await;
+    assert!(!body.contains(EVE));
 }
 
 #[tokio::test]
@@ -79,9 +109,9 @@ async fn anyone_tailscale_lets_in_is_a_user() {
     let addr = spawn().await;
     let (status, body) = get(addr, "eve@example.com", "/").await;
     assert_eq!(status, StatusCode::OK);
-    assert!(body.contains(r#"<span class="who-name">eve</span>"#));
+    assert!(body.contains(r#"<span class="who-name">Eve</span>"#));
 
-    // The proxy names them with X-User-Slug, and the name follows it.
+    // The name follows the latest X-User-Slug.
     let body = client()
         .get(format!("http://{addr}/?m=4"))
         .header("Remote-User", "eve@example.com")
@@ -118,7 +148,7 @@ async fn lanes_and_search() {
         ("/do", "Exemption renewal"),
         ("/wait", "Claim 4471"),
         ("/watch", "Shipped: furnace filters"),
-        ("/search?q=downspout", "sam → Northwind Roofing"),
+        ("/search?q=downspout", "Sam → Northwind Roofing"),
     ] {
         let (status, body) = get(addr, SAM, path).await;
         assert_eq!(status, StatusCode::OK, "{path}");
@@ -316,88 +346,108 @@ async fn comments_join_the_thread() {
 
 #[tokio::test]
 async fn cross_site_posts_are_refused() {
-    let addr = spawn_with(true).await;
-    let send = |origin: &'static str, path: &'static str| {
+    let addr = spawn().await;
+    let send = |origin: &'static str| {
         client()
-            .post(format!("http://{addr}{path}"))
+            .post(format!("http://{addr}/messages/4/state"))
             .header("Remote-User", SAM)
+            .header("X-User-Slug", "Sam")
             .header("Origin", origin)
-            .form(&[("state", "do"), ("user", ALEX), ("back", "/")])
+            .form(&[("state", "do"), ("back", "/")])
             .send()
     };
-    let res = send("https://evil.example", "/messages/4/state")
-        .await
-        .unwrap();
-    assert_eq!(res.status(), StatusCode::FORBIDDEN);
-    let res = send("null", "/messages/4/state").await.unwrap();
+    let res = send("https://evil.example").await.unwrap();
     assert_eq!(res.status(), StatusCode::FORBIDDEN);
-    let res = send("https://evil.example", "/dev/user").await.unwrap();
+    let res = send("null").await.unwrap();
     assert_eq!(res.status(), StatusCode::FORBIDDEN);
     let same: &'static str = Box::leak(format!("http://{addr}").into_boxed_str());
-    let res = send(same, "/messages/4/state").await.unwrap();
+    let res = send(same).await.unwrap();
     assert_eq!(res.status(), StatusCode::SEE_OTHER);
 }
 
 #[tokio::test]
-async fn dev_mode_picks_a_user_from_a_cookie() {
-    let addr = spawn_with(true).await;
-    let anon = client();
-    let body = anon
-        .get(format!("http://{addr}/"))
-        .send()
-        .await
-        .unwrap()
-        .text()
-        .await
-        .unwrap();
-    assert!(body.contains(r#"<span class="who-name">Alex</span>"#));
-    assert!(body.contains(r#"action="/dev/user""#));
+async fn without_the_dev_feature_there_is_no_user_switch() {
+    let addr = spawn().await;
+    let res = post(addr, SAM, "/dev/user", &[("user", ALEX), ("back", "/")]).await;
+    assert_eq!(res.status(), StatusCode::NOT_FOUND);
+}
 
-    let res = anon
-        .post(format!("http://{addr}/dev/user"))
-        .form(&[("user", SAM), ("back", "/do")])
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), StatusCode::SEE_OTHER);
-    assert_eq!(location(&res), "/do");
-    let cookie = res.headers()["set-cookie"].to_str().unwrap();
-    assert!(cookie.starts_with("docket_dev_user=sam@example.com;"));
+#[cfg(feature = "dev")]
+mod dev {
+    use super::*;
 
-    let body = anon
-        .get(format!("http://{addr}/"))
-        .header("Cookie", "theme=dark; docket_dev_user=sam@example.com")
-        .send()
-        .await
-        .unwrap()
-        .text()
-        .await
-        .unwrap();
-    assert!(body.contains(r#"<span class="who-name">Sam</span>"#));
+    async fn spawn_dev(store: docket::store::Store) -> SocketAddr {
+        serve(docket::dev::router(AppState::new(store))).await
+    }
 
-    let res = anon
-        .post(format!("http://{addr}/dev/user"))
-        .form(&[("user", "eve"), ("back", "/")])
-        .send()
-        .await
-        .unwrap();
-    assert_eq!(res.status(), StatusCode::NOT_FOUND);
+    async fn page(addr: SocketAddr, cookie: Option<&str>) -> String {
+        let mut req = client()
+            .get(format!("http://{addr}/"))
+            // Whatever the client claims, the middleware decides.
+            .header("Remote-User", "mallory@example.com")
+            .header("X-User-Slug", "Mallory");
+        if let Some(cookie) = cookie {
+            req = req.header("Cookie", cookie);
+        }
+        req.send().await.unwrap().text().await.unwrap()
+    }
 
-    // Outside dev mode, the switch doesn't exist.
-    let prod = spawn().await;
-    let res = post(prod, SAM, "/dev/user", &[("user", ALEX), ("back", "/")]).await;
-    assert_eq!(res.status(), StatusCode::NOT_FOUND);
-}
+    #[tokio::test]
+    async fn picks_a_user_from_a_cookie() {
+        let addr = spawn_dev(docket::fixtures::store().unwrap()).await;
+        let body = page(addr, None).await;
+        assert!(body.contains(r#"<span class="who-name">Alex</span>"#));
+        assert!(body.contains(r#"action="/dev/user""#));
+        assert!(!body.contains("Mallory"));
+
+        let res = client()
+            .post(format!("http://{addr}/dev/user"))
+            .form(&[("user", SAM), ("back", "/do")])
+            .send()
+            .await
+            .unwrap();
+        assert_eq!(res.status(), StatusCode::SEE_OTHER);
+        assert_eq!(location(&res), "/do");
+        let cookie = res.headers()["set-cookie"].to_str().unwrap();
+        assert!(cookie.starts_with("docket_dev_user=sam@example.com;"));
+
+        let body = page(addr, Some("theme=dark; docket_dev_user=sam@example.com")).await;
+        assert!(body.contains(r#"<span class="who-name">Sam</span>"#));
+        // A cookie for someone unknown falls back to the first user.
+        let body = page(addr, Some("docket_dev_user=eve@example.com")).await;
+        assert!(body.contains(r#"<span class="who-name">Alex</span>"#));
+
+        let res = client()
+            .post(format!("http://{addr}/dev/user"))
+            .form(&[("user", "eve"), ("back", "/")])
+            .send()
+            .await
+            .unwrap();
+        assert_eq!(res.status(), StatusCode::NOT_FOUND);
+
+        let res = client()
+            .post(format!("http://{addr}/dev/user"))
+            .header("Origin", "https://evil.example")
+            .form(&[("user", SAM), ("back", "/")])
+            .send()
+            .await
+            .unwrap();
+        assert_eq!(res.status(), StatusCode::FORBIDDEN);
+    }
 
-#[tokio::test]
-async fn dev_mode_with_no_users_is_unauthenticated() {
-    let store = docket::store::Store::open_in_memory(docket::fixtures::now()).unwrap();
-    let app = router(AppState::new(store, true));
-    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
-    let addr = listener.local_addr().unwrap();
-    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
-    let res = reqwest::get(format!("http://{addr}/")).await.unwrap();
-    assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
+    #[tokio::test]
+    async fn with_no_users_is_unauthenticated() {
+        let store = docket::store::Store::open_in_memory(docket::fixtures::now()).unwrap();
+        let addr = spawn_dev(store).await;
+        let res = client()
+            .get(format!("http://{addr}/"))
+            .header("Remote-User", SAM)
+            .header("X-User-Slug", "Sam")
+            .send()
+            .await
+            .unwrap();
+        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
+    }
 }
 
 #[tokio::test]