Rename auth_token to run_token and add token-only API routes
Renames `ApiSession.auth_token` → `run_token` (and the `runs.auth_token`
DB column) throughout, updating the env var passed to quire-ci from
`QUIRE__AUTH_TOKEN` to `QUIRE__RUN_TOKEN`.

Adds new `/api/run/bootstrap` and `/api/run/secrets/{name}` endpoints
where the bearer token itself identifies the run — no `{run_id}` in the
path. The `verify_run_token` middleware looks up the run via
`SELECT id FROM runs WHERE run_token = ?1` and injects the resolved run
ID as a request extension. Updates `RunClient` in quire-ci to use the
new paths.

The original `/api/runs/{run_id}/…` routes are kept for now.

https://claude.ai/code/session_01XsLrdwUarxN9QHTfagwxJT
change
commit 29f8edabdc9ee8c0f8af90bae1c1b1102d7bc75a
author Claude <noreply@anthropic.com>
date
parent qlkprlns
diff --git a/docs/config.md b/docs/config.md
index 24f923f..25e7a3d 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -94,7 +94,7 @@ file (mode `0600`) before `quire-ci` is spawned. The subprocess reads and
 immediately unlinks the file.
 
 **`"api"`:** `quire-ci` ignores the secrets map in the bootstrap file and
