Skip to main content

hyphae_server/
server.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::{
4    future::Future,
5    net::SocketAddr,
6    path::PathBuf,
7    sync::{
8        Arc, Mutex,
9        atomic::{AtomicBool, Ordering},
10    },
11    time::Duration,
12};
13
14use axum::{
15    Router,
16    body::{self, Body},
17    extract::{Extension, Request, State},
18    http::{HeaderMap, HeaderValue, StatusCode, header},
19    middleware::{self, Next},
20    response::{IntoResponse, Response},
21    routing::{get, post},
22};
23use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
24use hyphae_contracts::v1::{
25    CapabilitiesV1, CommitReceiptV1, DeleteRequestV1, GetRequestV1, GetResponseV1, HealthV1,
26    ProofV1, PutRequestV1, QueryRequestV1, QueryResponseV1, RecordV1, WitnessV1, decode_key_hex,
27    encode_hex,
28};
29use hyphae_core::current_version;
30use hyphae_engine::{EngineError, HyphaeEngine, ProofError, ProvenResult, ResultProofArtifact};
31use hyphae_storage::{AppendOutcome, LogError, SnapshotError, StorageError, verify_snapshot};
32use serde::{Serialize, de::DeserializeOwned};
33use tokio::{net::TcpListener, sync::Semaphore};
34use tokio_util::io::ReaderStream;
35use uuid::Uuid;
36
37use crate::{ApiError, BearerToken, ServerConfig, ServerError, ServerLimits};
38
39const FEATURES: [&str; 7] = [
40    "atomic_batch",
41    "deterministic_query",
42    "idempotency",
43    "kv",
44    "offline_result_proof",
45    "snapshot_witness",
46    "structured_aggregation",
47];
48
49#[derive(Clone, Debug)]
50struct RequestId(String);
51
52struct ServerState {
53    engine: Arc<Mutex<HyphaeEngine>>,
54    data_dir: PathBuf,
55    limits: ServerLimits,
56    bearer_token: Option<BearerToken>,
57    admission: Arc<Semaphore>,
58    ready: AtomicBool,
59}
60
61/// Opened optional HTTP surface owning exactly one embedded Hyphae engine.
62pub struct HyphaeServer {
63    bind: SocketAddr,
64    state: Arc<ServerState>,
65}
66
67impl HyphaeServer {
68    /// Validates secure defaults and opens the exclusively owned engine.
69    ///
70    /// No socket is opened by this method. In particular, a non-loopback bind
71    /// without authentication fails here before [`Self::bind`].
72    ///
73    /// # Errors
74    ///
75    /// Returns a configuration, data-directory lock, recovery, or corruption
76    /// error.
77    pub fn open(config: ServerConfig) -> Result<Self, ServerError> {
78        config.validate()?;
79        let opened = HyphaeEngine::open(config.data_dir())?;
80        let data_dir = opened.engine.data_path().to_path_buf();
81        Ok(Self {
82            bind: config.bind,
83            state: Arc::new(ServerState {
84                engine: Arc::new(Mutex::new(opened.engine)),
85                data_dir,
86                admission: Arc::new(Semaphore::new(config.limits.concurrent_operations)),
87                ready: AtomicBool::new(true),
88                limits: config.limits,
89                bearer_token: config.bearer_token,
90            }),
91        })
92    }
93
94    /// Opens the configured TCP listener and prepares graceful serving.
95    ///
96    /// # Errors
97    ///
98    /// Returns an operating-system socket bind failure.
99    pub async fn bind(self) -> Result<BoundServer, ServerError> {
100        let listener = TcpListener::bind(self.bind)
101            .await
102            .map_err(|source| ServerError::Bind {
103                address: self.bind,
104                source,
105            })?;
106        let local_addr = listener.local_addr().map_err(|source| ServerError::Bind {
107            address: self.bind,
108            source,
109        })?;
110        Ok(BoundServer {
111            listener,
112            local_addr,
113            router: build_router(self.state),
114        })
115    }
116
117    #[cfg(test)]
118    fn test_router(&self) -> Router {
119        build_router(Arc::clone(&self.state))
120    }
121}
122
123/// Successfully bound HTTP service awaiting a shutdown signal.
124pub struct BoundServer {
125    listener: TcpListener,
126    local_addr: SocketAddr,
127    router: Router,
128}
129
130impl BoundServer {
131    /// Returns the actual local address, including an assigned ephemeral port.
132    pub fn local_addr(&self) -> SocketAddr {
133        self.local_addr
134    }
135
136    /// Serves until the supplied graceful-shutdown future resolves.
137    ///
138    /// # Errors
139    ///
140    /// Returns an HTTP listener/service I/O failure.
141    pub async fn run_with_shutdown<F>(self, shutdown: F) -> Result<(), ServerError>
142    where
143        F: Future<Output = ()> + Send + 'static,
144    {
145        axum::serve(self.listener, self.router)
146            .with_graceful_shutdown(shutdown)
147            .await
148            .map_err(ServerError::Serve)
149    }
150}
151
152fn build_router(state: Arc<ServerState>) -> Router {
153    let public = Router::new()
154        .route("/v1/capabilities", get(capabilities))
155        .route("/v1/health/live", get(liveness))
156        .route("/v1/health/ready", get(readiness));
157    let protected = Router::new()
158        .route("/v1/kv/put", post(put_records))
159        .route("/v1/kv/get", post(get_record))
160        .route("/v1/kv/delete", post(delete_records))
161        .route("/v1/query", post(query_records))
162        .route(
163            "/v1/witnesses/{checkpoint_sequence}/{snapshot_digest}",
164            get(download_witness),
165        )
166        .route_layer(middleware::from_fn_with_state(
167            Arc::clone(&state),
168            authenticate,
169        ));
170
171    public
172        .merge(protected)
173        .fallback(route_not_found)
174        .method_not_allowed_fallback(method_not_allowed)
175        .with_state(state)
176        .layer(middleware::from_fn(assign_request_id))
177}
178
179async fn assign_request_id(mut request: Request, next: Next) -> Response {
180    let request_id = RequestId(Uuid::now_v7().to_string());
181    request.extensions_mut().insert(request_id.clone());
182    let mut response = next.run(request).await;
183    if let Ok(value) = HeaderValue::from_str(&request_id.0) {
184        response.headers_mut().insert("x-request-id", value);
185    }
186    response
187}
188
189async fn authenticate(
190    State(state): State<Arc<ServerState>>,
191    request: Request,
192    next: Next,
193) -> Response {
194    let Some(expected) = &state.bearer_token else {
195        return next.run(request).await;
196    };
197    let request_id = request_id(&request);
198    if bearer_candidate(request.headers()).is_some_and(|candidate| expected.verifies(candidate)) {
199        return next.run(request).await;
200    }
201    ApiError::new(
202        StatusCode::UNAUTHORIZED,
203        "unauthorized",
204        "valid bearer authentication is required",
205        request_id,
206    )
207    .into_response()
208}
209
210fn bearer_candidate(headers: &HeaderMap) -> Option<&[u8]> {
211    let mut values = headers.get_all(header::AUTHORIZATION).iter();
212    let value = values.next()?;
213    if values.next().is_some() {
214        return None;
215    }
216    let value = value.as_bytes();
217    let separator = value.iter().position(|byte| *byte == b' ')?;
218    if !value[..separator].eq_ignore_ascii_case(b"bearer") {
219        return None;
220    }
221    let candidate = &value[separator.saturating_add(1)..];
222    (!candidate.is_empty()).then_some(candidate)
223}
224
225async fn liveness(
226    State(state): State<Arc<ServerState>>,
227    Extension(request_id): Extension<RequestId>,
228) -> Result<Response, ApiError> {
229    bounded_json(
230        &HealthV1 {
231            status: "live".to_owned(),
232        },
233        &state,
234        &request_id.0,
235    )
236}
237
238async fn readiness(
239    State(state): State<Arc<ServerState>>,
240    Extension(request_id): Extension<RequestId>,
241) -> Result<Response, ApiError> {
242    if !state.ready.load(Ordering::Acquire) {
243        return Err(ApiError::unavailable(&request_id.0));
244    }
245    bounded_json(
246        &HealthV1 {
247            status: "ready".to_owned(),
248        },
249        &state,
250        &request_id.0,
251    )
252}
253
254async fn capabilities(
255    State(state): State<Arc<ServerState>>,
256    Extension(request_id): Extension<RequestId>,
257) -> Result<Response, ApiError> {
258    let version = current_version();
259    bounded_json(
260        &CapabilitiesV1 {
261            api_version: version.api.to_owned(),
262            disk_format_version: version.disk_format,
263            features: FEATURES.iter().map(ToString::to_string).collect(),
264            limits: state.limits.as_contract(),
265        },
266        &state,
267        &request_id.0,
268    )
269}
270
271async fn put_records(
272    State(state): State<Arc<ServerState>>,
273    Extension(request_id): Extension<RequestId>,
274    request: Request,
275) -> Result<Response, ApiError> {
276    let request: PutRequestV1 = parse_json(request, &state, &request_id.0).await?;
277    validate_batch(request.records.len(), &state, &request_id.0)?;
278    let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
279    let records = request
280        .records
281        .iter()
282        .map(RecordV1::to_domain)
283        .collect::<Result<Vec<_>, _>>()
284        .map_err(|_| ApiError::invalid(&request_id.0))?;
285    let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
286        capture_write_outcome(engine.put_records(transaction_id, &records))
287    })
288    .await?;
289    if outcome.requires_recovery {
290        state.ready.store(false, Ordering::Release);
291    }
292    bounded_json(&receipt(outcome.append), &state, &request_id.0)
293}
294
295async fn delete_records(
296    State(state): State<Arc<ServerState>>,
297    Extension(request_id): Extension<RequestId>,
298    request: Request,
299) -> Result<Response, ApiError> {
300    let request: DeleteRequestV1 = parse_json(request, &state, &request_id.0).await?;
301    validate_batch(request.keys_hex.len(), &state, &request_id.0)?;
302    let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
303    let keys = request
304        .keys_hex
305        .iter()
306        .map(|key| decode_key_hex(key))
307        .collect::<Result<Vec<_>, _>>()
308        .map_err(|_| ApiError::invalid(&request_id.0))?;
309    let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
310        let keys = keys.iter().map(Vec::as_slice).collect::<Vec<_>>();
311        capture_write_outcome(engine.delete_records(transaction_id, &keys))
312    })
313    .await?;
314    if outcome.requires_recovery {
315        state.ready.store(false, Ordering::Release);
316    }
317    bounded_json(&receipt(outcome.append), &state, &request_id.0)
318}
319
320async fn get_record(
321    State(state): State<Arc<ServerState>>,
322    Extension(request_id): Extension<RequestId>,
323    request: Request,
324) -> Result<Response, ApiError> {
325    let request: GetRequestV1 = parse_json(request, &state, &request_id.0).await?;
326    let key = decode_key_hex(&request.key_hex).map_err(|_| ApiError::invalid(&request_id.0))?;
327    let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
328        engine.get_record_with_proof(&key)
329    })
330    .await?;
331    let proof = proof_transport(&artifact, &state, &request_id.0)?;
332    let ProvenResult::Get(record) = artifact.proof.result() else {
333        return Err(ApiError::internal(&request_id.0));
334    };
335    let response = GetResponseV1 {
336        found: record.is_some(),
337        record: record.as_ref().map(RecordV1::from_domain),
338        proof,
339    };
340    bounded_json(&response, &state, &request_id.0)
341}
342
343async fn query_records(
344    State(state): State<Arc<ServerState>>,
345    Extension(request_id): Extension<RequestId>,
346    request: Request,
347) -> Result<Response, ApiError> {
348    let request: QueryRequestV1 = parse_json(request, &state, &request_id.0).await?;
349    let timeout = requested_timeout(request.timeout_ms, &state, &request_id.0)?;
350    let query = request
351        .to_domain()
352        .map_err(|_| ApiError::invalid(&request_id.0))?;
353    let mut execution_limits = state.limits.query.clone();
354    execution_limits.timeout = timeout;
355    let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
356        engine.query_with_proof(&query, &execution_limits)
357    })
358    .await?;
359    let proof = proof_transport(&artifact, &state, &request_id.0)?;
360    let ProvenResult::Query(result) = artifact.proof.result() else {
361        return Err(ApiError::internal(&request_id.0));
362    };
363    bounded_json(
364        &QueryResponseV1::from_domain(result, proof),
365        &state,
366        &request_id.0,
367    )
368}
369
370async fn download_witness(
371    State(state): State<Arc<ServerState>>,
372    Extension(request_id): Extension<RequestId>,
373    request: Request,
374) -> Result<Response, ApiError> {
375    let (sequence, expected_digest) =
376        parse_witness_path(request.uri().path()).ok_or_else(|| ApiError::invalid(&request_id.0))?;
377    let path = state
378        .data_dir
379        .join("snapshots")
380        .join(format!("snapshot-{sequence:020}.hysnap"));
381    let permit = Arc::clone(&state.admission)
382        .try_acquire_owned()
383        .map_err(|_| busy(&request_id.0))?;
384    let verification_path = path.clone();
385    let verified = tokio::task::spawn_blocking(move || verify_snapshot(verification_path)).await;
386    drop(permit);
387    let info = match verified {
388        Ok(Ok(info)) => info,
389        Ok(Err(SnapshotError::Io(source))) if source.kind() == std::io::ErrorKind::NotFound => {
390            return Err(not_found(&request_id.0));
391        }
392        Ok(Err(_)) | Err(_) => return Err(ApiError::internal(&request_id.0)),
393    };
394    if info.checkpoint_sequence != sequence || info.snapshot_digest != expected_digest {
395        return Err(not_found(&request_id.0));
396    }
397    if info.file_bytes > state.limits.witness_bytes {
398        return Err(ApiError::limit(&request_id.0));
399    }
400    let file = tokio::fs::File::open(&path)
401        .await
402        .map_err(|_| ApiError::internal(&request_id.0))?;
403    let stream = ReaderStream::new(file);
404    Response::builder()
405        .status(StatusCode::OK)
406        .header(header::CONTENT_TYPE, "application/octet-stream")
407        .header(header::CONTENT_LENGTH, info.file_bytes)
408        .header(
409            "digest",
410            format!("blake3={}", encode_hex(&info.snapshot_digest)),
411        )
412        .body(Body::from_stream(stream))
413        .map_err(|_| ApiError::internal(&request_id.0))
414}
415
416async fn parse_json<T: DeserializeOwned>(
417    request: Request,
418    state: &ServerState,
419    request_id: &str,
420) -> Result<T, ApiError> {
421    if !is_json_content_type(request.headers()) {
422        return Err(ApiError::new(
423            StatusCode::UNSUPPORTED_MEDIA_TYPE,
424            "unsupported_media_type",
425            "content type must be application/json",
426            request_id,
427        ));
428    }
429    let bytes = tokio::time::timeout(
430        state.limits.request_body_timeout,
431        body::to_bytes(request.into_body(), state.limits.request_body_bytes),
432    )
433    .await
434    .map_err(|_| {
435        ApiError::new(
436            StatusCode::REQUEST_TIMEOUT,
437            "timeout",
438            "request body deadline elapsed without starting an operation",
439            request_id,
440        )
441    })?
442    .map_err(|_| ApiError::payload_too_large(request_id))?;
443    if bytes.is_empty() {
444        return Err(ApiError::invalid(request_id));
445    }
446    let value: serde_json::Value =
447        serde_json::from_slice(&bytes).map_err(|_| ApiError::invalid(request_id))?;
448    validate_json_shape(&value, state.limits.json_depth, state.limits.json_nodes)
449        .map_err(|()| ApiError::limit(request_id))?;
450    serde_json::from_value(value).map_err(|_| ApiError::invalid(request_id))
451}
452
453fn validate_json_shape(
454    root: &serde_json::Value,
455    maximum_depth: usize,
456    maximum_nodes: usize,
457) -> Result<(), ()> {
458    let mut stack = vec![(root, 0_usize)];
459    let mut nodes = 0_usize;
460    while let Some((value, depth)) = stack.pop() {
461        nodes = nodes.checked_add(1).ok_or(())?;
462        if nodes > maximum_nodes || depth > maximum_depth {
463            return Err(());
464        }
465        match value {
466            serde_json::Value::Array(values) => {
467                let next_depth = depth.checked_add(1).ok_or(())?;
468                stack.extend(values.iter().map(|value| (value, next_depth)));
469            }
470            serde_json::Value::Object(values) => {
471                let next_depth = depth.checked_add(1).ok_or(())?;
472                stack.extend(values.values().map(|value| (value, next_depth)));
473            }
474            serde_json::Value::Null
475            | serde_json::Value::Bool(_)
476            | serde_json::Value::Number(_)
477            | serde_json::Value::String(_) => {}
478        }
479    }
480    Ok(())
481}
482
483fn is_json_content_type(headers: &HeaderMap) -> bool {
484    let Some(value) = headers.get(header::CONTENT_TYPE) else {
485        return false;
486    };
487    let Ok(value) = value.to_str() else {
488        return false;
489    };
490    let media_type = value.split(';').next().unwrap_or_default().trim();
491    let media_type = media_type.to_ascii_lowercase();
492    media_type == "application/json"
493        || (media_type.starts_with("application/") && media_type.ends_with("+json"))
494}
495
496fn validate_batch(length: usize, state: &ServerState, request_id: &str) -> Result<(), ApiError> {
497    if length == 0 {
498        return Err(ApiError::invalid(request_id));
499    }
500    if length > state.limits.batch_items {
501        return Err(ApiError::limit(request_id));
502    }
503    Ok(())
504}
505
506fn parse_transaction_id(value: Option<&str>, request_id: &str) -> Result<Uuid, ApiError> {
507    value.map_or_else(
508        || Ok(Uuid::now_v7()),
509        |value| Uuid::parse_str(value).map_err(|_| ApiError::invalid(request_id)),
510    )
511}
512
513fn requested_timeout(
514    requested_ms: Option<u64>,
515    state: &ServerState,
516    request_id: &str,
517) -> Result<Duration, ApiError> {
518    let maximum_ms = u64::try_from(state.limits.query.timeout.as_millis()).unwrap_or(u64::MAX);
519    let requested_ms = requested_ms.unwrap_or(maximum_ms);
520    if requested_ms == 0 {
521        return Err(ApiError::invalid(request_id));
522    }
523    if requested_ms > maximum_ms {
524        return Err(ApiError::limit(request_id));
525    }
526    Ok(Duration::from_millis(requested_ms))
527}
528
529async fn with_engine<T, F>(
530    state: Arc<ServerState>,
531    request_id: &str,
532    operation: F,
533) -> Result<T, ApiError>
534where
535    T: Send + 'static,
536    F: FnOnce(&mut HyphaeEngine) -> Result<T, EngineError> + Send + 'static,
537{
538    if !state.ready.load(Ordering::Acquire) {
539        return Err(ApiError::unavailable(request_id));
540    }
541    let _permit = Arc::clone(&state.admission)
542        .try_acquire_owned()
543        .map_err(|_| busy(request_id))?;
544    let engine = Arc::clone(&state.engine);
545    let result = tokio::task::spawn_blocking(move || {
546        let mut engine = engine.lock().map_err(|_| EngineTaskError::Poisoned)?;
547        operation(&mut engine).map_err(EngineTaskError::Engine)
548    })
549    .await;
550    match result {
551        Ok(Ok(value)) => Ok(value),
552        Ok(Err(EngineTaskError::Engine(source))) => {
553            if engine_error_requires_recovery(&source) {
554                state.ready.store(false, Ordering::Release);
555            }
556            Err(ApiError::from_engine(source, request_id))
557        }
558        Ok(Err(EngineTaskError::Poisoned)) | Err(_) => {
559            state.ready.store(false, Ordering::Release);
560            Err(ApiError::internal(request_id))
561        }
562    }
563}
564
565enum EngineTaskError {
566    Engine(EngineError),
567    Poisoned,
568}
569
570struct WriteOutcome {
571    append: AppendOutcome,
572    requires_recovery: bool,
573}
574
575fn capture_write_outcome(
576    result: Result<AppendOutcome, EngineError>,
577) -> Result<WriteOutcome, EngineError> {
578    match result {
579        Ok(append) => Ok(WriteOutcome {
580            append,
581            requires_recovery: false,
582        }),
583        Err(EngineError::Storage(StorageError::CommittedButNotIndexed { receipt, .. })) => {
584            Ok(WriteOutcome {
585                append: AppendOutcome::Committed(receipt),
586                requires_recovery: true,
587            })
588        }
589        Err(source) => Err(source),
590    }
591}
592
593fn engine_error_requires_recovery(error: &EngineError) -> bool {
594    if matches!(
595        error,
596        EngineError::Proof(ProofError::ProofLimitExceeded { .. } | ProofError::LengthOverflow)
597    ) {
598        return false;
599    }
600    matches!(
601        error,
602        EngineError::Storage(
603            StorageError::Index { .. }
604                | StorageError::CommittedButNotIndexed { .. }
605                | StorageError::StaleIndex
606                | StorageError::Snapshot { .. }
607                | StorageError::DataDirectory(_)
608                | StorageError::Log(LogError::Poisoned)
609        ) | EngineError::Proof(_)
610    )
611}
612
613fn proof_transport(
614    artifact: &ResultProofArtifact,
615    state: &ServerState,
616    request_id: &str,
617) -> Result<ProofV1, ApiError> {
618    let encoded = artifact
619        .proof
620        .to_bytes()
621        .map_err(|source| ApiError::from_engine(EngineError::Proof(source), request_id))?;
622    if encoded.len() > state.limits.proof_bytes
623        || artifact.snapshot.file_bytes > state.limits.witness_bytes
624    {
625        return Err(ApiError::result_too_large(request_id));
626    }
627    let anchor = artifact.proof.anchor();
628    let snapshot_digest = encode_hex(&anchor.snapshot_digest);
629    Ok(ProofV1 {
630        encoding: "base64".to_owned(),
631        data: BASE64.encode(encoded),
632        proof_digest: encode_hex(&artifact.proof.proof_digest()),
633        anchor_digest: encode_hex(&artifact.proof.anchor_digest()),
634        checkpoint_sequence: anchor.checkpoint_sequence,
635        checkpoint_digest: anchor
636            .checkpoint_digest
637            .as_ref()
638            .map(|digest| encode_hex(digest)),
639        snapshot_digest: snapshot_digest.clone(),
640        witness: WitnessV1 {
641            path: format!(
642                "/v1/witnesses/{}/{}",
643                anchor.checkpoint_sequence, snapshot_digest
644            ),
645            file_bytes: artifact.snapshot.file_bytes,
646        },
647    })
648}
649
650fn receipt(outcome: AppendOutcome) -> CommitReceiptV1 {
651    let (status, receipt) = match outcome {
652        AppendOutcome::Committed(receipt) => ("committed", receipt),
653        AppendOutcome::Existing(receipt) => ("existing", receipt),
654    };
655    CommitReceiptV1 {
656        status: status.to_owned(),
657        transaction_id: receipt.transaction_id.to_string(),
658        commit_sequence: receipt.commit_sequence,
659        commit_digest: encode_hex(&receipt.commit_digest),
660        transaction_digest: encode_hex(&receipt.transaction_digest),
661    }
662}
663
664fn bounded_json<T: Serialize>(
665    value: &T,
666    state: &ServerState,
667    request_id: &str,
668) -> Result<Response, ApiError> {
669    let encoded = serde_json::to_vec(value).map_err(|_| ApiError::internal(request_id))?;
670    if encoded.len() > state.limits.response_bytes {
671        return Err(ApiError::result_too_large(request_id));
672    }
673    Response::builder()
674        .status(StatusCode::OK)
675        .header(header::CONTENT_TYPE, "application/json")
676        .header(header::CONTENT_LENGTH, encoded.len())
677        .body(Body::from(encoded))
678        .map_err(|_| ApiError::internal(request_id))
679}
680
681fn parse_witness_path(path: &str) -> Option<(u64, [u8; 32])> {
682    let suffix = path.strip_prefix("/v1/witnesses/")?;
683    let mut components = suffix.split('/');
684    let sequence = components.next()?.parse().ok()?;
685    let digest = components.next()?;
686    if components.next().is_some()
687        || digest.len() != 64
688        || !digest
689            .bytes()
690            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
691    {
692        return None;
693    }
694    let decoded = decode_key_hex(digest).ok()?;
695    decoded.try_into().ok().map(|digest| (sequence, digest))
696}
697
698fn request_id(request: &Request) -> String {
699    request
700        .extensions()
701        .get::<RequestId>()
702        .map_or_else(|| Uuid::now_v7().to_string(), |value| value.0.clone())
703}
704
705fn busy(request_id: &str) -> ApiError {
706    ApiError::new(
707        StatusCode::TOO_MANY_REQUESTS,
708        "busy",
709        "concurrent operation admission limit reached",
710        request_id,
711    )
712}
713
714fn not_found(request_id: &str) -> ApiError {
715    ApiError::new(
716        StatusCode::NOT_FOUND,
717        "not_found",
718        "requested version 1 resource does not exist",
719        request_id,
720    )
721}
722
723async fn route_not_found(Extension(request_id): Extension<RequestId>) -> ApiError {
724    not_found(&request_id.0)
725}
726
727async fn method_not_allowed(Extension(request_id): Extension<RequestId>) -> ApiError {
728    ApiError::new(
729        StatusCode::METHOD_NOT_ALLOWED,
730        "method_not_allowed",
731        "HTTP method is not defined for this version 1 route",
732        request_id.0,
733    )
734}
735
736#[cfg(test)]
737mod tests {
738    use std::{error::Error, fs, net::Ipv4Addr, path::PathBuf, sync::Arc, time::Duration};
739
740    use axum::{body::Body, http::Request};
741    use serde_json::Value;
742    use tokio::{
743        io::{AsyncReadExt as _, AsyncWriteExt as _},
744        net::TcpStream,
745        sync::oneshot,
746    };
747    use tokio_util::io::ReaderStream;
748    use tower::ServiceExt;
749
750    use hyphae_engine::EngineError;
751    use hyphae_storage::{AppendOutcome, CommitReceipt, MaterializedIndexError, StorageError};
752
753    use super::{HyphaeServer, ServerConfig, StatusCode, body, capture_write_outcome};
754    use crate::{BearerToken, ServerConfigError};
755
756    struct TestDirectory {
757        path: PathBuf,
758    }
759
760    impl TestDirectory {
761        fn create(name: &str) -> Result<Self, Box<dyn Error>> {
762            let path = std::env::temp_dir().join(format!(
763                "hyphae-server-{name}-{}-{}",
764                std::process::id(),
765                uuid::Uuid::now_v7()
766            ));
767            fs::create_dir_all(&path)?;
768            Ok(Self { path })
769        }
770    }
771
772    impl Drop for TestDirectory {
773        fn drop(&mut self) {
774            let _ignored = fs::remove_dir_all(&self.path);
775        }
776    }
777
778    #[test]
779    fn remote_bind_is_rejected_before_socket_bind() -> Result<(), Box<dyn Error>> {
780        let temporary = TestDirectory::create("remote-rejected")?;
781        let mut config = ServerConfig::new(&temporary.path);
782        config.bind = (Ipv4Addr::UNSPECIFIED, 8_787).into();
783        assert!(matches!(
784            HyphaeServer::open(config),
785            Err(crate::ServerError::Configuration(
786                ServerConfigError::RemoteBindRequiresAuthentication { .. }
787            ))
788        ));
789        Ok(())
790    }
791
792    #[test]
793    fn bearer_tokens_require_visible_header_safe_entropy() {
794        assert!(BearerToken::new("short").is_err());
795        assert!(BearerToken::new("0123456789abcdef0123456789abcde\n").is_err());
796        assert!(BearerToken::new("0123456789abcdef0123456789abcdef").is_ok());
797    }
798
799    #[test]
800    fn durable_unmaterialized_commit_keeps_its_public_receipt() -> Result<(), Box<dyn Error>> {
801        let receipt = CommitReceipt {
802            transaction_id: uuid::Uuid::now_v7(),
803            commit_sequence: 9,
804            commit_digest: [7; 32],
805            transaction_digest: [8; 32],
806        };
807        let outcome = capture_write_outcome(Err(EngineError::Storage(
808            StorageError::CommittedButNotIndexed {
809                receipt,
810                source: Box::new(MaterializedIndexError::MalformedCheckpoint),
811            },
812        )))?;
813        assert!(outcome.requires_recovery);
814        assert!(matches!(
815            outcome.append,
816            AppendOutcome::Committed(actual) if actual == receipt
817        ));
818        Ok(())
819    }
820
821    #[tokio::test]
822    async fn authenticated_put_get_and_witness_are_contract_shaped() -> Result<(), Box<dyn Error>> {
823        let temporary = TestDirectory::create("authenticated-flow")?;
824        let secret = "correct-hyphae-token-material-0001";
825        let mut config = ServerConfig::new(&temporary.path);
826        config.bearer_token = Some(BearerToken::new(secret)?);
827        let app = HyphaeServer::open(config)?.test_router();
828
829        let unauthorized = app
830            .clone()
831            .oneshot(json_request("/v1/kv/put", r#"{"records":[]}"#, None)?)
832            .await?;
833        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
834        assert!(unauthorized.headers().contains_key("x-request-id"));
835
836        let wrong = app
837            .clone()
838            .oneshot(json_request(
839                "/v1/kv/put",
840                r#"{"records":[]}"#,
841                Some("incorrect-hyphae-token-material-001"),
842            )?)
843            .await?;
844        assert_error(wrong, StatusCode::UNAUTHORIZED, "unauthorized").await?;
845
846        let duplicate_header = app
847            .clone()
848            .oneshot(
849                Request::builder()
850                    .method("POST")
851                    .uri("/v1/query")
852                    .header("content-type", "application/json")
853                    .header("authorization", format!("Bearer {secret}"))
854                    .header("authorization", format!("Bearer {secret}"))
855                    .body(Body::from(r#"{"limit":1}"#))?,
856            )
857            .await?;
858        assert_error(duplicate_header, StatusCode::UNAUTHORIZED, "unauthorized").await?;
859
860        let put = app
861            .clone()
862            .oneshot(json_request(
863                "/v1/kv/put",
864                r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":7}}]}"#,
865                Some(secret),
866            )?)
867            .await?;
868        assert_eq!(put.status(), StatusCode::OK);
869        let put: Value = serde_json::from_slice(&response_bytes(put).await?)?;
870        assert_eq!(put["status"], "committed");
871
872        let retry = app
873            .clone()
874            .oneshot(json_request(
875                "/v1/kv/put",
876                r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":7}}]}"#,
877                Some(secret),
878            )?)
879            .await?;
880        assert_eq!(retry.status(), StatusCode::OK);
881        let retry: Value = serde_json::from_slice(&response_bytes(retry).await?)?;
882        assert_eq!(retry["status"], "existing");
883
884        let conflict = app
885            .clone()
886            .oneshot(json_request(
887                "/v1/kv/put",
888                r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":8}}]}"#,
889                Some(secret),
890            )?)
891            .await?;
892        assert_error(conflict, StatusCode::CONFLICT, "idempotency_conflict").await?;
893
894        let get = app
895            .clone()
896            .oneshot(json_request(
897                "/v1/kv/get",
898                r#"{"key_hex":"61"}"#,
899                Some(secret),
900            )?)
901            .await?;
902        assert_eq!(get.status(), StatusCode::OK);
903        let get: Value = serde_json::from_slice(&response_bytes(get).await?)?;
904        assert_eq!(get["found"], true);
905        assert_eq!(get["record"]["value"]["score"], 7);
906        assert_eq!(get["proof"]["encoding"], "base64");
907        let witness_path = get["proof"]["witness"]["path"]
908            .as_str()
909            .ok_or("missing witness path")?;
910
911        let witness = app
912            .clone()
913            .oneshot(
914                Request::builder()
915                    .uri(witness_path)
916                    .header("authorization", format!("Bearer {secret}"))
917                    .body(Body::empty())?,
918            )
919            .await?;
920        assert_eq!(witness.status(), StatusCode::OK);
921        assert!(witness.headers().contains_key("digest"));
922        assert!(response_bytes(witness).await?.starts_with(b"HYSNAP01"));
923
924        let query = app
925            .oneshot(json_request("/v1/query", r#"{"limit":10}"#, Some(secret))?)
926            .await?;
927        assert_eq!(query.status(), StatusCode::OK);
928        let query: Value = serde_json::from_slice(&response_bytes(query).await?)?;
929        assert_eq!(query["rows"].as_array().map(Vec::len), Some(1));
930        assert_eq!(query["proof"]["encoding"], "base64");
931        Ok(())
932    }
933
934    #[tokio::test]
935    async fn public_routes_and_limit_failures_never_emit_framework_text()
936    -> Result<(), Box<dyn Error>> {
937        let temporary = TestDirectory::create("limits")?;
938        let mut config = ServerConfig::new(&temporary.path);
939        config.limits.request_body_bytes = 1_024;
940        config.limits.batch_items = 1;
941        let app = HyphaeServer::open(config)?.test_router();
942
943        let capabilities = app
944            .clone()
945            .oneshot(
946                Request::builder()
947                    .uri("/v1/capabilities")
948                    .body(Body::empty())?,
949            )
950            .await?;
951        assert_eq!(capabilities.status(), StatusCode::OK);
952        let capabilities: Value = serde_json::from_slice(&response_bytes(capabilities).await?)?;
953        assert_eq!(capabilities["api_version"], "v1");
954        assert_eq!(capabilities["limits"]["batch_items"], 1);
955
956        let too_many = app
957            .clone()
958            .oneshot(json_request(
959                "/v1/kv/delete",
960                r#"{"keys_hex":["61","62"]}"#,
961                None,
962            )?)
963            .await?;
964        assert_error(too_many, StatusCode::UNPROCESSABLE_ENTITY, "limit_exceeded").await?;
965
966        let unsupported = app
967            .clone()
968            .oneshot(
969                Request::builder()
970                    .method("POST")
971                    .uri("/v1/query")
972                    .header("content-type", "text/plain")
973                    .body(Body::from("{}"))?,
974            )
975            .await?;
976        assert_error(
977            unsupported,
978            StatusCode::UNSUPPORTED_MEDIA_TYPE,
979            "unsupported_media_type",
980        )
981        .await?;
982
983        let oversized = app
984            .clone()
985            .oneshot(json_request(
986                "/v1/query",
987                &format!(r#"{{"limit":1,"ignored":"{}"}}"#, "x".repeat(2_000)),
988                None,
989            )?)
990            .await?;
991        assert_error(
992            oversized,
993            StatusCode::PAYLOAD_TOO_LARGE,
994            "payload_too_large",
995        )
996        .await?;
997
998        let missing = app
999            .clone()
1000            .oneshot(Request::builder().uri("/v1/unknown").body(Body::empty())?)
1001            .await?;
1002        assert_error(missing, StatusCode::NOT_FOUND, "not_found").await?;
1003
1004        let wrong_method = app
1005            .oneshot(
1006                Request::builder()
1007                    .method("DELETE")
1008                    .uri("/v1/health/live")
1009                    .body(Body::empty())?,
1010            )
1011            .await?;
1012        assert_error(
1013            wrong_method,
1014            StatusCode::METHOD_NOT_ALLOWED,
1015            "method_not_allowed",
1016        )
1017        .await?;
1018        Ok(())
1019    }
1020
1021    #[tokio::test]
1022    async fn shape_proof_and_admission_limits_fail_without_partial_results()
1023    -> Result<(), Box<dyn Error>> {
1024        let temporary = TestDirectory::create("bounded-work")?;
1025        let mut config = ServerConfig::new(&temporary.path);
1026        config.limits.json_depth = 3;
1027        config.limits.concurrent_operations = 1;
1028        config.limits.proof_bytes = 128;
1029        let server = HyphaeServer::open(config)?;
1030        let app = server.test_router();
1031
1032        let too_deep = app
1033            .clone()
1034            .oneshot(json_request(
1035                "/v1/query",
1036                r#"{"filter":{"op":"not","filter":{"op":"not","filter":{"op":"not","filter":{"op":"match_all"}}}},"limit":1}"#,
1037                None,
1038            )?)
1039            .await?;
1040        assert_error(too_deep, StatusCode::UNPROCESSABLE_ENTITY, "limit_exceeded").await?;
1041
1042        let put = app
1043            .clone()
1044            .oneshot(json_request(
1045                "/v1/kv/put",
1046                r#"{"records":[{"key_hex":"61","value":1}]}"#,
1047                None,
1048            )?)
1049            .await?;
1050        assert_eq!(put.status(), StatusCode::OK);
1051
1052        let proof_too_large = app
1053            .clone()
1054            .oneshot(json_request("/v1/kv/get", r#"{"key_hex":"61"}"#, None)?)
1055            .await?;
1056        assert_error(
1057            proof_too_large,
1058            StatusCode::PAYLOAD_TOO_LARGE,
1059            "result_too_large",
1060        )
1061        .await?;
1062
1063        let permit = Arc::clone(&server.state.admission).try_acquire_owned()?;
1064        let busy = app
1065            .clone()
1066            .oneshot(json_request("/v1/query", r#"{"limit":1}"#, None)?)
1067            .await?;
1068        drop(permit);
1069        assert_error(busy, StatusCode::TOO_MANY_REQUESTS, "busy").await?;
1070
1071        server
1072            .state
1073            .ready
1074            .store(false, std::sync::atomic::Ordering::Release);
1075        let unavailable = app
1076            .oneshot(
1077                Request::builder()
1078                    .uri("/v1/health/ready")
1079                    .body(Body::empty())?,
1080            )
1081            .await?;
1082        assert_error(unavailable, StatusCode::SERVICE_UNAVAILABLE, "unavailable").await?;
1083        Ok(())
1084    }
1085
1086    #[tokio::test]
1087    async fn stalled_json_body_times_out_before_any_operation_starts() -> Result<(), Box<dyn Error>>
1088    {
1089        let temporary = TestDirectory::create("body-timeout")?;
1090        let mut config = ServerConfig::new(&temporary.path);
1091        config.limits.request_body_timeout = Duration::from_millis(5);
1092        let app = HyphaeServer::open(config)?.test_router();
1093        let (_writer, reader) = tokio::io::duplex(1);
1094        let response = app
1095            .oneshot(
1096                Request::builder()
1097                    .method("POST")
1098                    .uri("/v1/query")
1099                    .header("content-type", "application/json")
1100                    .body(Body::from_stream(ReaderStream::new(reader)))?,
1101            )
1102            .await?;
1103        assert_error(response, StatusCode::REQUEST_TIMEOUT, "timeout").await?;
1104        Ok(())
1105    }
1106
1107    #[tokio::test]
1108    async fn bound_server_stops_on_graceful_shutdown() -> Result<(), Box<dyn Error>> {
1109        let temporary = TestDirectory::create("graceful")?;
1110        let mut config = ServerConfig::new(&temporary.path);
1111        config.bind.set_port(0);
1112        let bound = HyphaeServer::open(config)?.bind().await?;
1113        let local_addr = bound.local_addr();
1114        assert_ne!(local_addr.port(), 0);
1115        let (send, receive) = oneshot::channel::<()>();
1116        let serving = tokio::spawn(bound.run_with_shutdown(async move {
1117            let _ignored = receive.await;
1118        }));
1119        let mut connection = TcpStream::connect(local_addr).await?;
1120        connection
1121            .write_all(
1122                b"GET /v1/health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
1123            )
1124            .await?;
1125        let mut response = Vec::new();
1126        connection.read_to_end(&mut response).await?;
1127        assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n"));
1128        assert!(response.ends_with(br#"{"status":"live"}"#));
1129        let _ignored = send.send(());
1130        serving.await??;
1131        Ok(())
1132    }
1133
1134    fn json_request(
1135        uri: &str,
1136        body: &str,
1137        bearer: Option<&str>,
1138    ) -> Result<Request<Body>, axum::http::Error> {
1139        let mut request = Request::builder()
1140            .method("POST")
1141            .uri(uri)
1142            .header("content-type", "application/json");
1143        if let Some(bearer) = bearer {
1144            request = request.header("authorization", format!("Bearer {bearer}"));
1145        }
1146        request.body(Body::from(body.to_owned()))
1147    }
1148
1149    async fn response_bytes(
1150        response: axum::response::Response,
1151    ) -> Result<axum::body::Bytes, Box<dyn Error>> {
1152        Ok(body::to_bytes(response.into_body(), 64 * 1024 * 1024).await?)
1153    }
1154
1155    async fn assert_error(
1156        response: axum::response::Response,
1157        status: StatusCode,
1158        code: &str,
1159    ) -> Result<(), Box<dyn Error>> {
1160        assert_eq!(response.status(), status);
1161        let header_request_id = response
1162            .headers()
1163            .get("x-request-id")
1164            .and_then(|value| value.to_str().ok())
1165            .ok_or("missing request ID header")?
1166            .to_owned();
1167        let value: Value = serde_json::from_slice(&response_bytes(response).await?)?;
1168        assert_eq!(value["code"], code);
1169        assert_eq!(value["request_id"], header_request_id);
1170        Ok(())
1171    }
1172}