awa_model/
callback_contract.rs1use serde::{Deserialize, Serialize};
28
29pub const SIGNATURE_HEADER: &str = "X-Awa-Signature";
32
33pub const DEFAULT_HEARTBEAT_TIMEOUT_SECS: f64 = 3600.0;
36
37pub const DEFAULT_CALLBACK_PATH_PREFIX: &str = "/api/callbacks";
41
42pub 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
86pub 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
93pub 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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
103pub struct CompletePayload {
104 #[serde(default)]
105 pub payload: Option<serde_json::Value>,
106}
107
108#[derive(Debug, Clone, Deserialize, Serialize)]
110pub struct FailPayload {
111 pub error: String,
112}
113
114#[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#[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 #[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}