1use 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
61pub struct HyphaeServer {
63 bind: SocketAddr,
64 state: Arc<ServerState>,
65}
66
67impl HyphaeServer {
68 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 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
123pub struct BoundServer {
125 listener: TcpListener,
126 local_addr: SocketAddr,
127 router: Router,
128}
129
130impl BoundServer {
131 pub fn local_addr(&self) -> SocketAddr {
133 self.local_addr
134 }
135
136 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}