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}