Skip to main content

awa_ui/
callback_router.rs

1//! Callback-only receiver router.
2//!
3//! Builds an axum router that exposes only the three callback ingress routes
4//! (`complete`, `fail`, `heartbeat`) — no admin REST, no static UI assets, no
5//! permissive CORS. Pair with the CLI subcommand `awa callbacks serve` to
6//! deploy callback ingress on its own host/port, separate from the admin UI.
7//!
8//! See ADR-027 for design rationale and the deployable-role split.
9//!
10//! ```no_run
11//! # use awa_ui::callback_router::{callback_router, CallbackReceiverConfig, CallbackAuth};
12//! # async fn build(pool: sqlx::PgPool) -> Result<axum::Router, Box<dyn std::error::Error>> {
13//! let secret = [7u8; 32];
14//! let router = callback_router(
15//!     pool,
16//!     CallbackReceiverConfig::new(CallbackAuth::Signed(secret)),
17//! )
18//! .await?;
19//! # Ok(router)
20//! # }
21//! ```
22
23use awa_model::callback_contract::DEFAULT_CALLBACK_PATH_PREFIX;
24use axum::routing::post;
25use axum::Router;
26use sqlx::PgPool;
27
28use crate::handlers;
29use crate::state::AppState;
30
31/// How an incoming callback request is authenticated.
32#[derive(Debug, Clone)]
33pub enum CallbackAuth {
34    /// BLAKE3 keyed-hash signature verification. Every request must carry a
35    /// valid `X-Awa-Signature` header. This is the only sensible mode for a
36    /// publicly-reachable callback receiver.
37    Signed([u8; 32]),
38    /// No signature verification. Callers must protect the receiver some
39    /// other way (private network, mTLS at the load balancer, IP allow-list,
40    /// etc.). The name is intentionally awkward — choose `Signed` unless you
41    /// have a clear story for the alternative.
42    Unsigned,
43}
44
45/// Configuration for the callback-only receiver router.
46#[derive(Debug, Clone)]
47pub struct CallbackReceiverConfig {
48    /// How inbound requests are authenticated.
49    pub auth: CallbackAuth,
50    /// Path prefix the three callback routes are mounted under. Defaults to
51    /// [`DEFAULT_CALLBACK_PATH_PREFIX`] (`/api/callbacks`), matching the
52    /// built-in `awa serve` layout so callback URLs produced by the worker
53    /// continue to work unchanged. Override when the receiver lives at a
54    /// different path (e.g. `/awa-cb`).
55    pub path_prefix: String,
56}
57
58impl CallbackReceiverConfig {
59    /// New config with the default `/api/callbacks` path prefix.
60    pub fn new(auth: CallbackAuth) -> Self {
61        Self {
62            auth,
63            path_prefix: DEFAULT_CALLBACK_PATH_PREFIX.to_string(),
64        }
65    }
66
67    pub fn with_path_prefix(mut self, prefix: impl Into<String>) -> Self {
68        self.path_prefix = prefix.into();
69        self
70    }
71}
72
73/// Build a router exposing only the callback ingress routes.
74///
75/// The returned router:
76/// - Mounts `POST {prefix}/{callback_id}/{complete,fail,heartbeat}`.
77/// - Does NOT serve static UI assets.
78/// - Does NOT expose admin REST routes.
79/// - Does NOT apply permissive CORS.
80/// - Requires writable database access — all three routes mutate job state.
81///   The pool is probed once at build time and the call fails if the pool
82///   reports `transaction_read_only = on`.
83pub async fn callback_router(
84    pool: PgPool,
85    config: CallbackReceiverConfig,
86) -> Result<Router, CallbackRouterBuildError> {
87    let read_only = crate::state::detect_read_only(&pool).await?;
88    if read_only {
89        return Err(CallbackRouterBuildError::ReadOnlyDatabase);
90    }
91
92    let secret = match config.auth {
93        CallbackAuth::Signed(secret) => Some(secret),
94        CallbackAuth::Unsigned => None,
95    };
96
97    // Cache TTL is unused on the callback path — pass any value; the handler
98    // does not touch the dashboard caches.
99    let state = AppState::new(pool, false, std::time::Duration::ZERO, secret);
100
101    let prefix = normalize_prefix(&config.path_prefix);
102    let routes = Router::new()
103        .route(
104            &format!("{prefix}/{{callback_id}}/complete"),
105            post(handlers::callbacks::complete_callback),
106        )
107        .route(
108            &format!("{prefix}/{{callback_id}}/fail"),
109            post(handlers::callbacks::fail_callback),
110        )
111        .route(
112            &format!("{prefix}/{{callback_id}}/heartbeat"),
113            post(handlers::callbacks::heartbeat_callback),
114        )
115        .with_state(state);
116
117    Ok(routes)
118}
119
120fn normalize_prefix(prefix: &str) -> String {
121    let trimmed = prefix.trim().trim_end_matches('/');
122    if trimmed.is_empty() {
123        String::new()
124    } else if trimmed.starts_with('/') {
125        trimmed.to_string()
126    } else {
127        format!("/{trimmed}")
128    }
129}
130
131/// Reasons `callback_router` can refuse to build.
132#[derive(Debug, thiserror::Error)]
133pub enum CallbackRouterBuildError {
134    /// The database pool resolves to a read-only connection. All three
135    /// callback routes mutate job state, so a read-only DB would surface as
136    /// runtime 503s on every request. Fail at build time instead.
137    #[error(
138        "callback receiver requires a writable database (pool reports transaction_read_only=on)"
139    )]
140    ReadOnlyDatabase,
141    /// Pool probe failure when reading `transaction_read_only`.
142    #[error("failed to probe database read-only state: {0}")]
143    Probe(#[from] sqlx::Error),
144}