Skip to main content

awa_model/
callback_contract.rs

1//! HTTP callback ingress contract — wire types, signing, header names.
2//!
3//! Shared between:
4//! - `awa-worker`'s [`HttpWorker`](../../../awa_worker/http_worker/index.html)
5//!   which signs the outgoing callback id when posting to a function endpoint.
6//! - `awa-ui`'s built-in callback handler, which verifies incoming requests.
7//! - User-owned callback receivers (FastAPI/axum/etc.) that want to share the
8//!   exact same authentication and payload contract — see ADR-027.
9//!
10//! See ADR-018 (HTTP worker) and ADR-027 (callback ingress as a deployable
11//! surface).
12//!
13//! The signature scheme is BLAKE3 keyed-hash over the UTF-8 bytes of the
14//! callback id, rendered as lowercase hex. Verification uses the constant-time
15//! `PartialEq` implementation on `blake3::Hash`.
16//!
17//! ```no_run
18//! use awa_model::callback_contract::{sign, verify, SIGNATURE_HEADER};
19//!
20//! let secret = [7u8; 32];
21//! let id = "550e8400-e29b-41d4-a716-446655440000";
22//! let signature = sign(&secret, id);
23//! assert!(verify(&secret, id, &signature));
24//! assert_eq!(SIGNATURE_HEADER, "X-Awa-Signature");
25//! ```
26
27use serde::{Deserialize, Serialize};
28
29/// HTTP header carrying the hex-encoded BLAKE3 keyed-hash signature of the
30/// callback id.
31pub const SIGNATURE_HEADER: &str = "X-Awa-Signature";
32
33/// Default heartbeat timeout in seconds (1 hour). Used when an inbound
34/// heartbeat request omits `timeout_seconds`.
35pub const DEFAULT_HEARTBEAT_TIMEOUT_SECS: f64 = 3600.0;
36
37/// Path prefix used by `awa serve` and the built-in callback handler. The
38/// full URL of a callback action is
39/// `{callback_base_url}{prefix}/{callback_id}/{action}`.
40pub const DEFAULT_CALLBACK_PATH_PREFIX: &str = "/api/callbacks";
41
42/// Build the URL for a given callback action.
43///
44/// `base` is the externally-reachable URL of the receiver (e.g.
45/// `https://awa.example.com`). `prefix` is mounted under that base; an empty
46/// or whitespace-only prefix is permitted for receivers that expose the
47/// callback routes at the root. The output is normalized so callers cannot
48/// produce double slashes or accidentally drop the leading `/`:
49///
50/// - `base` has any trailing `/` stripped.
51/// - `prefix` is trimmed of surrounding whitespace and trailing `/`s; if it
52///   is non-empty and does not already start with `/`, one is prepended.
53/// - `action` is appended verbatim — pass `"complete"`, `"fail"`, or
54///   `"heartbeat"`.
55///
56/// ```
57/// # use awa_model::callback_contract::{callback_url, DEFAULT_CALLBACK_PATH_PREFIX};
58/// let url = callback_url(
59///     "https://awa.example.com",
60///     DEFAULT_CALLBACK_PATH_PREFIX,
61///     "550e8400-e29b-41d4-a716-446655440000",
62///     "complete",
63/// );
64/// assert_eq!(
65///     url,
66///     "https://awa.example.com/api/callbacks/550e8400-e29b-41d4-a716-446655440000/complete",
67/// );
68/// ```
69pub fn callback_url(base: &str, prefix: &str, callback_id: &str, action: &str) -> String {
70    let base = base.trim_end_matches('/');
71    let prefix = normalize_prefix(prefix);
72    format!("{base}{prefix}/{callback_id}/{action}")
73}
74
75fn normalize_prefix(prefix: &str) -> String {
76    let trimmed = prefix.trim().trim_end_matches('/');
77    if trimmed.is_empty() {
78        String::new()
79    } else if trimmed.starts_with('/') {
80        trimmed.to_string()
81    } else {
82        format!("/{trimmed}")
83    }
84}
85
86/// Sign a callback id with the shared secret. Returns lowercase hex.
87pub fn sign(secret: &[u8; 32], callback_id: &str) -> String {
88    blake3::keyed_hash(secret, callback_id.as_bytes())
89        .to_hex()
90        .to_string()
91}
92
93/// Verify a provided signature against the expected one. Returns `true` only
94/// when the signature is well-formed hex and equals the keyed hash of the
95/// callback id. Comparison is constant-time.
96pub fn verify(secret: &[u8; 32], callback_id: &str, signature: &str) -> bool {
97    let expected = blake3::keyed_hash(secret, callback_id.as_bytes());
98    blake3::Hash::from_hex(signature).is_ok_and(|h| expected == h)
99}
100
101/// Body of `POST /api/callbacks/{id}/complete`.
102#[derive(Debug, Clone, Default, Deserialize, Serialize)]
103pub struct CompletePayload {
104    #[serde(default)]
105    pub payload: Option<serde_json::Value>,
106}
107
108/// Body of `POST /api/callbacks/{id}/fail`.
109#[derive(Debug, Clone, Deserialize, Serialize)]
110pub struct FailPayload {
111    pub error: String,
112}
113
114/// Body of `POST /api/callbacks/{id}/heartbeat`. `timeout_seconds` defaults to
115/// [`DEFAULT_HEARTBEAT_TIMEOUT_SECS`] when omitted.
116#[derive(Debug, Clone, Deserialize, Serialize)]
117pub struct HeartbeatPayload {
118    #[serde(default = "default_heartbeat_timeout")]
119    pub timeout_seconds: f64,
120}
121
122impl Default for HeartbeatPayload {
123    fn default() -> Self {
124        Self {
125            timeout_seconds: DEFAULT_HEARTBEAT_TIMEOUT_SECS,
126        }
127    }
128}
129
130fn default_heartbeat_timeout() -> f64 {
131    DEFAULT_HEARTBEAT_TIMEOUT_SECS
132}
133
134/// Response body returned by the built-in callback handler. User-owned
135/// receivers SHOULD mirror this shape so generic clients work against any
136/// receiver implementation.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct CallbackResponse {
139    pub id: i64,
140    pub state: String,
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn sign_and_verify_roundtrip() {
149        let secret = [3u8; 32];
150        let id = "abc";
151        let sig = sign(&secret, id);
152        assert!(verify(&secret, id, &sig));
153    }
154
155    #[test]
156    fn verify_rejects_wrong_secret() {
157        let secret = [3u8; 32];
158        let other = [4u8; 32];
159        let id = "abc";
160        let sig = sign(&secret, id);
161        assert!(!verify(&other, id, &sig));
162    }
163
164    #[test]
165    fn verify_rejects_wrong_id() {
166        let secret = [3u8; 32];
167        let sig = sign(&secret, "abc");
168        assert!(!verify(&secret, "abd", &sig));
169    }
170
171    #[test]
172    fn verify_rejects_malformed_signature() {
173        let secret = [3u8; 32];
174        assert!(!verify(&secret, "abc", "not-hex"));
175        assert!(!verify(&secret, "abc", ""));
176    }
177
178    #[test]
179    fn heartbeat_default_uses_constant() {
180        let payload: HeartbeatPayload =
181            serde_json::from_str("{}").expect("empty object should deserialize");
182        assert_eq!(payload.timeout_seconds, DEFAULT_HEARTBEAT_TIMEOUT_SECS);
183    }
184
185    #[test]
186    fn callback_url_default_path() {
187        let url = callback_url(
188            "https://awa.example.com",
189            DEFAULT_CALLBACK_PATH_PREFIX,
190            "abc",
191            "complete",
192        );
193        assert_eq!(url, "https://awa.example.com/api/callbacks/abc/complete");
194    }
195
196    #[test]
197    fn callback_url_custom_prefix() {
198        let url = callback_url("https://api.example.com", "/awa-cb", "abc", "fail");
199        assert_eq!(url, "https://api.example.com/awa-cb/abc/fail");
200    }
201
202    #[test]
203    fn callback_url_strips_trailing_slashes() {
204        let url = callback_url("https://api.example.com/", "/awa-cb/", "abc", "heartbeat");
205        assert_eq!(url, "https://api.example.com/awa-cb/abc/heartbeat");
206    }
207
208    #[test]
209    fn callback_url_normalizes_missing_leading_slash() {
210        let url = callback_url("https://api.example.com", "awa-cb", "abc", "complete");
211        assert_eq!(url, "https://api.example.com/awa-cb/abc/complete");
212    }
213
214    #[test]
215    fn callback_url_handles_empty_prefix() {
216        let url = callback_url("https://api.example.com", "", "abc", "complete");
217        assert_eq!(url, "https://api.example.com/abc/complete");
218        let url = callback_url("https://api.example.com", "   ", "abc", "complete");
219        assert_eq!(url, "https://api.example.com/abc/complete");
220    }
221
222    /// Pin a known signing output so silent algorithm changes break this
223    /// test. Same vector is asserted from Python in
224    /// `awa-python/tests/test_callback_contract.py::test_known_test_vector`
225    /// so the two language bindings cannot drift.
226    #[test]
227    fn sign_pinned_test_vector() {
228        let secret = [7u8; 32];
229        let signature = sign(&secret, "abc");
230        assert_eq!(
231            signature,
232            "b1495225f01fa8cd09e410d288d09b68c1cc9fb2414686c0d3ac13fc905497d9",
233        );
234    }
235
236    #[test]
237    fn callback_url_nested_prefix() {
238        let url = callback_url(
239            "https://api.example.com",
240            "/jobs/v1/callbacks",
241            "abc",
242            "complete",
243        );
244        assert_eq!(
245            url,
246            "https://api.example.com/jobs/v1/callbacks/abc/complete"
247        );
248    }
249}