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, DefineLexicalIndexRequestV1, DefineVectorSpaceRequestV1,
26 DeleteRequestV1, DeleteVectorsRequestV1, ExactAbstentionReasonV1, ExactAbstentionV1,
27 ExactRetrievalMatchV1, ExactRetrievalOutcomeV1, ExactRetrievalRequestV1,
28 ExactRetrievalResponseV1, GetRequestV1, GetResponseV1, HealthV1, HybridAbstentionV1,
29 HybridBranchAbsenceV1, HybridExplanationV1, HybridRetrievalMatchV1, HybridRetrievalOutcomeV1,
30 HybridRetrievalRequestV1, HybridRetrievalResponseV1, LexicalAbstentionReasonV1,
31 LexicalAbstentionV1, LexicalFieldContributionV1, LexicalRetrievalMatchV1,
32 LexicalRetrievalOutcomeV1, LexicalRetrievalRequestV1, LexicalRetrievalResponseV1,
33 LexicalTermContributionV1, ProofV1, PutRequestV1, PutVectorsRequestV1, QueryRequestV1,
34 QueryResponseV1, RecordV1, RetrievalProofV1, VectorMetricV1, WitnessV1, decode_key_hex,
35 encode_hex,
36};
37use hyphae_core::{Q15Vector, VectorSpaceDefinition, VectorSpaceName, current_version};
38use hyphae_engine::{
39 EngineError, ExactRetrievalProofArtifact, HybridRetrievalProofArtifact, HyphaeEngine,
40 LexicalRetrievalProofArtifact, ProofError, ProvenResult, ResultProofArtifact,
41};
42use hyphae_query::FieldPath;
43use hyphae_retrieval::{
44 ExactAbstentionReason, ExactRetrievalOutcome, ExactRetrievalRequest, HybridBranchAbsence,
45 HybridOutcome, HybridRequest, LexicalAbstentionReason, LexicalField, LexicalIndexDefinition,
46 LexicalOutcome, LexicalRequest,
47};
48use hyphae_storage::{
49 AppendOutcome, LogError, MaterializedIndexError, SnapshotError, StorageError, verify_snapshot,
50};
51use serde::{Serialize, de::DeserializeOwned};
52use tokio::{net::TcpListener, sync::Semaphore};
53use tokio_util::io::ReaderStream;
54use uuid::Uuid;
55
56use crate::{ApiError, BearerToken, ServerConfig, ServerError, ServerLimits};
57
58const FEATURES: [&str; 14] = [
59 "atomic_batch",
60 "deterministic_query",
61 "durable_vectors",
62 "exact_retrieval",
63 "hybrid_retrieval",
64 "idempotency",
65 "kv",
66 "lexical_retrieval",
67 "offline_result_proof",
68 "offline_retrieval_proof",
69 "provider_free_lexical",
70 "snapshot_witness",
71 "structured_aggregation",
72 "typed_abstention",
73];
74
75#[derive(Clone, Debug)]
76struct RequestId(String);
77
78struct ServerState {
79 engine: Arc<Mutex<HyphaeEngine>>,
80 data_dir: PathBuf,
81 limits: ServerLimits,
82 bearer_token: Option<BearerToken>,
83 admission: Arc<Semaphore>,
84 ready: AtomicBool,
85}
86
87pub struct HyphaeServer {
89 bind: SocketAddr,
90 state: Arc<ServerState>,
91}
92
93impl HyphaeServer {
94 pub fn open(config: ServerConfig) -> Result<Self, ServerError> {
104 config.validate()?;
105 let opened = HyphaeEngine::open(config.data_dir())?;
106 let data_dir = opened.engine.data_path().to_path_buf();
107 Ok(Self {
108 bind: config.bind,
109 state: Arc::new(ServerState {
110 engine: Arc::new(Mutex::new(opened.engine)),
111 data_dir,
112 admission: Arc::new(Semaphore::new(config.limits.concurrent_operations)),
113 ready: AtomicBool::new(true),
114 limits: config.limits,
115 bearer_token: config.bearer_token,
116 }),
117 })
118 }
119
120 pub async fn bind(self) -> Result<BoundServer, ServerError> {
126 let listener = TcpListener::bind(self.bind)
127 .await
128 .map_err(|source| ServerError::Bind {
129 address: self.bind,
130 source,
131 })?;
132 let local_addr = listener.local_addr().map_err(|source| ServerError::Bind {
133 address: self.bind,
134 source,
135 })?;
136 Ok(BoundServer {
137 listener,
138 local_addr,
139 router: build_router(self.state),
140 })
141 }
142
143 #[cfg(test)]
144 fn test_router(&self) -> Router {
145 build_router(Arc::clone(&self.state))
146 }
147}
148
149pub struct BoundServer {
151 listener: TcpListener,
152 local_addr: SocketAddr,
153 router: Router,
154}
155
156impl BoundServer {
157 pub fn local_addr(&self) -> SocketAddr {
159 self.local_addr
160 }
161
162 pub async fn run_with_shutdown<F>(self, shutdown: F) -> Result<(), ServerError>
168 where
169 F: Future<Output = ()> + Send + 'static,
170 {
171 axum::serve(self.listener, self.router)
172 .with_graceful_shutdown(shutdown)
173 .await
174 .map_err(ServerError::Serve)
175 }
176}
177
178fn build_router(state: Arc<ServerState>) -> Router {
179 let public = Router::new()
180 .route("/v1/capabilities", get(capabilities))
181 .route("/v1/health/live", get(liveness))
182 .route("/v1/health/ready", get(readiness));
183 let protected = Router::new()
184 .route("/v1/kv/put", post(put_records))
185 .route("/v1/kv/get", post(get_record))
186 .route("/v1/kv/delete", post(delete_records))
187 .route("/v1/query", post(query_records))
188 .route("/v1/vector-spaces/define", post(define_vector_space))
189 .route("/v1/vectors/put", post(put_vectors))
190 .route("/v1/vectors/delete", post(delete_vectors))
191 .route("/v1/retrieve/exact", post(retrieve_exact))
192 .route("/v1/lexical-indexes/define", post(define_lexical_index))
193 .route("/v1/retrieve/lexical", post(retrieve_lexical))
194 .route("/v1/retrieve/hybrid", post(retrieve_hybrid))
195 .route(
196 "/v1/witnesses/{checkpoint_sequence}/{snapshot_digest}",
197 get(download_witness),
198 )
199 .route_layer(middleware::from_fn_with_state(
200 Arc::clone(&state),
201 authenticate,
202 ));
203
204 public
205 .merge(protected)
206 .fallback(route_not_found)
207 .method_not_allowed_fallback(method_not_allowed)
208 .with_state(state)
209 .layer(middleware::from_fn(assign_request_id))
210}
211
212async fn assign_request_id(mut request: Request, next: Next) -> Response {
213 let request_id = RequestId(Uuid::now_v7().to_string());
214 request.extensions_mut().insert(request_id.clone());
215 let mut response = next.run(request).await;
216 if let Ok(value) = HeaderValue::from_str(&request_id.0) {
217 response.headers_mut().insert("x-request-id", value);
218 }
219 response
220}
221
222async fn authenticate(
223 State(state): State<Arc<ServerState>>,
224 request: Request,
225 next: Next,
226) -> Response {
227 let Some(expected) = &state.bearer_token else {
228 return next.run(request).await;
229 };
230 let request_id = request_id(&request);
231 if bearer_candidate(request.headers()).is_some_and(|candidate| expected.verifies(candidate)) {
232 return next.run(request).await;
233 }
234 ApiError::new(
235 StatusCode::UNAUTHORIZED,
236 "unauthorized",
237 "valid bearer authentication is required",
238 request_id,
239 )
240 .into_response()
241}
242
243fn bearer_candidate(headers: &HeaderMap) -> Option<&[u8]> {
244 let mut values = headers.get_all(header::AUTHORIZATION).iter();
245 let value = values.next()?;
246 if values.next().is_some() {
247 return None;
248 }
249 let value = value.as_bytes();
250 let separator = value.iter().position(|byte| *byte == b' ')?;
251 if !value[..separator].eq_ignore_ascii_case(b"bearer") {
252 return None;
253 }
254 let candidate = &value[separator.saturating_add(1)..];
255 (!candidate.is_empty()).then_some(candidate)
256}
257
258async fn liveness(
259 State(state): State<Arc<ServerState>>,
260 Extension(request_id): Extension<RequestId>,
261) -> Result<Response, ApiError> {
262 bounded_json(
263 &HealthV1 {
264 status: "live".to_owned(),
265 },
266 &state,
267 &request_id.0,
268 )
269}
270
271async fn readiness(
272 State(state): State<Arc<ServerState>>,
273 Extension(request_id): Extension<RequestId>,
274) -> Result<Response, ApiError> {
275 if !state.ready.load(Ordering::Acquire) {
276 return Err(ApiError::unavailable(&request_id.0));
277 }
278 bounded_json(
279 &HealthV1 {
280 status: "ready".to_owned(),
281 },
282 &state,
283 &request_id.0,
284 )
285}
286
287async fn capabilities(
288 State(state): State<Arc<ServerState>>,
289 Extension(request_id): Extension<RequestId>,
290) -> Result<Response, ApiError> {
291 let version = current_version();
292 bounded_json(
293 &CapabilitiesV1 {
294 api_version: version.api.to_owned(),
295 disk_format_version: version.disk_format,
296 features: FEATURES.iter().map(ToString::to_string).collect(),
297 limits: state.limits.as_contract(),
298 },
299 &state,
300 &request_id.0,
301 )
302}
303
304async fn put_records(
305 State(state): State<Arc<ServerState>>,
306 Extension(request_id): Extension<RequestId>,
307 request: Request,
308) -> Result<Response, ApiError> {
309 let request: PutRequestV1 = parse_json(request, &state, &request_id.0).await?;
310 validate_batch(request.records.len(), &state, &request_id.0)?;
311 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
312 let records = request
313 .records
314 .iter()
315 .map(RecordV1::to_domain)
316 .collect::<Result<Vec<_>, _>>()
317 .map_err(|_| ApiError::invalid(&request_id.0))?;
318 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
319 capture_write_outcome(engine.put_records(transaction_id, &records))
320 })
321 .await?;
322 if outcome.requires_recovery {
323 state.ready.store(false, Ordering::Release);
324 }
325 bounded_json(&receipt(outcome.append), &state, &request_id.0)
326}
327
328async fn delete_records(
329 State(state): State<Arc<ServerState>>,
330 Extension(request_id): Extension<RequestId>,
331 request: Request,
332) -> Result<Response, ApiError> {
333 let request: DeleteRequestV1 = parse_json(request, &state, &request_id.0).await?;
334 validate_batch(request.keys_hex.len(), &state, &request_id.0)?;
335 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
336 let keys = request
337 .keys_hex
338 .iter()
339 .map(|key| decode_key_hex(key))
340 .collect::<Result<Vec<_>, _>>()
341 .map_err(|_| ApiError::invalid(&request_id.0))?;
342 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
343 let keys = keys.iter().map(Vec::as_slice).collect::<Vec<_>>();
344 capture_write_outcome(engine.delete_records(transaction_id, &keys))
345 })
346 .await?;
347 if outcome.requires_recovery {
348 state.ready.store(false, Ordering::Release);
349 }
350 bounded_json(&receipt(outcome.append), &state, &request_id.0)
351}
352
353async fn get_record(
354 State(state): State<Arc<ServerState>>,
355 Extension(request_id): Extension<RequestId>,
356 request: Request,
357) -> Result<Response, ApiError> {
358 let request: GetRequestV1 = parse_json(request, &state, &request_id.0).await?;
359 let key = decode_key_hex(&request.key_hex).map_err(|_| ApiError::invalid(&request_id.0))?;
360 let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
361 engine.get_record_with_proof(&key)
362 })
363 .await?;
364 let proof = proof_transport(&artifact, &state, &request_id.0)?;
365 let ProvenResult::Get(record) = artifact.proof.result() else {
366 return Err(ApiError::internal(&request_id.0));
367 };
368 let response = GetResponseV1 {
369 found: record.is_some(),
370 record: record.as_ref().map(RecordV1::from_domain),
371 proof,
372 };
373 bounded_json(&response, &state, &request_id.0)
374}
375
376async fn query_records(
377 State(state): State<Arc<ServerState>>,
378 Extension(request_id): Extension<RequestId>,
379 request: Request,
380) -> Result<Response, ApiError> {
381 let request: QueryRequestV1 = parse_json(request, &state, &request_id.0).await?;
382 let timeout = requested_timeout(request.timeout_ms, &state, &request_id.0)?;
383 let query = request
384 .to_domain()
385 .map_err(|_| ApiError::invalid(&request_id.0))?;
386 let mut execution_limits = state.limits.query.clone();
387 execution_limits.timeout = timeout;
388 let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
389 engine.query_with_proof(&query, &execution_limits)
390 })
391 .await?;
392 let proof = proof_transport(&artifact, &state, &request_id.0)?;
393 let ProvenResult::Query(result) = artifact.proof.result() else {
394 return Err(ApiError::internal(&request_id.0));
395 };
396 bounded_json(
397 &QueryResponseV1::from_domain(result, proof),
398 &state,
399 &request_id.0,
400 )
401}
402
403async fn define_vector_space(
404 State(state): State<Arc<ServerState>>,
405 Extension(request_id): Extension<RequestId>,
406 request: Request,
407) -> Result<Response, ApiError> {
408 let request: DefineVectorSpaceRequestV1 = parse_json(request, &state, &request_id.0).await?;
409 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
410 if request.vector_space.metric != VectorMetricV1::CosineQ15Nanos {
411 return Err(ApiError::invalid(&request_id.0));
412 }
413 let name = VectorSpaceName::new(request.vector_space.name)
414 .map_err(|_| ApiError::invalid(&request_id.0))?;
415 let definition = VectorSpaceDefinition::cosine(name, request.vector_space.dimension)
416 .map_err(|_| ApiError::invalid(&request_id.0))?;
417 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
418 capture_write_outcome(engine.define_vector_space(transaction_id, definition))
419 })
420 .await?;
421 if outcome.requires_recovery {
422 state.ready.store(false, Ordering::Release);
423 }
424 bounded_json(&receipt(outcome.append), &state, &request_id.0)
425}
426
427async fn put_vectors(
428 State(state): State<Arc<ServerState>>,
429 Extension(request_id): Extension<RequestId>,
430 request: Request,
431) -> Result<Response, ApiError> {
432 let request: PutVectorsRequestV1 = parse_json(request, &state, &request_id.0).await?;
433 validate_batch(request.vectors.len(), &state, &request_id.0)?;
434 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
435 let space =
436 VectorSpaceName::new(request.vector_space).map_err(|_| ApiError::invalid(&request_id.0))?;
437 let vectors = request
438 .vectors
439 .into_iter()
440 .map(|vector| {
441 Ok((
442 decode_key_hex(&vector.key_hex).map_err(|_| ApiError::invalid(&request_id.0))?,
443 Q15Vector::new(vector.values).map_err(|_| ApiError::invalid(&request_id.0))?,
444 ))
445 })
446 .collect::<Result<Vec<_>, ApiError>>()?;
447 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
448 capture_write_outcome(engine.put_vectors(transaction_id, &space, &vectors))
449 })
450 .await?;
451 if outcome.requires_recovery {
452 state.ready.store(false, Ordering::Release);
453 }
454 bounded_json(&receipt(outcome.append), &state, &request_id.0)
455}
456
457async fn delete_vectors(
458 State(state): State<Arc<ServerState>>,
459 Extension(request_id): Extension<RequestId>,
460 request: Request,
461) -> Result<Response, ApiError> {
462 let request: DeleteVectorsRequestV1 = parse_json(request, &state, &request_id.0).await?;
463 validate_batch(request.keys_hex.len(), &state, &request_id.0)?;
464 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
465 let space =
466 VectorSpaceName::new(request.vector_space).map_err(|_| ApiError::invalid(&request_id.0))?;
467 let keys = request
468 .keys_hex
469 .iter()
470 .map(|key| decode_key_hex(key))
471 .collect::<Result<Vec<_>, _>>()
472 .map_err(|_| ApiError::invalid(&request_id.0))?;
473 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
474 let keys = keys.iter().map(Vec::as_slice).collect::<Vec<_>>();
475 capture_write_outcome(engine.delete_vectors(transaction_id, &space, &keys))
476 })
477 .await?;
478 if outcome.requires_recovery {
479 state.ready.store(false, Ordering::Release);
480 }
481 bounded_json(&receipt(outcome.append), &state, &request_id.0)
482}
483
484async fn retrieve_exact(
485 State(state): State<Arc<ServerState>>,
486 Extension(request_id): Extension<RequestId>,
487 request: Request,
488) -> Result<Response, ApiError> {
489 let request: ExactRetrievalRequestV1 = parse_json(request, &state, &request_id.0).await?;
490 let timeout = requested_retrieval_timeout(request.timeout_ms, &state, &request_id.0)?;
491 let request = exact_request(request, &request_id.0)?;
492 let mut limits = state.limits.exact_retrieval.clone();
493 limits.timeout = timeout;
494 let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
495 engine.retrieve_exact_with_proof(&request, &limits)
496 })
497 .await?;
498 let proof = retrieval_proof_transport(&artifact, &state, &request_id.0)?;
499 let response = ExactRetrievalResponseV1 {
500 outcome: exact_outcome_transport(artifact.proof.outcome()),
501 proof,
502 };
503 bounded_json(&response, &state, &request_id.0)
504}
505
506fn exact_request(
507 request: ExactRetrievalRequestV1,
508 request_id: &str,
509) -> Result<ExactRetrievalRequest, ApiError> {
510 Ok(ExactRetrievalRequest {
511 vector_space: VectorSpaceName::new(request.vector_space)
512 .map_err(|_| ApiError::invalid(request_id))?,
513 query: Q15Vector::new(request.query).map_err(|_| ApiError::invalid(request_id))?,
514 limit: usize::try_from(request.limit).map_err(|_| ApiError::limit(request_id))?,
515 minimum_score_nanos: request.minimum_score_nanos,
516 minimum_margin_nanos: request.minimum_margin_nanos,
517 })
518}
519
520fn lexical_request(
521 request: LexicalRetrievalRequestV1,
522 request_id: &str,
523) -> Result<LexicalRequest, ApiError> {
524 Ok(LexicalRequest {
525 index: VectorSpaceName::new(request.lexical_index)
526 .map_err(|_| ApiError::invalid(request_id))?,
527 query: request.query,
528 limit: usize::try_from(request.limit).map_err(|_| ApiError::limit(request_id))?,
529 })
530}
531
532async fn define_lexical_index(
533 State(state): State<Arc<ServerState>>,
534 Extension(request_id): Extension<RequestId>,
535 request: Request,
536) -> Result<Response, ApiError> {
537 let request: DefineLexicalIndexRequestV1 = parse_json(request, &state, &request_id.0).await?;
538 let transaction_id = parse_transaction_id(request.transaction_id.as_deref(), &request_id.0)?;
539 let name = VectorSpaceName::new(request.lexical_index.name)
540 .map_err(|_| ApiError::invalid(&request_id.0))?;
541 let fields = request
542 .lexical_index
543 .fields
544 .into_iter()
545 .map(|field| {
546 Ok(LexicalField {
547 path: FieldPath::new(field.path),
548 weight_micros: field.weight_micros,
549 })
550 })
551 .collect::<Result<Vec<_>, ApiError>>()?;
552 let definition =
553 LexicalIndexDefinition::new(name, fields).map_err(|_| ApiError::invalid(&request_id.0))?;
554 let outcome = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
555 capture_write_outcome(engine.define_lexical_index(transaction_id, definition))
556 })
557 .await?;
558 if outcome.requires_recovery {
559 state.ready.store(false, Ordering::Release);
560 }
561 bounded_json(&receipt(outcome.append), &state, &request_id.0)
562}
563
564async fn retrieve_lexical(
565 State(state): State<Arc<ServerState>>,
566 Extension(request_id): Extension<RequestId>,
567 request: Request,
568) -> Result<Response, ApiError> {
569 let request: LexicalRetrievalRequestV1 = parse_json(request, &state, &request_id.0).await?;
570 let timeout = requested_lexical_timeout(request.timeout_ms, &state, &request_id.0)?;
571 let request = lexical_request(request, &request_id.0)?;
572 let mut limits = state.limits.lexical_retrieval.clone();
573 limits.timeout = timeout;
574 let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
575 engine.retrieve_lexical_with_proof(&request, &limits)
576 })
577 .await?;
578 let proof = lexical_retrieval_proof_transport(&artifact, &state, &request_id.0)?;
579 bounded_json(
580 &LexicalRetrievalResponseV1 {
581 outcome: lexical_outcome_transport(artifact.proof.outcome()),
582 proof,
583 },
584 &state,
585 &request_id.0,
586 )
587}
588
589async fn retrieve_hybrid(
590 State(state): State<Arc<ServerState>>,
591 Extension(request_id): Extension<RequestId>,
592 request: Request,
593) -> Result<Response, ApiError> {
594 let request: HybridRetrievalRequestV1 = parse_json(request, &state, &request_id.0).await?;
595 let lexical_timeout =
596 requested_lexical_timeout(request.lexical.timeout_ms, &state, &request_id.0)?;
597 let vector_timeout =
598 requested_retrieval_timeout(request.vector.timeout_ms, &state, &request_id.0)?;
599 let lexical_request = lexical_request(request.lexical, &request_id.0)?;
600 let vector_request = exact_request(request.vector, &request_id.0)?;
601 let hybrid_request = HybridRequest {
602 lexical_weight: request.lexical_weight,
603 vector_weight: request.vector_weight,
604 limit: usize::try_from(request.limit).map_err(|_| ApiError::limit(&request_id.0))?,
605 };
606 if hybrid_request.limit > state.limits.lexical_retrieval.max_returned {
607 return Err(ApiError::limit(&request_id.0));
608 }
609 let mut lexical_limits = state.limits.lexical_retrieval.clone();
610 lexical_limits.timeout = lexical_timeout;
611 let mut vector_limits = state.limits.exact_retrieval.clone();
612 vector_limits.timeout = vector_timeout;
613 let artifact = with_engine(Arc::clone(&state), &request_id.0, move |engine| {
614 engine.retrieve_hybrid_with_proof(
615 &lexical_request,
616 &lexical_limits,
617 &vector_request,
618 &vector_limits,
619 &hybrid_request,
620 )
621 })
622 .await?;
623 let proof = hybrid_retrieval_proof_transport(&artifact, &state, &request_id.0)?;
624 bounded_json(
625 &HybridRetrievalResponseV1 {
626 outcome: hybrid_outcome_transport(artifact.proof.outcome()),
627 proof,
628 },
629 &state,
630 &request_id.0,
631 )
632}
633
634async fn download_witness(
635 State(state): State<Arc<ServerState>>,
636 Extension(request_id): Extension<RequestId>,
637 request: Request,
638) -> Result<Response, ApiError> {
639 let (sequence, expected_digest) =
640 parse_witness_path(request.uri().path()).ok_or_else(|| ApiError::invalid(&request_id.0))?;
641 let path = state
642 .data_dir
643 .join("snapshots")
644 .join(format!("snapshot-{sequence:020}.hysnap"));
645 let permit = Arc::clone(&state.admission)
646 .try_acquire_owned()
647 .map_err(|_| busy(&request_id.0))?;
648 let verification_path = path.clone();
649 let verified = tokio::task::spawn_blocking(move || verify_snapshot(verification_path)).await;
650 drop(permit);
651 let info = match verified {
652 Ok(Ok(info)) => info,
653 Ok(Err(SnapshotError::Io(source))) if source.kind() == std::io::ErrorKind::NotFound => {
654 return Err(not_found(&request_id.0));
655 }
656 Ok(Err(_)) | Err(_) => return Err(ApiError::internal(&request_id.0)),
657 };
658 if info.checkpoint_sequence != sequence || info.snapshot_digest != expected_digest {
659 return Err(not_found(&request_id.0));
660 }
661 if info.file_bytes > state.limits.witness_bytes {
662 return Err(ApiError::limit(&request_id.0));
663 }
664 let file = tokio::fs::File::open(&path)
665 .await
666 .map_err(|_| ApiError::internal(&request_id.0))?;
667 let stream = ReaderStream::new(file);
668 Response::builder()
669 .status(StatusCode::OK)
670 .header(header::CONTENT_TYPE, "application/octet-stream")
671 .header(header::CONTENT_LENGTH, info.file_bytes)
672 .header(
673 "digest",
674 format!("blake3={}", encode_hex(&info.snapshot_digest)),
675 )
676 .body(Body::from_stream(stream))
677 .map_err(|_| ApiError::internal(&request_id.0))
678}
679
680async fn parse_json<T: DeserializeOwned>(
681 request: Request,
682 state: &ServerState,
683 request_id: &str,
684) -> Result<T, ApiError> {
685 if !is_json_content_type(request.headers()) {
686 return Err(ApiError::new(
687 StatusCode::UNSUPPORTED_MEDIA_TYPE,
688 "unsupported_media_type",
689 "content type must be application/json",
690 request_id,
691 ));
692 }
693 let bytes = tokio::time::timeout(
694 state.limits.request_body_timeout,
695 body::to_bytes(request.into_body(), state.limits.request_body_bytes),
696 )
697 .await
698 .map_err(|_| {
699 ApiError::new(
700 StatusCode::REQUEST_TIMEOUT,
701 "timeout",
702 "request body deadline elapsed without starting an operation",
703 request_id,
704 )
705 })?
706 .map_err(|_| ApiError::payload_too_large(request_id))?;
707 if bytes.is_empty() {
708 return Err(ApiError::invalid(request_id));
709 }
710 let value: serde_json::Value =
711 serde_json::from_slice(&bytes).map_err(|_| ApiError::invalid(request_id))?;
712 validate_json_shape(&value, state.limits.json_depth, state.limits.json_nodes)
713 .map_err(|()| ApiError::limit(request_id))?;
714 serde_json::from_value(value).map_err(|_| ApiError::invalid(request_id))
715}
716
717fn validate_json_shape(
718 root: &serde_json::Value,
719 maximum_depth: usize,
720 maximum_nodes: usize,
721) -> Result<(), ()> {
722 let mut stack = vec![(root, 0_usize)];
723 let mut nodes = 0_usize;
724 while let Some((value, depth)) = stack.pop() {
725 nodes = nodes.checked_add(1).ok_or(())?;
726 if nodes > maximum_nodes || depth > maximum_depth {
727 return Err(());
728 }
729 match value {
730 serde_json::Value::Array(values) => {
731 let next_depth = depth.checked_add(1).ok_or(())?;
732 stack.extend(values.iter().map(|value| (value, next_depth)));
733 }
734 serde_json::Value::Object(values) => {
735 let next_depth = depth.checked_add(1).ok_or(())?;
736 stack.extend(values.values().map(|value| (value, next_depth)));
737 }
738 serde_json::Value::Null
739 | serde_json::Value::Bool(_)
740 | serde_json::Value::Number(_)
741 | serde_json::Value::String(_) => {}
742 }
743 }
744 Ok(())
745}
746
747fn is_json_content_type(headers: &HeaderMap) -> bool {
748 let Some(value) = headers.get(header::CONTENT_TYPE) else {
749 return false;
750 };
751 let Ok(value) = value.to_str() else {
752 return false;
753 };
754 let media_type = value.split(';').next().unwrap_or_default().trim();
755 let media_type = media_type.to_ascii_lowercase();
756 media_type == "application/json"
757 || (media_type.starts_with("application/") && media_type.ends_with("+json"))
758}
759
760fn validate_batch(length: usize, state: &ServerState, request_id: &str) -> Result<(), ApiError> {
761 if length == 0 {
762 return Err(ApiError::invalid(request_id));
763 }
764 if length > state.limits.batch_items {
765 return Err(ApiError::limit(request_id));
766 }
767 Ok(())
768}
769
770fn parse_transaction_id(value: Option<&str>, request_id: &str) -> Result<Uuid, ApiError> {
771 value.map_or_else(
772 || Ok(Uuid::now_v7()),
773 |value| Uuid::parse_str(value).map_err(|_| ApiError::invalid(request_id)),
774 )
775}
776
777fn requested_timeout(
778 requested_ms: Option<u64>,
779 state: &ServerState,
780 request_id: &str,
781) -> Result<Duration, ApiError> {
782 let maximum_ms = u64::try_from(state.limits.query.timeout.as_millis()).unwrap_or(u64::MAX);
783 let requested_ms = requested_ms.unwrap_or(maximum_ms);
784 if requested_ms == 0 {
785 return Err(ApiError::invalid(request_id));
786 }
787 if requested_ms > maximum_ms {
788 return Err(ApiError::limit(request_id));
789 }
790 Ok(Duration::from_millis(requested_ms))
791}
792
793fn requested_retrieval_timeout(
794 requested_ms: Option<u64>,
795 state: &ServerState,
796 request_id: &str,
797) -> Result<Duration, ApiError> {
798 let maximum_ms =
799 u64::try_from(state.limits.exact_retrieval.timeout.as_millis()).unwrap_or(u64::MAX);
800 let requested_ms = requested_ms.unwrap_or(maximum_ms);
801 if requested_ms == 0 {
802 return Err(ApiError::invalid(request_id));
803 }
804 if requested_ms > maximum_ms {
805 return Err(ApiError::limit(request_id));
806 }
807 Ok(Duration::from_millis(requested_ms))
808}
809
810fn requested_lexical_timeout(
811 requested_ms: Option<u64>,
812 state: &ServerState,
813 request_id: &str,
814) -> Result<Duration, ApiError> {
815 let maximum_ms =
816 u64::try_from(state.limits.lexical_retrieval.timeout.as_millis()).unwrap_or(u64::MAX);
817 let requested_ms = requested_ms.unwrap_or(maximum_ms);
818 if requested_ms == 0 {
819 return Err(ApiError::invalid(request_id));
820 }
821 if requested_ms > maximum_ms {
822 return Err(ApiError::limit(request_id));
823 }
824 Ok(Duration::from_millis(requested_ms))
825}
826
827async fn with_engine<T, F>(
828 state: Arc<ServerState>,
829 request_id: &str,
830 operation: F,
831) -> Result<T, ApiError>
832where
833 T: Send + 'static,
834 F: FnOnce(&mut HyphaeEngine) -> Result<T, EngineError> + Send + 'static,
835{
836 if !state.ready.load(Ordering::Acquire) {
837 return Err(ApiError::unavailable(request_id));
838 }
839 let _permit = Arc::clone(&state.admission)
840 .try_acquire_owned()
841 .map_err(|_| busy(request_id))?;
842 let engine = Arc::clone(&state.engine);
843 let result = tokio::task::spawn_blocking(move || {
844 let mut engine = engine.lock().map_err(|_| EngineTaskError::Poisoned)?;
845 operation(&mut engine).map_err(EngineTaskError::Engine)
846 })
847 .await;
848 match result {
849 Ok(Ok(value)) => Ok(value),
850 Ok(Err(EngineTaskError::Engine(source))) => {
851 if engine_error_requires_recovery(&source) {
852 state.ready.store(false, Ordering::Release);
853 }
854 Err(ApiError::from_engine(source, request_id))
855 }
856 Ok(Err(EngineTaskError::Poisoned)) | Err(_) => {
857 state.ready.store(false, Ordering::Release);
858 Err(ApiError::internal(request_id))
859 }
860 }
861}
862
863enum EngineTaskError {
864 Engine(EngineError),
865 Poisoned,
866}
867
868struct WriteOutcome {
869 append: AppendOutcome,
870 requires_recovery: bool,
871}
872
873fn capture_write_outcome(
874 result: Result<AppendOutcome, EngineError>,
875) -> Result<WriteOutcome, EngineError> {
876 match result {
877 Ok(append) => Ok(WriteOutcome {
878 append,
879 requires_recovery: false,
880 }),
881 Err(EngineError::Storage(StorageError::CommittedButNotIndexed { receipt, .. })) => {
882 Ok(WriteOutcome {
883 append: AppendOutcome::Committed(receipt),
884 requires_recovery: true,
885 })
886 }
887 Err(source) => Err(source),
888 }
889}
890
891fn engine_error_requires_recovery(error: &EngineError) -> bool {
892 if matches!(
893 error,
894 EngineError::Proof(ProofError::ProofLimitExceeded { .. } | ProofError::LengthOverflow)
895 ) {
896 return false;
897 }
898 match error {
899 EngineError::Storage(StorageError::Index { source }) => {
900 materialized_index_error_requires_recovery(source)
901 }
902 EngineError::Storage(
903 StorageError::CommittedButNotIndexed { .. }
904 | StorageError::StaleIndex
905 | StorageError::Snapshot { .. }
906 | StorageError::DataDirectory(_)
907 | StorageError::Log(LogError::Poisoned),
908 )
909 | EngineError::Proof(_) => true,
910 _ => false,
911 }
912}
913
914fn materialized_index_error_requires_recovery(error: &MaterializedIndexError) -> bool {
915 !matches!(
916 error,
917 MaterializedIndexError::Vector(_)
918 | MaterializedIndexError::UnknownVectorSpace { .. }
919 | MaterializedIndexError::VectorSpaceConflict { .. }
920 | MaterializedIndexError::Lexical(_)
921 | MaterializedIndexError::LexicalIndexConflict { .. }
922 | MaterializedIndexError::UnknownLexicalIndex { .. }
923 | MaterializedIndexError::VectorCandidateBudgetExceeded { .. }
924 | MaterializedIndexError::VectorByteBudgetExceeded { .. }
925 )
926}
927
928fn proof_transport(
929 artifact: &ResultProofArtifact,
930 state: &ServerState,
931 request_id: &str,
932) -> Result<ProofV1, ApiError> {
933 let encoded = artifact
934 .proof
935 .to_bytes()
936 .map_err(|source| ApiError::from_engine(EngineError::Proof(source), request_id))?;
937 if encoded.len() > state.limits.proof_bytes
938 || artifact.snapshot.file_bytes > state.limits.witness_bytes
939 {
940 return Err(ApiError::result_too_large(request_id));
941 }
942 let anchor = artifact.proof.anchor();
943 let snapshot_digest = encode_hex(&anchor.snapshot_digest);
944 Ok(ProofV1 {
945 encoding: "base64".to_owned(),
946 data: BASE64.encode(encoded),
947 proof_digest: encode_hex(&artifact.proof.proof_digest()),
948 anchor_digest: encode_hex(&artifact.proof.anchor_digest()),
949 checkpoint_sequence: anchor.checkpoint_sequence,
950 checkpoint_digest: anchor
951 .checkpoint_digest
952 .as_ref()
953 .map(|digest| encode_hex(digest)),
954 snapshot_digest: snapshot_digest.clone(),
955 witness: WitnessV1 {
956 path: format!(
957 "/v1/witnesses/{}/{}",
958 anchor.checkpoint_sequence, snapshot_digest
959 ),
960 file_bytes: artifact.snapshot.file_bytes,
961 },
962 })
963}
964
965fn retrieval_proof_transport(
966 artifact: &ExactRetrievalProofArtifact,
967 state: &ServerState,
968 request_id: &str,
969) -> Result<RetrievalProofV1, ApiError> {
970 let encoded = artifact
971 .proof
972 .to_bytes()
973 .map_err(|source| ApiError::from_engine(EngineError::RetrievalProof(source), request_id))?;
974 if encoded.len() > state.limits.proof_bytes
975 || artifact.snapshot.file_bytes > state.limits.witness_bytes
976 {
977 return Err(ApiError::result_too_large(request_id));
978 }
979 let anchor = artifact.proof.anchor();
980 let snapshot_digest = encode_hex(&anchor.snapshot_digest);
981 Ok(RetrievalProofV1 {
982 encoding: "base64".to_owned(),
983 data: BASE64.encode(encoded),
984 proof_digest: encode_hex(&artifact.proof.proof_digest()),
985 anchor_digest: encode_hex(&artifact.proof.anchor_digest()),
986 checkpoint_sequence: anchor.checkpoint_sequence,
987 checkpoint_digest: anchor
988 .checkpoint_digest
989 .as_ref()
990 .map(|digest| encode_hex(digest)),
991 snapshot_digest: snapshot_digest.clone(),
992 witness: WitnessV1 {
993 path: format!(
994 "/v1/witnesses/{}/{}",
995 anchor.checkpoint_sequence, snapshot_digest
996 ),
997 file_bytes: artifact.snapshot.file_bytes,
998 },
999 })
1000}
1001
1002fn lexical_retrieval_proof_transport(
1003 artifact: &LexicalRetrievalProofArtifact,
1004 state: &ServerState,
1005 request_id: &str,
1006) -> Result<RetrievalProofV1, ApiError> {
1007 retrieval_proof_transport_parts(
1008 artifact.proof.to_bytes(),
1009 artifact.proof.proof_digest(),
1010 artifact.proof.anchor_digest(),
1011 artifact.proof.anchor(),
1012 artifact.snapshot.file_bytes,
1013 state,
1014 request_id,
1015 )
1016}
1017
1018fn hybrid_retrieval_proof_transport(
1019 artifact: &HybridRetrievalProofArtifact,
1020 state: &ServerState,
1021 request_id: &str,
1022) -> Result<RetrievalProofV1, ApiError> {
1023 retrieval_proof_transport_parts(
1024 artifact.proof.to_bytes(),
1025 artifact.proof.proof_digest(),
1026 artifact.proof.anchor_digest(),
1027 artifact.proof.anchor(),
1028 artifact.snapshot.file_bytes,
1029 state,
1030 request_id,
1031 )
1032}
1033
1034#[allow(clippy::too_many_arguments)]
1035fn retrieval_proof_transport_parts(
1036 encoded: Result<Vec<u8>, hyphae_engine::RetrievalProofError>,
1037 proof_digest: [u8; 32],
1038 anchor_digest: [u8; 32],
1039 anchor: &hyphae_engine::RetrievalProofAnchor,
1040 witness_bytes: u64,
1041 state: &ServerState,
1042 request_id: &str,
1043) -> Result<RetrievalProofV1, ApiError> {
1044 let encoded = encoded
1045 .map_err(|source| ApiError::from_engine(EngineError::RetrievalProof(source), request_id))?;
1046 if encoded.len() > state.limits.proof_bytes || witness_bytes > state.limits.witness_bytes {
1047 return Err(ApiError::result_too_large(request_id));
1048 }
1049 let snapshot_digest = encode_hex(&anchor.snapshot_digest);
1050 Ok(RetrievalProofV1 {
1051 encoding: "base64".to_owned(),
1052 data: BASE64.encode(encoded),
1053 proof_digest: encode_hex(&proof_digest),
1054 anchor_digest: encode_hex(&anchor_digest),
1055 checkpoint_sequence: anchor.checkpoint_sequence,
1056 checkpoint_digest: anchor
1057 .checkpoint_digest
1058 .as_ref()
1059 .map(|digest| encode_hex(digest)),
1060 snapshot_digest: snapshot_digest.clone(),
1061 witness: WitnessV1 {
1062 path: format!(
1063 "/v1/witnesses/{}/{}",
1064 anchor.checkpoint_sequence, snapshot_digest
1065 ),
1066 file_bytes: witness_bytes,
1067 },
1068 })
1069}
1070
1071fn exact_outcome_transport(outcome: &ExactRetrievalOutcome) -> ExactRetrievalOutcomeV1 {
1072 match outcome {
1073 ExactRetrievalOutcome::Matches {
1074 matches,
1075 scanned_candidates,
1076 } => ExactRetrievalOutcomeV1::Matches {
1077 matches: matches
1078 .iter()
1079 .map(|matched| ExactRetrievalMatchV1 {
1080 key_hex: encode_hex(&matched.key),
1081 score_nanos: matched.score_nanos,
1082 })
1083 .collect(),
1084 scanned_candidates: *scanned_candidates,
1085 },
1086 ExactRetrievalOutcome::Abstained(abstention) => ExactRetrievalOutcomeV1::Abstained {
1087 abstention: ExactAbstentionV1 {
1088 reason: match abstention.reason {
1089 ExactAbstentionReason::NoCandidates => ExactAbstentionReasonV1::NoCandidates,
1090 ExactAbstentionReason::BelowThreshold => {
1091 ExactAbstentionReasonV1::BelowThreshold
1092 }
1093 ExactAbstentionReason::Ambiguous => ExactAbstentionReasonV1::Ambiguous,
1094 },
1095 best_score_nanos: abstention.best_score_nanos,
1096 runner_up_score_nanos: abstention.runner_up_score_nanos,
1097 scanned_candidates: abstention.scanned_candidates,
1098 },
1099 },
1100 }
1101}
1102
1103fn lexical_outcome_transport(outcome: &LexicalOutcome) -> LexicalRetrievalOutcomeV1 {
1104 match outcome {
1105 LexicalOutcome::Matches {
1106 matches,
1107 scanned_documents,
1108 matched_documents,
1109 query_tokens,
1110 } => LexicalRetrievalOutcomeV1::Matches {
1111 matches: matches
1112 .iter()
1113 .map(|matched| LexicalRetrievalMatchV1 {
1114 key_hex: encode_hex(&matched.key),
1115 score_nanos: matched.score_nanos,
1116 terms: matched
1117 .terms
1118 .iter()
1119 .map(|term| LexicalTermContributionV1 {
1120 token: term.token.clone(),
1121 document_frequency: term.document_frequency,
1122 score_nanos: term.score_nanos,
1123 fields: term
1124 .fields
1125 .iter()
1126 .map(|field| LexicalFieldContributionV1 {
1127 path: field.path.segments().to_vec(),
1128 term_frequency: field.term_frequency,
1129 field_length: field.field_length,
1130 })
1131 .collect(),
1132 })
1133 .collect(),
1134 })
1135 .collect(),
1136 scanned_documents: *scanned_documents,
1137 matched_documents: *matched_documents,
1138 query_tokens: query_tokens.clone(),
1139 },
1140 LexicalOutcome::Abstained(abstention) => LexicalRetrievalOutcomeV1::Abstained {
1141 abstention: LexicalAbstentionV1 {
1142 reason: match abstention.reason {
1143 LexicalAbstentionReason::NoCandidates => {
1144 LexicalAbstentionReasonV1::NoCandidates
1145 }
1146 },
1147 scanned_documents: abstention.scanned_documents,
1148 query_tokens: abstention.query_tokens.clone(),
1149 },
1150 },
1151 }
1152}
1153
1154fn hybrid_outcome_transport(outcome: &HybridOutcome) -> HybridRetrievalOutcomeV1 {
1155 match outcome {
1156 HybridOutcome::Matches {
1157 matches,
1158 lexical_absence,
1159 vector_absence,
1160 } => HybridRetrievalOutcomeV1::Matches {
1161 matches: matches
1162 .iter()
1163 .map(|matched| HybridRetrievalMatchV1 {
1164 key_hex: encode_hex(&matched.key),
1165 explanation: HybridExplanationV1 {
1166 lexical_rank: matched.explanation.lexical_rank,
1167 lexical_score_nanos: matched.explanation.lexical_score_nanos,
1168 vector_rank: matched.explanation.vector_rank,
1169 vector_score_nanos: matched.explanation.vector_score_nanos,
1170 lexical_contribution: matched.explanation.lexical_contribution,
1171 vector_contribution: matched.explanation.vector_contribution,
1172 fusion_score: matched.explanation.fusion_score,
1173 final_rank: matched.explanation.final_rank,
1174 },
1175 })
1176 .collect(),
1177 lexical_absence: lexical_absence.map(hybrid_absence_transport),
1178 vector_absence: vector_absence.map(hybrid_absence_transport),
1179 },
1180 HybridOutcome::Abstained(abstention) => HybridRetrievalOutcomeV1::Abstained {
1181 abstention: HybridAbstentionV1 {
1182 lexical: hybrid_absence_transport(abstention.lexical),
1183 vector: hybrid_absence_transport(abstention.vector),
1184 },
1185 },
1186 }
1187}
1188
1189fn hybrid_absence_transport(absence: HybridBranchAbsence) -> HybridBranchAbsenceV1 {
1190 match absence {
1191 HybridBranchAbsence::LexicalNoCandidates => HybridBranchAbsenceV1::LexicalNoCandidates,
1192 HybridBranchAbsence::VectorNoCandidates => HybridBranchAbsenceV1::VectorNoCandidates,
1193 HybridBranchAbsence::VectorBelowThreshold => HybridBranchAbsenceV1::VectorBelowThreshold,
1194 HybridBranchAbsence::VectorAmbiguous => HybridBranchAbsenceV1::VectorAmbiguous,
1195 }
1196}
1197
1198fn receipt(outcome: AppendOutcome) -> CommitReceiptV1 {
1199 let (status, receipt) = match outcome {
1200 AppendOutcome::Committed(receipt) => ("committed", receipt),
1201 AppendOutcome::Existing(receipt) => ("existing", receipt),
1202 };
1203 CommitReceiptV1 {
1204 status: status.to_owned(),
1205 transaction_id: receipt.transaction_id.to_string(),
1206 commit_sequence: receipt.commit_sequence,
1207 commit_digest: encode_hex(&receipt.commit_digest),
1208 transaction_digest: encode_hex(&receipt.transaction_digest),
1209 }
1210}
1211
1212fn bounded_json<T: Serialize>(
1213 value: &T,
1214 state: &ServerState,
1215 request_id: &str,
1216) -> Result<Response, ApiError> {
1217 let encoded = serde_json::to_vec(value).map_err(|_| ApiError::internal(request_id))?;
1218 if encoded.len() > state.limits.response_bytes {
1219 return Err(ApiError::result_too_large(request_id));
1220 }
1221 Response::builder()
1222 .status(StatusCode::OK)
1223 .header(header::CONTENT_TYPE, "application/json")
1224 .header(header::CONTENT_LENGTH, encoded.len())
1225 .body(Body::from(encoded))
1226 .map_err(|_| ApiError::internal(request_id))
1227}
1228
1229fn parse_witness_path(path: &str) -> Option<(u64, [u8; 32])> {
1230 let suffix = path.strip_prefix("/v1/witnesses/")?;
1231 let mut components = suffix.split('/');
1232 let sequence = components.next()?.parse().ok()?;
1233 let digest = components.next()?;
1234 if components.next().is_some()
1235 || digest.len() != 64
1236 || !digest
1237 .bytes()
1238 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1239 {
1240 return None;
1241 }
1242 let decoded = decode_key_hex(digest).ok()?;
1243 decoded.try_into().ok().map(|digest| (sequence, digest))
1244}
1245
1246fn request_id(request: &Request) -> String {
1247 request
1248 .extensions()
1249 .get::<RequestId>()
1250 .map_or_else(|| Uuid::now_v7().to_string(), |value| value.0.clone())
1251}
1252
1253fn busy(request_id: &str) -> ApiError {
1254 ApiError::new(
1255 StatusCode::TOO_MANY_REQUESTS,
1256 "busy",
1257 "concurrent operation admission limit reached",
1258 request_id,
1259 )
1260}
1261
1262fn not_found(request_id: &str) -> ApiError {
1263 ApiError::new(
1264 StatusCode::NOT_FOUND,
1265 "not_found",
1266 "requested version 1 resource does not exist",
1267 request_id,
1268 )
1269}
1270
1271async fn route_not_found(Extension(request_id): Extension<RequestId>) -> ApiError {
1272 not_found(&request_id.0)
1273}
1274
1275async fn method_not_allowed(Extension(request_id): Extension<RequestId>) -> ApiError {
1276 ApiError::new(
1277 StatusCode::METHOD_NOT_ALLOWED,
1278 "method_not_allowed",
1279 "HTTP method is not defined for this version 1 route",
1280 request_id.0,
1281 )
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use std::{error::Error, fs, net::Ipv4Addr, path::PathBuf, sync::Arc, time::Duration};
1287
1288 use axum::{body::Body, http::Request};
1289 use serde_json::Value;
1290 use tokio::{
1291 io::{AsyncReadExt as _, AsyncWriteExt as _},
1292 net::TcpStream,
1293 sync::oneshot,
1294 };
1295 use tokio_util::io::ReaderStream;
1296 use tower::ServiceExt;
1297
1298 use hyphae_engine::EngineError;
1299 use hyphae_storage::{AppendOutcome, CommitReceipt, MaterializedIndexError, StorageError};
1300
1301 use super::{HyphaeServer, ServerConfig, StatusCode, body, capture_write_outcome};
1302 use crate::{BearerToken, ServerConfigError};
1303
1304 struct TestDirectory {
1305 path: PathBuf,
1306 }
1307
1308 impl TestDirectory {
1309 fn create(name: &str) -> Result<Self, Box<dyn Error>> {
1310 let path = std::env::temp_dir().join(format!(
1311 "hyphae-server-{name}-{}-{}",
1312 std::process::id(),
1313 uuid::Uuid::now_v7()
1314 ));
1315 fs::create_dir_all(&path)?;
1316 Ok(Self { path })
1317 }
1318 }
1319
1320 impl Drop for TestDirectory {
1321 fn drop(&mut self) {
1322 let _ignored = fs::remove_dir_all(&self.path);
1323 }
1324 }
1325
1326 #[test]
1327 fn remote_bind_is_rejected_before_socket_bind() -> Result<(), Box<dyn Error>> {
1328 let temporary = TestDirectory::create("remote-rejected")?;
1329 let mut config = ServerConfig::new(&temporary.path);
1330 config.bind = (Ipv4Addr::UNSPECIFIED, 8_787).into();
1331 assert!(matches!(
1332 HyphaeServer::open(config),
1333 Err(crate::ServerError::Configuration(
1334 ServerConfigError::RemoteBindRequiresAuthentication { .. }
1335 ))
1336 ));
1337 Ok(())
1338 }
1339
1340 #[test]
1341 fn bearer_tokens_require_visible_header_safe_entropy() {
1342 assert!(BearerToken::new("short").is_err());
1343 assert!(BearerToken::new("0123456789abcdef0123456789abcde\n").is_err());
1344 assert!(BearerToken::new("0123456789abcdef0123456789abcdef").is_ok());
1345 }
1346
1347 #[test]
1348 fn durable_unmaterialized_commit_keeps_its_public_receipt() -> Result<(), Box<dyn Error>> {
1349 let receipt = CommitReceipt {
1350 transaction_id: uuid::Uuid::now_v7(),
1351 commit_sequence: 9,
1352 commit_digest: [7; 32],
1353 transaction_digest: [8; 32],
1354 };
1355 let outcome = capture_write_outcome(Err(EngineError::Storage(
1356 StorageError::CommittedButNotIndexed {
1357 receipt,
1358 source: Box::new(MaterializedIndexError::MalformedCheckpoint),
1359 },
1360 )))?;
1361 assert!(outcome.requires_recovery);
1362 assert!(matches!(
1363 outcome.append,
1364 AppendOutcome::Committed(actual) if actual == receipt
1365 ));
1366 Ok(())
1367 }
1368
1369 #[tokio::test]
1370 async fn authenticated_put_get_and_witness_are_contract_shaped() -> Result<(), Box<dyn Error>> {
1371 let temporary = TestDirectory::create("authenticated-flow")?;
1372 let secret = "correct-hyphae-token-material-0001";
1373 let mut config = ServerConfig::new(&temporary.path);
1374 config.bearer_token = Some(BearerToken::new(secret)?);
1375 let app = HyphaeServer::open(config)?.test_router();
1376
1377 let unauthorized = app
1378 .clone()
1379 .oneshot(json_request("/v1/kv/put", r#"{"records":[]}"#, None)?)
1380 .await?;
1381 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
1382 assert!(unauthorized.headers().contains_key("x-request-id"));
1383
1384 let wrong = app
1385 .clone()
1386 .oneshot(json_request(
1387 "/v1/kv/put",
1388 r#"{"records":[]}"#,
1389 Some("incorrect-hyphae-token-material-001"),
1390 )?)
1391 .await?;
1392 assert_error(wrong, StatusCode::UNAUTHORIZED, "unauthorized").await?;
1393
1394 let duplicate_header = app
1395 .clone()
1396 .oneshot(
1397 Request::builder()
1398 .method("POST")
1399 .uri("/v1/query")
1400 .header("content-type", "application/json")
1401 .header("authorization", format!("Bearer {secret}"))
1402 .header("authorization", format!("Bearer {secret}"))
1403 .body(Body::from(r#"{"limit":1}"#))?,
1404 )
1405 .await?;
1406 assert_error(duplicate_header, StatusCode::UNAUTHORIZED, "unauthorized").await?;
1407
1408 let put = app
1409 .clone()
1410 .oneshot(json_request(
1411 "/v1/kv/put",
1412 r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":7}}]}"#,
1413 Some(secret),
1414 )?)
1415 .await?;
1416 assert_eq!(put.status(), StatusCode::OK);
1417 let put: Value = serde_json::from_slice(&response_bytes(put).await?)?;
1418 assert_eq!(put["status"], "committed");
1419
1420 let retry = app
1421 .clone()
1422 .oneshot(json_request(
1423 "/v1/kv/put",
1424 r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":7}}]}"#,
1425 Some(secret),
1426 )?)
1427 .await?;
1428 assert_eq!(retry.status(), StatusCode::OK);
1429 let retry: Value = serde_json::from_slice(&response_bytes(retry).await?)?;
1430 assert_eq!(retry["status"], "existing");
1431
1432 let conflict = app
1433 .clone()
1434 .oneshot(json_request(
1435 "/v1/kv/put",
1436 r#"{"transaction_id":"018f0000-0000-7000-8000-000000000001","records":[{"key_hex":"61","value":{"score":8}}]}"#,
1437 Some(secret),
1438 )?)
1439 .await?;
1440 assert_error(conflict, StatusCode::CONFLICT, "idempotency_conflict").await?;
1441
1442 let get = app
1443 .clone()
1444 .oneshot(json_request(
1445 "/v1/kv/get",
1446 r#"{"key_hex":"61"}"#,
1447 Some(secret),
1448 )?)
1449 .await?;
1450 assert_eq!(get.status(), StatusCode::OK);
1451 let get: Value = serde_json::from_slice(&response_bytes(get).await?)?;
1452 assert_eq!(get["found"], true);
1453 assert_eq!(get["record"]["value"]["score"], 7);
1454 assert_eq!(get["proof"]["encoding"], "base64");
1455 let witness_path = get["proof"]["witness"]["path"]
1456 .as_str()
1457 .ok_or("missing witness path")?;
1458
1459 let witness = app
1460 .clone()
1461 .oneshot(
1462 Request::builder()
1463 .uri(witness_path)
1464 .header("authorization", format!("Bearer {secret}"))
1465 .body(Body::empty())?,
1466 )
1467 .await?;
1468 assert_eq!(witness.status(), StatusCode::OK);
1469 assert!(witness.headers().contains_key("digest"));
1470 assert!(response_bytes(witness).await?.starts_with(b"HYSNAP01"));
1471
1472 let query = app
1473 .oneshot(json_request("/v1/query", r#"{"limit":10}"#, Some(secret))?)
1474 .await?;
1475 assert_eq!(query.status(), StatusCode::OK);
1476 let query: Value = serde_json::from_slice(&response_bytes(query).await?)?;
1477 assert_eq!(query["rows"].as_array().map(Vec::len), Some(1));
1478 assert_eq!(query["proof"]["encoding"], "base64");
1479 Ok(())
1480 }
1481
1482 #[tokio::test]
1483 async fn public_routes_and_limit_failures_never_emit_framework_text()
1484 -> Result<(), Box<dyn Error>> {
1485 let temporary = TestDirectory::create("limits")?;
1486 let mut config = ServerConfig::new(&temporary.path);
1487 config.limits.request_body_bytes = 1_024;
1488 config.limits.batch_items = 1;
1489 let app = HyphaeServer::open(config)?.test_router();
1490
1491 let capabilities = app
1492 .clone()
1493 .oneshot(
1494 Request::builder()
1495 .uri("/v1/capabilities")
1496 .body(Body::empty())?,
1497 )
1498 .await?;
1499 assert_eq!(capabilities.status(), StatusCode::OK);
1500 let capabilities: Value = serde_json::from_slice(&response_bytes(capabilities).await?)?;
1501 assert_eq!(capabilities["api_version"], "v1");
1502 assert_eq!(capabilities["limits"]["batch_items"], 1);
1503
1504 let too_many = app
1505 .clone()
1506 .oneshot(json_request(
1507 "/v1/kv/delete",
1508 r#"{"keys_hex":["61","62"]}"#,
1509 None,
1510 )?)
1511 .await?;
1512 assert_error(too_many, StatusCode::UNPROCESSABLE_ENTITY, "limit_exceeded").await?;
1513
1514 let unsupported = app
1515 .clone()
1516 .oneshot(
1517 Request::builder()
1518 .method("POST")
1519 .uri("/v1/query")
1520 .header("content-type", "text/plain")
1521 .body(Body::from("{}"))?,
1522 )
1523 .await?;
1524 assert_error(
1525 unsupported,
1526 StatusCode::UNSUPPORTED_MEDIA_TYPE,
1527 "unsupported_media_type",
1528 )
1529 .await?;
1530
1531 let oversized = app
1532 .clone()
1533 .oneshot(json_request(
1534 "/v1/query",
1535 &format!(r#"{{"limit":1,"ignored":"{}"}}"#, "x".repeat(2_000)),
1536 None,
1537 )?)
1538 .await?;
1539 assert_error(
1540 oversized,
1541 StatusCode::PAYLOAD_TOO_LARGE,
1542 "payload_too_large",
1543 )
1544 .await?;
1545
1546 let missing = app
1547 .clone()
1548 .oneshot(Request::builder().uri("/v1/unknown").body(Body::empty())?)
1549 .await?;
1550 assert_error(missing, StatusCode::NOT_FOUND, "not_found").await?;
1551
1552 let wrong_method = app
1553 .oneshot(
1554 Request::builder()
1555 .method("DELETE")
1556 .uri("/v1/health/live")
1557 .body(Body::empty())?,
1558 )
1559 .await?;
1560 assert_error(
1561 wrong_method,
1562 StatusCode::METHOD_NOT_ALLOWED,
1563 "method_not_allowed",
1564 )
1565 .await?;
1566 Ok(())
1567 }
1568
1569 #[tokio::test]
1570 async fn vector_lexical_and_hybrid_routes_return_proof_bearing_results()
1571 -> Result<(), Box<dyn Error>> {
1572 let temporary = TestDirectory::create("retrieval-flow")?;
1573 let app = HyphaeServer::open(ServerConfig::new(&temporary.path))?.test_router();
1574
1575 for (path, payload) in [
1576 (
1577 "/v1/kv/put",
1578 r#"{"records":[{"key_hex":"616c706861","value":{"title":"Durable memory","body":"offline agent memory"}},{"key_hex":"62657461","value":{"title":"Fast search","body":"exact vector retrieval"}}]}"#,
1579 ),
1580 (
1581 "/v1/lexical-indexes/define",
1582 r#"{"lexical_index":{"name":"content","fields":[{"path":["title"],"weight_micros":2000000},{"path":["body"],"weight_micros":1000000}]}}"#,
1583 ),
1584 (
1585 "/v1/vector-spaces/define",
1586 r#"{"vector_space":{"name":"semantic","dimension":2,"metric":"cosine_q15_nanos"}}"#,
1587 ),
1588 (
1589 "/v1/vectors/put",
1590 r#"{"vector_space":"semantic","vectors":[{"key_hex":"616c706861","values":[32767,0]},{"key_hex":"62657461","values":[0,32767]}]}"#,
1591 ),
1592 ] {
1593 let response = app
1594 .clone()
1595 .oneshot(json_request(path, payload, None)?)
1596 .await?;
1597 assert_eq!(response.status(), StatusCode::OK, "{path}");
1598 }
1599
1600 let exact = app
1601 .clone()
1602 .oneshot(json_request(
1603 "/v1/retrieve/exact",
1604 r#"{"vector_space":"semantic","query":[32767,0],"limit":2,"minimum_score_nanos":-1000000000,"minimum_margin_nanos":0}"#,
1605 None,
1606 )?)
1607 .await?;
1608 assert_eq!(exact.status(), StatusCode::OK);
1609 let exact: Value = serde_json::from_slice(&response_bytes(exact).await?)?;
1610 assert_eq!(exact["outcome"]["matches"][0]["key_hex"], "616c706861");
1611 assert_eq!(exact["proof"]["encoding"], "base64");
1612
1613 let lexical = app
1614 .clone()
1615 .oneshot(json_request(
1616 "/v1/retrieve/lexical",
1617 r#"{"lexical_index":"content","query":"durable memory","limit":2}"#,
1618 None,
1619 )?)
1620 .await?;
1621 assert_eq!(lexical.status(), StatusCode::OK);
1622 let lexical: Value = serde_json::from_slice(&response_bytes(lexical).await?)?;
1623 assert_eq!(lexical["outcome"]["matches"][0]["key_hex"], "616c706861");
1624 assert_eq!(lexical["proof"]["encoding"], "base64");
1625
1626 let hybrid = app
1627 .clone()
1628 .oneshot(json_request(
1629 "/v1/retrieve/hybrid",
1630 r#"{"lexical":{"lexical_index":"content","query":"durable memory","limit":2},"vector":{"vector_space":"semantic","query":[32767,0],"limit":2,"minimum_score_nanos":-1000000000,"minimum_margin_nanos":0},"lexical_weight":1,"vector_weight":1,"limit":2}"#,
1631 None,
1632 )?)
1633 .await?;
1634 assert_eq!(hybrid.status(), StatusCode::OK);
1635 let hybrid: Value = serde_json::from_slice(&response_bytes(hybrid).await?)?;
1636 assert_eq!(hybrid["outcome"]["matches"][0]["key_hex"], "616c706861");
1637 assert_eq!(
1638 hybrid["outcome"]["matches"][0]["explanation"]["final_rank"],
1639 1
1640 );
1641 assert_eq!(hybrid["proof"]["encoding"], "base64");
1642
1643 let wrong_dimension = app
1644 .clone()
1645 .oneshot(json_request(
1646 "/v1/retrieve/exact",
1647 r#"{"vector_space":"semantic","query":[32767],"limit":2,"minimum_score_nanos":-1000000000,"minimum_margin_nanos":0}"#,
1648 None,
1649 )?)
1650 .await?;
1651 assert_error(wrong_dimension, StatusCode::BAD_REQUEST, "invalid_request").await?;
1652
1653 let empty_query = app
1654 .oneshot(json_request(
1655 "/v1/retrieve/lexical",
1656 r#"{"lexical_index":"content","query":"---","limit":2}"#,
1657 None,
1658 )?)
1659 .await?;
1660 assert_error(empty_query, StatusCode::BAD_REQUEST, "invalid_request").await?;
1661 Ok(())
1662 }
1663
1664 #[tokio::test]
1665 async fn shape_proof_and_admission_limits_fail_without_partial_results()
1666 -> Result<(), Box<dyn Error>> {
1667 let temporary = TestDirectory::create("bounded-work")?;
1668 let mut config = ServerConfig::new(&temporary.path);
1669 config.limits.json_depth = 3;
1670 config.limits.concurrent_operations = 1;
1671 config.limits.proof_bytes = 128;
1672 let server = HyphaeServer::open(config)?;
1673 let app = server.test_router();
1674
1675 let too_deep = app
1676 .clone()
1677 .oneshot(json_request(
1678 "/v1/query",
1679 r#"{"filter":{"op":"not","filter":{"op":"not","filter":{"op":"not","filter":{"op":"match_all"}}}},"limit":1}"#,
1680 None,
1681 )?)
1682 .await?;
1683 assert_error(too_deep, StatusCode::UNPROCESSABLE_ENTITY, "limit_exceeded").await?;
1684
1685 let put = app
1686 .clone()
1687 .oneshot(json_request(
1688 "/v1/kv/put",
1689 r#"{"records":[{"key_hex":"61","value":1}]}"#,
1690 None,
1691 )?)
1692 .await?;
1693 assert_eq!(put.status(), StatusCode::OK);
1694
1695 let proof_too_large = app
1696 .clone()
1697 .oneshot(json_request("/v1/kv/get", r#"{"key_hex":"61"}"#, None)?)
1698 .await?;
1699 assert_error(
1700 proof_too_large,
1701 StatusCode::PAYLOAD_TOO_LARGE,
1702 "result_too_large",
1703 )
1704 .await?;
1705
1706 let permit = Arc::clone(&server.state.admission).try_acquire_owned()?;
1707 let busy = app
1708 .clone()
1709 .oneshot(json_request("/v1/query", r#"{"limit":1}"#, None)?)
1710 .await?;
1711 drop(permit);
1712 assert_error(busy, StatusCode::TOO_MANY_REQUESTS, "busy").await?;
1713
1714 server
1715 .state
1716 .ready
1717 .store(false, std::sync::atomic::Ordering::Release);
1718 let unavailable = app
1719 .oneshot(
1720 Request::builder()
1721 .uri("/v1/health/ready")
1722 .body(Body::empty())?,
1723 )
1724 .await?;
1725 assert_error(unavailable, StatusCode::SERVICE_UNAVAILABLE, "unavailable").await?;
1726 Ok(())
1727 }
1728
1729 #[tokio::test]
1730 async fn stalled_json_body_times_out_before_any_operation_starts() -> Result<(), Box<dyn Error>>
1731 {
1732 let temporary = TestDirectory::create("body-timeout")?;
1733 let mut config = ServerConfig::new(&temporary.path);
1734 config.limits.request_body_timeout = Duration::from_millis(5);
1735 let app = HyphaeServer::open(config)?.test_router();
1736 let (_writer, reader) = tokio::io::duplex(1);
1737 let response = app
1738 .oneshot(
1739 Request::builder()
1740 .method("POST")
1741 .uri("/v1/query")
1742 .header("content-type", "application/json")
1743 .body(Body::from_stream(ReaderStream::new(reader)))?,
1744 )
1745 .await?;
1746 assert_error(response, StatusCode::REQUEST_TIMEOUT, "timeout").await?;
1747 Ok(())
1748 }
1749
1750 #[tokio::test]
1751 async fn bound_server_stops_on_graceful_shutdown() -> Result<(), Box<dyn Error>> {
1752 let temporary = TestDirectory::create("graceful")?;
1753 let mut config = ServerConfig::new(&temporary.path);
1754 config.bind.set_port(0);
1755 let bound = HyphaeServer::open(config)?.bind().await?;
1756 let local_addr = bound.local_addr();
1757 assert_ne!(local_addr.port(), 0);
1758 let (send, receive) = oneshot::channel::<()>();
1759 let serving = tokio::spawn(bound.run_with_shutdown(async move {
1760 let _ignored = receive.await;
1761 }));
1762 let mut connection = TcpStream::connect(local_addr).await?;
1763 connection
1764 .write_all(
1765 b"GET /v1/health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
1766 )
1767 .await?;
1768 let mut response = Vec::new();
1769 connection.read_to_end(&mut response).await?;
1770 assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n"));
1771 assert!(response.ends_with(br#"{"status":"live"}"#));
1772 let _ignored = send.send(());
1773 serving.await??;
1774 Ok(())
1775 }
1776
1777 fn json_request(
1778 uri: &str,
1779 body: &str,
1780 bearer: Option<&str>,
1781 ) -> Result<Request<Body>, axum::http::Error> {
1782 let mut request = Request::builder()
1783 .method("POST")
1784 .uri(uri)
1785 .header("content-type", "application/json");
1786 if let Some(bearer) = bearer {
1787 request = request.header("authorization", format!("Bearer {bearer}"));
1788 }
1789 request.body(Body::from(body.to_owned()))
1790 }
1791
1792 async fn response_bytes(
1793 response: axum::response::Response,
1794 ) -> Result<axum::body::Bytes, Box<dyn Error>> {
1795 Ok(body::to_bytes(response.into_body(), 64 * 1024 * 1024).await?)
1796 }
1797
1798 async fn assert_error(
1799 response: axum::response::Response,
1800 status: StatusCode,
1801 code: &str,
1802 ) -> Result<(), Box<dyn Error>> {
1803 assert_eq!(response.status(), status);
1804 let header_request_id = response
1805 .headers()
1806 .get("x-request-id")
1807 .and_then(|value| value.to_str().ok())
1808 .ok_or("missing request ID header")?
1809 .to_owned();
1810 let value: Value = serde_json::from_slice(&response_bytes(response).await?)?;
1811 assert_eq!(value["code"], code);
1812 assert_eq!(value["request_id"], header_request_id);
1813 Ok(())
1814 }
1815}