-fetches each value on demand from `GET /api/runs/{run_id}/secrets/{name}`
+fetches each value on demand from `GET /api/run/secrets/{name}`
 using the per-run bearer token when a run-fn calls `(secret :name)`. The
 bootstrap file is still written and used for run metadata; only the secret
 values travel over the loopback HTTP channel instead of disk.
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 9f8fbbf..9300965 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -49,7 +49,7 @@ struct Cli {
 
     /// Transport credentials and telemetry settings for
     /// orchestrator-dispatched runs, sourced from `QUIRE__*` env vars:
-    /// `QUIRE__RUN_ID`, `QUIRE__SERVER_URL`, `QUIRE__AUTH_TOKEN`,
+    /// `QUIRE__RUN_ID`, `QUIRE__SERVER_URL`, `QUIRE__RUN_TOKEN`,
     /// `QUIRE__TRANSPORT`, `QUIRE__SENTRY_DSN`.
     #[facet(args::config, args::env_prefix = "QUIRE")]
     quire: QuireConfig,
@@ -75,9 +75,9 @@ struct QuireConfig {
     #[facet(default)]
     server_url: String,
 
-    /// Bearer token minted at run creation time (`QUIRE__AUTH_TOKEN`).
+    /// Bearer token minted at run creation time (`QUIRE__RUN_TOKEN`).
     #[facet(sensitive, default)]
-    auth_token: String,
+    run_token: String,
 
     /// Transport mode (`QUIRE__TRANSPORT`).
     #[facet(default)]
@@ -211,7 +211,7 @@ struct RunClient {
 impl RunClient {
     fn new(session: ApiSession) -> Self {
         let mut headers = HeaderMap::new();
-        let mut auth = HeaderValue::from_str(&format!("Bearer {}", session.auth_token))
+        let mut auth = HeaderValue::from_str(&format!("Bearer {}", session.run_token))
             .expect("auth token contains only ASCII");
         auth.set_sensitive(true);
         headers.insert(AUTHORIZATION, auth);
@@ -220,7 +220,7 @@ impl RunClient {
                 .default_headers(headers)
                 .build()
                 .expect("failed to build HTTP client"),
-            base_url: format!("{}/api/runs/{}", session.server_url, session.run_id),
+            base_url: format!("{}/api/run", session.server_url),
         }
     }
 
@@ -320,7 +320,7 @@ fn main() -> Result<()> {
             let session = ApiSession {
                 run_id: cli.quire.run_id,
                 server_url: cli.quire.server_url,
-                auth_token: cli.quire.auth_token,
+                run_token: cli.quire.run_token,
             };
             let transport = Transport {
                 session: session.clone(),
diff --git a/quire-core/src/ci/transport.rs b/quire-core/src/ci/transport.rs
index 416a0fc..4d9a0b1 100644
--- a/quire-core/src/ci/transport.rs
+++ b/quire-core/src/ci/transport.rs
@@ -17,8 +17,8 @@ pub struct ApiSession {
     /// Base URL of quire-server (e.g. `http://127.0.0.1:3000`).
     pub server_url: String,
     /// Bearer token minted at run creation time. Matches
-    /// `runs.auth_token` server-side.
-    pub auth_token: String,
+    /// `runs.run_token` server-side.
+    pub run_token: String,
 }
 
 /// Transport mode for CI ↔ server communication.
diff --git a/quire-server/migrations/0005_rename_run_token.sql b/quire-server/migrations/0005_rename_run_token.sql
new file mode 100644
index 0000000..7d5608b
--- /dev/null
+++ b/quire-server/migrations/0005_rename_run_token.sql
@@ -0,0 +1 @@
+ALTER TABLE runs RENAME COLUMN auth_token TO run_token;
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index dbddcfd..d4c1c66 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -38,7 +38,7 @@ pub fn new_transport(mode: TransportMode, port: u16) -> Transport {
         session: ApiSession {
             run_id: uuid::Uuid::now_v7().to_string(),
             server_url: format!("http://127.0.0.1:{port}"),
-            auth_token: mint_auth_token(),
+            run_token: mint_run_token(),
         },
         mode,
     }
@@ -118,12 +118,9 @@ impl Runs {
     /// and the DB agree. Pass `None` for local runs; a fresh UUID is minted
     /// and no auth token is stored.
     pub fn create(&self, meta: &RunMeta, transport: Option<&Transport>) -> Result<Run> {
-        let (id, auth_token_str) = match transport {
+        let (id, run_token_str) = match transport {
             None => (uuid::Uuid::now_v7().to_string(), None),
-            Some(t) => (
-                t.session.run_id.clone(),
-                Some(t.session.auth_token.as_str()),
-            ),
+            Some(t) => (t.session.run_id.clone(), Some(t.session.run_token.as_str())),
         };
         let workspace_path = self.base_dir.join(&id).join("workspace");
 
@@ -135,7 +132,7 @@ impl Runs {
         self.supersede_existing(&db, &meta.r#ref)?;
 
         db.execute(
-            "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, queued_at_ms, workspace_path, auth_token)
+            "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, queued_at_ms, workspace_path, run_token)
              VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7, ?8)",
             rusqlite::params![
                 &id,
@@ -148,7 +145,7 @@ impl Runs {
                     std::io::ErrorKind::InvalidData,
                     "workspace path is not valid UTF-8",
                 ))?,
-                auth_token_str,
+                run_token_str,
             ],
         )?;
 
@@ -345,7 +342,7 @@ impl Run {
             Some(t) => {
                 cmd.env("QUIRE__RUN_ID", &t.session.run_id);
                 cmd.env("QUIRE__SERVER_URL", &t.session.server_url);
-                cmd.env("QUIRE__AUTH_TOKEN", &t.session.auth_token);
+                cmd.env("QUIRE__RUN_TOKEN", &t.session.run_token);
                 if t.mode == TransportMode::Api {
                     self.store_bootstrap_data(git_dir, sentry_trace_id)?;
                     cmd.env("QUIRE__TRANSPORT", "api");
@@ -717,9 +714,9 @@ pub fn materialize_workspace(git_dir: &Path, sha: &str, workspace: &Path) -> Res
 /// Mint a 32-character alphanumeric bearer token from the OS CSPRNG.
 ///
 /// ~190 bits of entropy, opaque to the holder. Used as the per-run
-/// auth secret for the API transport; stored in the `runs.auth_token`
-/// column and passed to quire-ci via `QUIRE_CI_TOKEN`.
-fn mint_auth_token() -> String {
+/// auth secret for the API transport; stored in the `runs.run_token`
+/// column and passed to quire-ci via `QUIRE__RUN_TOKEN`.
+fn mint_run_token() -> String {
     rand::rng()
         .sample_iter(&Alphanumeric)
         .take(32)
@@ -907,7 +904,7 @@ mod tests {
     }
 
     #[test]
-    fn create_with_filesystem_persists_auth_token() {
+    fn create_with_filesystem_persists_run_token() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let transport = new_transport(TransportMode::Filesystem, 3000);
@@ -918,20 +915,20 @@ mod tests {
         let conn = crate::db::open(&quire.db_path()).expect("db");
         let stored: Option<String> = conn
             .query_row(
-                "SELECT auth_token FROM runs WHERE id = ?1",
+                "SELECT run_token FROM runs WHERE id = ?1",
                 rusqlite::params![run.id()],
                 |row| row.get(0),
             )
             .expect("row");
         assert_eq!(
             stored.as_deref(),
-            Some(transport.session.auth_token.as_str()),
-            "filesystem transport should persist its minted auth token"
+            Some(transport.session.run_token.as_str()),
+            "filesystem transport should persist its minted run token"
         );
     }
 
     #[test]
-    fn create_with_api_persists_minted_auth_token() {
+    fn create_with_api_persists_minted_run_token() {
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
         let transport = new_transport(TransportMode::Api, 3000);
@@ -942,14 +939,14 @@ mod tests {
         let conn = crate::db::open(&quire.db_path()).expect("db");
         let stored: Option<String> = conn
             .query_row(
-                "SELECT auth_token FROM runs WHERE id = ?1",
+                "SELECT run_token FROM runs WHERE id = ?1",
                 rusqlite::params![run.id()],
                 |row| row.get(0),
             )
             .expect("row");
         assert_eq!(
             stored.as_deref(),
-            Some(transport.session.auth_token.as_str())
+            Some(transport.session.run_token.as_str())
         );
     }
 
@@ -966,15 +963,15 @@ mod tests {
             ),
         ] {
             assert_eq!(transport.session.server_url, expected_url);
-            assert_eq!(transport.session.auth_token.len(), 32);
+            assert_eq!(transport.session.run_token.len(), 32);
             assert!(
                 transport
                     .session
-                    .auth_token
+                    .run_token
                     .chars()
                     .all(|c| c.is_ascii_alphanumeric()),
                 "token should be alphanumeric, got {:?}",
-                transport.session.auth_token
+                transport.session.run_token
             );
             assert!(
                 uuid::Uuid::parse_str(&transport.session.run_id).is_ok(),
@@ -985,9 +982,9 @@ mod tests {
     }
 
     #[test]
-    fn mint_auth_token_returns_unique_values() {
-        let a = mint_auth_token();
-        let b = mint_auth_token();
+    fn mint_run_token_returns_unique_values() {
+        let a = mint_run_token();
+        let b = mint_run_token();
         assert_ne!(a, b, "two mints should not collide");
     }
 
diff --git a/quire-server/src/db.rs b/quire-server/src/db.rs
index 8bc3f9f..e699abc 100644
--- a/quire-server/src/db.rs
+++ b/quire-server/src/db.rs
@@ -16,6 +16,7 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
         M::up(include_str!("../migrations/0002_sh_events.sql")),
         M::up(include_str!("../migrations/0003_ci_api.sql")),
         M::up(include_str!("../migrations/0004_bootstrap_api.sql")),
+        M::up(include_str!("../migrations/0005_rename_run_token.sql")),
     ])
 });
 
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index a1da2f0..a75fe5d 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -2,7 +2,11 @@
 //!
 //! These routes use per-run bearer-token auth (not the Remote-User
 //! header auth used by the web UI). Each token is minted when the run
-//! is created and scoped to that run's ID.
+//! is created and stored in `runs.run_token`.
+//!
+//! Two route families are provided:
+//! - `/runs/{run_id}/…` — legacy; path carries the run UUID, bearer token is verified against it.
+//! - `/run/…` — token-only; no run ID in the path, the bearer token itself identifies the run.
 
 use std::collections::HashMap;
 use std::path::PathBuf;
@@ -21,11 +25,12 @@ use quire_core::ci::run::RunMeta;
 
 use crate::Quire;
 
-/// Build the API router. Routes under `/runs/{run_id}` are wrapped in
-/// [`verify_bearer`] middleware which authenticates the bearer token against
-/// the run's stored token before any handler runs.
+/// Build the API router. Intended to be mounted at `/api` via `Router::nest`.
 ///
-/// Intended to be mounted at `/api` via `Router::nest`.
+/// - `/runs/{run_id}/…` — [`verify_bearer`] checks the bearer token against the run's stored
+///   token before any handler runs.
+/// - `/run/…` — [`verify_run_token`] looks the run up by the bearer token itself (no run ID
+///   in the path) and injects the resolved run ID as a request extension.
 pub fn router(quire: Quire) -> axum::Router {
     let run_routes = axum::Router::new()
         .route("/bootstrap", axum::routing::get(get_bootstrap))
@@ -35,8 +40,17 @@ pub fn router(quire: Quire) -> axum::Router {
             verify_bearer,
         ));
 
+    let token_routes = axum::Router::new()
+        .route("/bootstrap", axum::routing::get(get_bootstrap_by_token))
+        .route("/secrets/{name}", axum::routing::get(get_secret))
+        .layer(axum::middleware::from_fn_with_state(
+            quire.clone(),
+            verify_run_token,
+        ));
+
     axum::Router::new()
         .nest("/runs/{run_id}", run_routes)
+        .nest("/run", token_routes)
         .with_state(quire)
 }
 
@@ -89,10 +103,9 @@ impl IntoResponse for ApiError {
     }
 }
 
-/// Middleware that authenticates requests under `/runs/{run_id}` by verifying
-/// the `Authorization: Bearer <token>` header against `runs.auth_token` in the
-/// DB. Returns 401 if the header is absent or the token doesn't match, 404 if
-/// the run doesn't exist.
+/// Middleware for `/runs/{run_id}/…`: verifies the `Authorization: Bearer <token>` header
+/// against `runs.run_token` in the DB. Returns 401 if the header is absent or the token
+/// doesn't match, 404 if the run doesn't exist.
 async fn verify_bearer(
     State(quire): State<Quire>,
     req: axum::extract::Request,
@@ -129,7 +142,7 @@ async fn verify_bearer(
     tokio::task::spawn_blocking(move || -> Result<(), ApiError> {
         let db = quire.db_pool().lock().expect("db mutex poisoned");
         let stored: Option<String> = db.query_row(
-            "SELECT auth_token FROM runs WHERE id = ?1",
+            "SELECT run_token FROM runs WHERE id = ?1",
             rusqlite::params![run_id],
             |row| row.get(0),
         )?;
@@ -144,21 +157,52 @@ async fn verify_bearer(
     Ok(next.run(req).await)
 }
 
-/// `GET /api/runs/:run_id/bootstrap`
-///
-/// Returns the bootstrap payload for a run. One-shot: the server marks
-/// bootstrap as fetched on the first successful read and returns 410 on
-/// any subsequent call. Auth is handled by [`verify_bearer`] middleware.
-///
-/// Returns 404 if the run does not have API bootstrap data (e.g. the run
-/// was created with filesystem transport and `store_bootstrap_data` was
-/// never called).
-async fn get_bootstrap(
+/// Middleware for `/run/…`: looks up the run by the `Authorization: Bearer <token>` header
+/// value against `runs.run_token`. Returns 401 if the header is absent or no run matches.
+/// On success, injects the resolved run ID as a request extension so handlers can use it.
+async fn verify_run_token(
     State(quire): State<Quire>,
-    AxumPath(params): AxumPath<HashMap<String, String>>,
-) -> Result<axum::Json<Bootstrap>, ApiError> {
-    let run_id = params.get("run_id").cloned().ok_or(ApiError::NotFound)?;
+    req: axum::extract::Request,
+    next: Next,
+) -> Result<Response, ApiError> {
+    let (mut parts, body) = req.into_parts();
+
+    let Some(TypedHeader(Authorization(bearer))) =
+        <TypedHeader<Authorization<Bearer>> as FromRequestParts<()>>::from_request_parts(
+            &mut parts,
+            &(),
+        )
+        .await
+        .ok()
+    else {
+        return Err(ApiError::Unauthorized);
+    };
+    let token = bearer.token().to_string();
 
+    let run_id = tokio::task::spawn_blocking(move || -> Result<String, ApiError> {
+        let db = quire.db_pool().lock().expect("db mutex poisoned");
+        let result: rusqlite::Result<String> = db.query_row(
+            "SELECT id FROM runs WHERE run_token = ?1",
+            rusqlite::params![token],
+            |row| row.get(0),
+        );
+        match result {
+            Ok(id) => Ok(id),
+            Err(rusqlite::Error::QueryReturnedNoRows) => Err(ApiError::Unauthorized),
+            Err(e) => Err(ApiError::Db(e)),
+        }
+    })
+    .await
+    .expect("blocking task panicked")?;
+
+    let mut req = axum::extract::Request::from_parts(parts, body);
+    req.extensions_mut().insert(run_id);
+    Ok(next.run(req).await)
+}
+
+/// Fetch and activate bootstrap data for `run_id`. One-shot: transitions the run from
+/// `pending` → `active` and returns 410 on any subsequent call.
+async fn fetch_bootstrap(quire: Quire, run_id: String) -> Result<axum::Json<Bootstrap>, ApiError> {
     let bootstrap =
         tokio::task::spawn_blocking(move || -> std::result::Result<Bootstrap, ApiError> {
             let db = crate::db::open(&quire.db_path())?;
@@ -213,6 +257,35 @@ async fn get_bootstrap(
     Ok(axum::Json(bootstrap))
 }
 
+/// `GET /api/runs/:run_id/bootstrap`
+///
+/// Returns the bootstrap payload for a run. One-shot: the server marks
+/// bootstrap as fetched on the first successful read and returns 410 on
+/// any subsequent call. Auth is handled by [`verify_bearer`] middleware.
+///
+/// Returns 404 if the run does not have API bootstrap data (e.g. the run
+/// was created with filesystem transport and `store_bootstrap_data` was
+/// never called).
+async fn get_bootstrap(
+    State(quire): State<Quire>,
+    AxumPath(params): AxumPath<HashMap<String, String>>,
+) -> Result<axum::Json<Bootstrap>, ApiError> {
+    let run_id = params.get("run_id").cloned().ok_or(ApiError::NotFound)?;
+    fetch_bootstrap(quire, run_id).await
+}
+
+/// `GET /api/run/bootstrap`
+///
+/// Token-only variant of [`get_bootstrap`]: no run ID in the path; the bearer token
+/// itself identifies the run. Auth and run-ID injection are handled by
+/// [`verify_run_token`] middleware.
+async fn get_bootstrap_by_token(
+    State(quire): State<Quire>,
+    axum::Extension(run_id): axum::Extension<String>,
+) -> Result<axum::Json<Bootstrap>, ApiError> {
+    fetch_bootstrap(quire, run_id).await
+}
+
 /// `GET /api/runs/:run_id/secrets/:name`
 ///
 /// Returns the plain-text value of a named secret from the global config.
@@ -339,7 +412,7 @@ mod tests {
             .expect("create");
         let url = format!("/runs/{}/secrets/no_such_secret", transport.session.run_id);
 
-        let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+        let resp = get(env.app(), &url, Some(&transport.session.run_token)).await;
         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
     }
 
@@ -396,7 +469,7 @@ mod tests {
             .expect("create run");
         let url = format!("/runs/{}/bootstrap", transport.session.run_id);
 
-        let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+        let resp = get(env.app(), &url, Some(&transport.session.run_token)).await;
         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
     }
 
@@ -407,7 +480,7 @@ mod tests {
         let run_id = create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
         let url = format!("/runs/{run_id}/bootstrap");
 
-        let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+        let resp = get(env.app(), &url, Some(&transport.session.run_token)).await;
         assert_eq!(resp.status(), StatusCode::OK);
 
         use http_body_util::BodyExt;
@@ -432,7 +505,7 @@ mod tests {
         .await;
         let url = format!("/runs/{run_id}/bootstrap");
 
-        let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+        let resp = get(env.app(), &url, Some(&transport.session.run_token)).await;
         assert_eq!(resp.status(), StatusCode::OK);
 
         use http_body_util::BodyExt;
@@ -450,7 +523,7 @@ mod tests {
         let transport = new_transport(TransportMode::Api, 3000);
         let run_id = create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
         let url = format!("/runs/{run_id}/bootstrap");
-        let token = &transport.session.auth_token;
+        let token = &transport.session.run_token;
 
         let first = get(env.app(), &url, Some(token)).await;
         assert_eq!(first.status(), StatusCode::OK);
@@ -466,7 +539,7 @@ mod tests {
         let run_id = create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
         let url = format!("/runs/{run_id}/bootstrap");
 
-        get(env.app(), &url, Some(&transport.session.auth_token)).await;
+        get(env.app(), &url, Some(&transport.session.run_token)).await;
 
         let db = crate::db::open(&env.quire.db_path()).expect("db open");
         let (state, started_at_ms): (String, Option<i64>) = db
@@ -495,7 +568,100 @@ mod tests {
             .expect("create");
         let url = format!("/runs/{}/secrets/my_token", transport.session.run_id);
 
-        let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+        let resp = get(env.app(), &url, Some(&transport.session.run_token)).await;
+        assert_eq!(resp.status(), StatusCode::OK);
+
+        use http_body_util::BodyExt;
+        let body = resp.into_body().collect().await.unwrap().to_bytes();
+        let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json body");
+        assert_eq!(parsed["value"], "hunter2");
+    }
+
+    // Token-only routes (/run/…)
+
+    #[tokio::test]
+    async fn token_route_bootstrap_returns_401_without_auth() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+
+        let resp = get(env.app(), "/run/bootstrap", None).await;
+        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+    }
+
+    #[tokio::test]
+    async fn token_route_bootstrap_returns_401_for_unknown_token() {
+        let env = TestEnv::new();
+
+        let resp = get(env.app(), "/run/bootstrap", Some("nosuchtoken")).await;
+        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+    }
+
+    #[tokio::test]
+    async fn token_route_bootstrap_returns_payload_on_first_fetch() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+
+        let resp = get(
+            env.app(),
+            "/run/bootstrap",
+            Some(&transport.session.run_token),
+        )
+        .await;
+        assert_eq!(resp.status(), StatusCode::OK);
+
+        use http_body_util::BodyExt;
+        let body = resp.into_body().collect().await.unwrap().to_bytes();
+        let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json body");
+        assert_eq!(parsed["git_dir"], "/repos/test.git");
+    }
+
+    #[tokio::test]
+    async fn token_route_bootstrap_returns_410_on_second_fetch() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+        let token = &transport.session.run_token;
+
+        let first = get(env.app(), "/run/bootstrap", Some(token)).await;
+        assert_eq!(first.status(), StatusCode::OK);
+
+        let second = get(env.app(), "/run/bootstrap", Some(token)).await;
+        assert_eq!(second.status(), StatusCode::GONE);
+    }
+
+    #[tokio::test]
+    async fn token_route_secret_returns_401_without_auth() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        env.runs()
+            .create(&TestEnv::meta(), Some(&transport))
+            .expect("create");
+
+        let resp = get(env.app(), "/run/secrets/my_secret", None).await;
+        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+    }
+
+    #[tokio::test]
+    async fn token_route_secret_returns_plaintext_value() {
+        let env = TestEnv::new();
+        fs_err::write(
+            env.quire.config_path(),
+            r#"{:secrets {:my_token "hunter2"}}"#,
+        )
+        .expect("write config");
+        let transport = new_transport(TransportMode::Api, 3000);
+        env.runs()
+            .create(&TestEnv::meta(), Some(&transport))
+            .expect("create");
+
+        let resp = get(
+            env.app(),
+            "/run/secrets/my_token",
+            Some(&transport.session.run_token),
+        )
+        .await;
         assert_eq!(resp.status(), StatusCode::OK);
 
         use http_body_util::BodyExt;