diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 660b3a76..9449700d 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -414,17 +414,82 @@ async fn ensure_config_org_avatar(token: &str) -> Result<()> { Ok(()) } +/// Outcome of probing whether the persisted core token still works +/// against the *current* forge. Existence on disk is not validity: a +/// token minted before a forge rebuild / re-provision is unknown to the +/// new forge's DB and 401s on every call — which silently breaks the +/// hive-ci runner-registration prefetch (it reads this same token to +/// fetch a runner registration token). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CoreTokenCheck { + /// Token authenticated successfully — keep using it. + Valid, + /// Forge explicitly rejected the token (401/403) — re-mint. + Invalid, + /// Couldn't determine (forge unreachable / 5xx). Don't re-mint on a + /// transient: keep the existing token and let a later ensure pass + /// re-check once the forge is responsive. Re-minting here would both + /// fail (mint needs the forge too) and churn tokens needlessly. + Indeterminate, +} + +/// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`]. +/// Pure so the decision logic is unit-testable without a live forge. +fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck { + if status.is_success() { + CoreTokenCheck::Valid + } else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { + CoreTokenCheck::Invalid + } else { + CoreTokenCheck::Indeterminate + } +} + +/// Probe whether `token` is still accepted by the current forge with a +/// cheap authenticated `GET /api/v1/user` (covered by the core token's +/// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is +/// interpreted. +async fn check_core_token(token: &str) -> CoreTokenCheck { + let url = format!("{FORGE_HTTP}/api/v1/user"); + match forge_http(reqwest::Method::GET, &url, token, "").await { + Ok(status) => classify_core_token_status(status), + Err(e) => { + tracing::debug!( + error = %e, + "forge: core-token probe could not reach forge; treating as indeterminate" + ); + CoreTokenCheck::Indeterminate + } + } +} + /// Ensure the bootstrap `core` admin user + a token at /// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo -/// API calls (org creation now, meta-repo push later). Returns the -/// token. Idempotent: skips creation when user exists, skips token -/// when the file is present. +/// API calls (org creation, meta-repo push, and the hive-ci +/// runner-registration prefetch). Returns the token. +/// +/// Idempotent, but validity-aware: when a token file is already present +/// it is **probed against the current forge** before being trusted. A +/// token persisted before a forge rebuild / re-provision is stale (the +/// new forge DB doesn't know it) and would 401 every caller — so on a +/// definitive rejection the token is re-minted. A merely-unreachable +/// forge leaves the existing token in place (a later ensure pass +/// re-checks) rather than churning tokens on a transient. async fn ensure_core_user_and_token() -> Result { let path = std::path::Path::new(CORE_TOKEN_PATH); if let Ok(existing) = std::fs::read_to_string(path) { let trimmed = existing.trim().to_owned(); if !trimmed.is_empty() { - return Ok(trimmed); + match check_core_token(&trimmed).await { + CoreTokenCheck::Valid | CoreTokenCheck::Indeterminate => return Ok(trimmed), + CoreTokenCheck::Invalid => { + tracing::warn!( + path = %path.display(), + "forge: persisted core token rejected by forge (stale after rebuild?); \ + re-minting" + ); + } + } } } ensure_user_exists("core", true, None).await?; @@ -868,3 +933,54 @@ pub async fn ensure_all() { sync_agent(name, core_token.as_deref()).await; } } + +#[cfg(test)] +mod tests { + use super::{CoreTokenCheck, classify_core_token_status}; + use reqwest::StatusCode; + + #[test] + fn success_statuses_are_valid() { + assert_eq!( + classify_core_token_status(StatusCode::OK), + CoreTokenCheck::Valid + ); + assert_eq!( + classify_core_token_status(StatusCode::NO_CONTENT), + CoreTokenCheck::Valid + ); + } + + #[test] + fn auth_rejection_statuses_are_invalid() { + // The whole point: a stale token (forge rebuilt out from under it) + // 401s, and 401/403 are the only outcomes that trigger a re-mint. + assert_eq!( + classify_core_token_status(StatusCode::UNAUTHORIZED), + CoreTokenCheck::Invalid + ); + assert_eq!( + classify_core_token_status(StatusCode::FORBIDDEN), + CoreTokenCheck::Invalid + ); + } + + #[test] + fn transient_and_unexpected_statuses_are_indeterminate() { + // Never re-mint on a transient — minting needs the forge too, and + // churning tokens on a blip is worse than keeping the existing one. + for s in [ + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::BAD_GATEWAY, + StatusCode::SERVICE_UNAVAILABLE, + StatusCode::GATEWAY_TIMEOUT, + StatusCode::NOT_FOUND, + ] { + assert_eq!( + classify_core_token_status(s), + CoreTokenCheck::Indeterminate, + "status {s} should be indeterminate" + ); + } + } +}