Skip to main content

cratefield_core/
http.rs

1//! HTTP plumbing every venture router shares (issue #2): the problem+json
2//! `Json` and `Form` extractors, the request-id middleware that creates
3//! the [`Scope`], and the `/v1/*` security headers.
4
5use axum::body::Body;
6use axum::extract::{FromRequest, Request};
7use axum::http::{HeaderValue, Request as HttpRequest, header};
8use axum::middleware::Next;
9use axum::response::{IntoResponse, Response as AxumResponse};
10use serde::de::DeserializeOwned;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::module::HARNESS_API;
15use crate::ports::{Defer, IdGen};
16use crate::problem::Problem;
17use crate::scope::Scope;
18use crate::sidecar::{X_HARNESS_API, X_HARNESS_MODULE};
19use tracing::info_span;
20
21/// `x-request-id`: accepted from the client when it matches
22/// `^[A-Za-z0-9_-]{8,128}$`, otherwise generated as a ULID. Always set on
23/// the response (architecture section 6).
24pub const X_REQUEST_ID: &str = "x-request-id";
25
26/// Default request body limit for `/v1/*` JSON endpoints.
27pub const MAX_BODY_BYTES: usize = 64 * 1024;
28
29// `Duration::from_days` is unstable on the pinned toolchain
30// (`duration_constructors`), so this stays in seconds.
31#[allow(clippy::duration_suboptimal_units)]
32const CORS_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(86_400);
33
34/// The character class and length bounds of an accepted request id.
35pub fn request_id_is_valid(value: &str) -> bool {
36    (8..=128).contains(&value.len())
37        && value
38            .bytes()
39            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
40}
41
42/// State the request-id layer needs, resolved from `Ports` when the router
43/// is assembled.
44#[derive(Clone)]
45pub(crate) struct ScopeState {
46    pub defer: Arc<dyn Defer>,
47    pub id_gen: Arc<dyn IdGen>,
48    /// The one module this deployment serves, when it serves exactly one —
49    /// the sidecar shape (ADR 0009). Set, the response carries
50    /// `x-harness-module` next to [`crate::sidecar::X_HARNESS_API`]; the
51    /// host reads both on every forwarded response, which is how a sidecar
52    /// redeployed against a different contract is caught within one
53    /// request instead of at a cold start that isolates do not have.
54    pub module: Option<&'static str>,
55}
56
57/// Middleware: resolve the request id, build the [`Scope`] (request id,
58/// defer, tracing span), insert it into extensions, echo the id on the
59/// response.
60pub(crate) async fn scope_layer(
61    axum::extract::State(state): axum::extract::State<ScopeState>,
62    mut request: Request,
63    next: Next,
64) -> AxumResponse {
65    use tracing::Instrument as _;
66    use tracing::field::Empty;
67
68    let incoming = request
69        .headers()
70        .get(X_REQUEST_ID)
71        .and_then(|value| value.to_str().ok())
72        .filter(|value| request_id_is_valid(value));
73    let request_id = match incoming {
74        Some(valid) => valid.to_owned(),
75        None => state.id_gen.ulid(),
76    };
77
78    // The one structured span per request (issue #14). `route`,
79    // `module`, `status` and `duration_ms` are recorded after the
80    // handler runs; no field ever carries an email (only `ip_hash`).
81    let method = request.method().as_str().to_owned();
82    let ip_hash = crate::logging::subject_hash(
83        &crate::rate_limit::client_ip(request.headers()).unwrap_or_default(),
84    );
85    let ua_family = request
86        .headers()
87        .get(header::USER_AGENT)
88        .and_then(|value| value.to_str().ok())
89        .map_or_else(|| "unknown".to_owned(), ua_family_of);
90    let span = info_span!(
91        "request",
92        request_id = %request_id,
93        method = %method,
94        route = Empty,
95        module = Empty,
96        status = Empty,
97        duration_ms = Empty,
98        ip_hash = %ip_hash,
99        ua_family = %ua_family,
100    );
101
102    let scope = Scope {
103        defer: Arc::clone(&state.defer),
104        span: span.clone(),
105        request_id: request_id.clone(),
106    };
107    request.extensions_mut().insert(scope);
108
109    let route = request
110        .extensions()
111        .get::<axum::extract::MatchedPath>()
112        .map(|matched| matched.as_str().to_owned())
113        .unwrap_or_default();
114
115    // `std::time::Instant::now()` panics on wasm32-unknown-unknown with
116    // "time not implemented on this platform", which took down every
117    // request on Workers — `/__health` included. There is no monotonic
118    // clock in that target, and `Date.now()` is frozen between I/O in
119    // workerd, so a wall-clock delta would read 0 and look measured.
120    // Timing is therefore recorded only where a real clock exists;
121    // Cloudflare's own request logs carry it on Workers.
122    #[cfg(not(target_arch = "wasm32"))]
123    let started = std::time::Instant::now();
124    let future = next.run(request);
125    let mut response = future.instrument(span.clone()).await;
126
127    if let Ok(value) = HeaderValue::from_str(&request_id) {
128        response.headers_mut().insert(X_REQUEST_ID, value);
129    }
130    // The identity stamp (issue #61): one insert, on every response, and
131    // never a cold-start subrequest. A sidecar is built and deployed
132    // separately, so the compile-time `Harness::build` contract check does
133    // not cover it; the host instead reads this header back on every
134    // forwarded response and refuses the prefix on a wrong number. That
135    // catches a sidecar redeployed under a warm host within one request —
136    // an isolate can outlive the sidecar for hours, so a cached cold-start
137    // verdict would keep serving a contract that no longer holds.
138    if let Ok(value) = HeaderValue::from_str(&HARNESS_API.to_string()) {
139        response.headers_mut().insert(X_HARNESS_API, value);
140    }
141    // The module name rides along so a host mounting several sidecars can
142    // tell whose answer it is looking at. It is only well-defined when the
143    // deployment serves exactly one module — the sidecar shape — so a
144    // multi-module host stays silent rather than stamping a guess.
145    if let Some(name) = state.module
146        && let Ok(value) = HeaderValue::from_str(name)
147    {
148        response.headers_mut().insert(X_HARNESS_MODULE, value);
149    }
150    span.record("route", route.as_str());
151    span.record(
152        "module",
153        route
154            .strip_prefix("/v1/")
155            .and_then(|rest| rest.split('/').next())
156            .unwrap_or_default(),
157    );
158    span.record("status", response.status().as_u16());
159    #[cfg(not(target_arch = "wasm32"))]
160    {
161        let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
162        span.record("duration_ms", duration_ms);
163    }
164    response
165}
166
167/// Coarse user-agent family: the first product token, lowercased —
168/// enough to group browsers, bots and libraries without a UA parser.
169fn ua_family_of(user_agent: &str) -> String {
170    let token = user_agent
171        .split(['/', ' ', ';', '('])
172        .next()
173        .unwrap_or_default()
174        .to_lowercase();
175    let truncated: String = token.chars().take(24).collect();
176    if truncated.is_empty() {
177        "unknown".to_owned()
178    } else {
179        truncated
180    }
181}
182
183/// Middleware: `/v1/*` responses carry
184/// `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` and
185/// `Referrer-Policy: no-referrer` (architecture section 6).
186pub(crate) async fn security_headers_layer(request: Request, next: Next) -> AxumResponse {
187    let is_api = request.uri().path().starts_with("/v1/");
188    let mut response = next.run(request).await;
189    if is_api {
190        insert_no_store_headers(response.headers_mut());
191    }
192    response
193}
194
195/// Middleware for token-bearing URLs (issue #135). A `token` query
196/// parameter is the credential — confirmation links and
197/// `/ui/waitlist/status?token=…` authenticate with the URL itself, and
198/// `/ui/*` is outside the `/v1/*` rule above. Any response to a request
199/// whose query carries a `token` parameter therefore gets the same
200/// no-store headers at the root: no cache may keep the credential and no
201/// outbound navigation may leak it through `Referer`.
202pub(crate) async fn token_response_layer(request: Request, next: Next) -> AxumResponse {
203    let carries_token = request.uri().query().is_some_and(|query| {
204        query
205            .split('&')
206            .any(|pair| pair == "token" || pair.starts_with("token="))
207    });
208    let mut response = next.run(request).await;
209    if carries_token {
210        insert_no_store_headers(response.headers_mut());
211    }
212    response
213}
214
215fn insert_no_store_headers(headers: &mut axum::http::HeaderMap) {
216    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
217    headers.insert(
218        header::X_CONTENT_TYPE_OPTIONS,
219        HeaderValue::from_static("nosniff"),
220    );
221    headers.insert(
222        header::HeaderName::from_static("referrer-policy"),
223        HeaderValue::from_static("no-referrer"),
224    );
225}
226
227/// A `Json` extractor and response whose rejections and serializations are
228/// problem+json (architecture section 6). Deserialization failures become a
229/// `400 validation-failed` problem listing the field.
230pub struct Json<T>(pub T);
231
232impl<T, S> FromRequest<S> for Json<T>
233where
234    T: DeserializeOwned,
235    S: Send + Sync,
236{
237    type Rejection = Problem;
238
239    async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
240        let instance = request
241            .extensions()
242            .get::<Scope>()
243            .map(|scope| scope.request_id.clone());
244        match axum::Json::<T>::from_request(request, state).await {
245            Ok(axum::Json(value)) => Ok(Json(value)),
246            Err(rejection) => {
247                // Body reads fail through the shared 413 slug (the size
248                // limit); everything else is a 400 validation problem.
249                let mut problem = match &rejection {
250                    axum::extract::rejection::JsonRejection::BytesRejection(_) => {
251                        Problem::request_too_large()
252                    }
253                    _ => Problem::validation_failed(rejection.body_text()),
254                };
255                if let Some(instance) = instance {
256                    problem = problem.instance(&instance);
257                }
258                Err(problem)
259            }
260        }
261    }
262}
263
264impl<T: serde::Serialize> IntoResponse for Json<T> {
265    fn into_response(self) -> AxumResponse {
266        axum::Json(self.0).into_response()
267    }
268}
269
270/// A form (`application/x-www-form-urlencoded`) extractor whose
271/// rejections are problem+json with the same shape as [`Json`]'s: body
272/// reads fail through the shared 413 slug (the size limit), everything
273/// else is a 400 validation problem. Needed for cross-site `form_post`
274/// callbacks (issue #46).
275pub struct Form<T>(pub T);
276
277impl<T, S> FromRequest<S> for Form<T>
278where
279    T: DeserializeOwned,
280    S: Send + Sync,
281{
282    type Rejection = Problem;
283
284    async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
285        let instance = request
286            .extensions()
287            .get::<Scope>()
288            .map(|scope| scope.request_id.clone());
289        match axum::Form::<T>::from_request(request, state).await {
290            Ok(axum::Form(value)) => Ok(Form(value)),
291            Err(rejection) => {
292                let mut problem = match &rejection {
293                    axum::extract::rejection::FormRejection::BytesRejection(_) => {
294                        Problem::request_too_large()
295                    }
296                    _ => Problem::validation_failed(rejection.body_text()),
297                };
298                if let Some(instance) = instance {
299                    problem = problem.instance(&instance);
300                }
301                Err(problem)
302            }
303        }
304    }
305}
306
307/// A `429 rate-limited` problem carrying `Retry-After: <seconds>` when the
308/// limiter reported a pause (architecture section 6).
309pub fn rate_limited(retry_after: Option<Duration>) -> AxumResponse {
310    let problem = Problem::new(&crate::problems::SLUGS.rate_limited);
311    let mut response = problem.into_response();
312    if let Some(pause) = retry_after {
313        let secs = pause.as_secs().max(1);
314        if let Ok(value) = HeaderValue::from_str(&secs.to_string()) {
315            response
316                .headers_mut()
317                .insert(header::HeaderName::from_static("retry-after"), value);
318        }
319    }
320    response
321}
322
323/// CORS allowlist from the venture's origins; never a wildcard
324/// (architecture section 6). Tower-http echoes the matched origin rather
325/// than emitting `*`, and requests from other origins get no CORS headers.
326///
327/// `PUT` and `Authorization` are on the list because a browser client on
328/// the venture's own site is a cross-origin caller: `cf.js` is served
329/// from the API origin but runs on the site's, and registering for
330/// notifications is `PUT /v1/notifications/subscriptions` with a bearer
331/// token (issue #183). Left off, the preflight fails and the fetch never
332/// leaves the browser — with a console message and no server-side trace
333/// at all.
334///
335/// Credentials stay off, which is what keeps that safe: no cookie is ever
336/// attached to a cross-origin request, so allowing the header only lets a
337/// script send a token it already holds and had to be given deliberately.
338pub(crate) fn cors_layer(origins: &[String]) -> tower_http::cors::CorsLayer {
339    use tower_http::cors::{AllowOrigin, CorsLayer};
340    let allowed: Vec<HeaderValue> = origins
341        .iter()
342        .filter_map(|origin| HeaderValue::from_str(origin).ok())
343        .collect();
344    CorsLayer::new()
345        .allow_origin(AllowOrigin::list(allowed))
346        .allow_methods([
347            axum::http::Method::GET,
348            axum::http::Method::POST,
349            axum::http::Method::PUT,
350            axum::http::Method::DELETE,
351            axum::http::Method::OPTIONS,
352        ])
353        .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
354        .max_age(CORS_PREFLIGHT_MAX_AGE)
355}