This commit implements comprehensive production hardening across multiple layers to prepare StemeDB for enterprise pilot deployments: ## API Layer - Add rate limiting middleware with configurable limits per endpoint - Enhance error handling with detailed context and proper HTTP status codes - Add security hardening tests for input validation and boundary conditions - Create store_helpers module for defensive storage access patterns ## Storage & WAL - Optimize group commit batching for higher throughput - Add defensive error handling in hybrid backend with proper fallbacks - Enhance WAL journal durability guarantees with fsync validation - Improve index store query performance with better caching ## Operations & Deployment - Add comprehensive operations documentation (deployment, monitoring, DR) - Create systemd units for backup, WAL archival, and verification - Add monitoring configs (Prometheus alerts, metrics exporters) - Implement backup/restore scripts with verification and S3 archival - Add DR drill automation and runbook procedures - Create load balancer configs (nginx, envoy) with health checks ## Documentation - Update CLAUDE.md with operations and troubleshooting guides - Expand roadmap with production readiness milestones - Add pilot success criteria and deployment reference architecture - Document TLS setup, monitoring integration, and incident response ## Configuration - Add .env.example with all required environment variables - Document resource sizing for different deployment scales - Add configuration examples for various deployment topologies This positions StemeDB for successful enterprise pilots with proper operational discipline, monitoring, backup/DR, and security hardening. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
134 lines
4.5 KiB
Rust
134 lines
4.5 KiB
Rust
//! Handler for creating votes.
|
|
|
|
use axum::{extract::State, http::StatusCode, Json};
|
|
use tracing::instrument;
|
|
|
|
use crate::{
|
|
dto::{CreateResponse, CreateVoteRequest, ErrorResponse},
|
|
error::{ApiError, Result},
|
|
hex,
|
|
state::AppState,
|
|
};
|
|
|
|
use stemedb_core::types::Vote;
|
|
use stemedb_ingest::worker::serialize_vote;
|
|
|
|
/// Create a new vote on an assertion.
|
|
///
|
|
/// This endpoint accepts a vote DTO, validates weight bounds (0.0-1.0),
|
|
/// converts hex-encoded fields to binary, serializes the vote to WAL format,
|
|
/// and appends it to the journal. Returns the content-addressed BLAKE3 hash.
|
|
///
|
|
/// # Validation
|
|
/// - Weight must be between 0.0 and 1.0
|
|
/// - All hex fields must have correct lengths (32 bytes for agent_id/assertion_hash, 64 bytes for signature)
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/v1/vote",
|
|
request_body = CreateVoteRequest,
|
|
responses(
|
|
(status = 201, description = "Vote created successfully", body = CreateResponse),
|
|
(status = 400, description = "Invalid request", body = ErrorResponse),
|
|
(status = 500, description = "Internal server error", body = ErrorResponse)
|
|
),
|
|
tag = "votes"
|
|
)]
|
|
#[instrument(skip(state), fields(assertion_hash = %req.assertion_hash, weight = %req.weight))]
|
|
pub async fn create_vote(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<CreateVoteRequest>,
|
|
) -> Result<(StatusCode, Json<CreateResponse>)> {
|
|
let start = std::time::Instant::now();
|
|
metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/vote").increment(1);
|
|
|
|
// Convert DTO to internal Vote type
|
|
let vote = dto_to_vote(req)?;
|
|
|
|
// Serialize to WAL format (includes record type header)
|
|
let payload = serialize_vote(&vote)
|
|
.map_err(|e| ApiError::Serialization(format!("Failed to serialize vote: {}", e)))?;
|
|
|
|
// Compute the content-addressed hash
|
|
let serialized_vote = stemedb_core::serde::serialize(&vote)
|
|
.map_err(|e| ApiError::Serialization(format!("Failed to serialize for hash: {}", e)))?;
|
|
let hash = blake3::hash(&serialized_vote);
|
|
|
|
// Append to WAL via group commit buffer
|
|
state.commit_buffer.append(payload).await?;
|
|
|
|
let response =
|
|
CreateResponse { hash: hash.to_hex().to_string(), status: "created".to_string() };
|
|
|
|
// Track request duration (success case)
|
|
metrics::histogram!("stemedb_http_request_duration_seconds",
|
|
"method" => "POST",
|
|
"path" => "/v1/vote",
|
|
"status" => "201"
|
|
).record(start.elapsed().as_secs_f64());
|
|
|
|
Ok((StatusCode::CREATED, Json(response)))
|
|
}
|
|
|
|
/// Convert CreateVoteRequest DTO to internal Vote type.
|
|
fn dto_to_vote(req: CreateVoteRequest) -> Result<Vote> {
|
|
// Validate weight bounds (0.0 to 1.0)
|
|
if req.weight < 0.0 || req.weight > 1.0 {
|
|
return Err(ApiError::InvalidRequest(format!(
|
|
"Weight must be between 0.0 and 1.0, got {}",
|
|
req.weight
|
|
)));
|
|
}
|
|
|
|
// Validate source_url if provided
|
|
if let Some(ref url) = req.source_url {
|
|
// Check if empty
|
|
if url.is_empty() {
|
|
return Err(ApiError::InvalidRequest(
|
|
"source_url must not be empty if provided".to_string(),
|
|
));
|
|
}
|
|
// Check max length (2048 characters)
|
|
if url.len() > 2048 {
|
|
return Err(ApiError::InvalidRequest(format!(
|
|
"source_url exceeds maximum length of 2048 characters, got {}",
|
|
url.len()
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Validate observed_context size if provided
|
|
let observed_context = if let Some(ref context_str) = req.observed_context {
|
|
let context_bytes = context_str.as_bytes();
|
|
// Check max size (64KB)
|
|
if context_bytes.len() > 65536 {
|
|
return Err(ApiError::InvalidRequest(format!(
|
|
"observed_context exceeds maximum size of 64KB, got {} bytes",
|
|
context_bytes.len()
|
|
)));
|
|
}
|
|
Some(context_bytes.to_vec())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let assertion_hash = hex::decode_hash_32(&req.assertion_hash)?;
|
|
let agent_id = hex::decode_hash_32(&req.agent_id)?;
|
|
let signature = hex::decode_signature(&req.signature)?;
|
|
|
|
// Get current timestamp
|
|
let timestamp = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_err(|e| ApiError::Serialization(format!("Failed to get timestamp: {}", e)))?
|
|
.as_secs();
|
|
|
|
Ok(Vote {
|
|
assertion_hash,
|
|
agent_id,
|
|
weight: req.weight,
|
|
signature,
|
|
timestamp,
|
|
source_url: req.source_url,
|
|
observed_context,
|
|
})
|
|
}
|