Skip to main content

allsource_core/infrastructure/web/
api_v1.rs

1/// v1.0 API router with authentication and multi-tenancy
2use crate::infrastructure::di::ServiceContainer;
3#[cfg(feature = "multi-tenant")]
4use crate::infrastructure::web::tenant_api::*;
5use crate::{
6    domain::repositories::TenantRepository,
7    infrastructure::{
8        cluster::{
9            ClusterManager, ClusterMember, GeoReplicationManager, GeoSyncRequest, VoteRequest,
10        },
11        replication::{WalReceiver, WalShipper},
12        security::{
13            auth::AuthManager,
14            middleware::{AuthState, RateLimitState, auth_middleware, rate_limit_middleware},
15            rate_limit::RateLimiter,
16        },
17        web::{audit_api::*, auth_api::*, config_api::*},
18    },
19    store::EventStore,
20};
21use axum::{
22    Json, Router,
23    extract::{Path, State},
24    middleware,
25    response::IntoResponse,
26    routing::{delete, get, post, put},
27};
28use std::sync::Arc;
29use tower_http::{
30    cors::{Any, CorsLayer},
31    trace::TraceLayer,
32};
33
34/// Node role for leader-follower replication
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
36#[serde(rename_all = "lowercase")]
37pub enum NodeRole {
38    Leader,
39    Follower,
40}
41
42impl NodeRole {
43    /// Detect role from environment variables.
44    ///
45    /// Checks `ALLSOURCE_ROLE` ("leader" or "follower") first,
46    /// then falls back to `ALLSOURCE_READ_ONLY` ("true" → follower).
47    /// Defaults to `Leader` if neither is set.
48    pub fn from_env() -> Self {
49        if let Ok(role) = std::env::var("ALLSOURCE_ROLE") {
50            match role.to_lowercase().as_str() {
51                "follower" => return NodeRole::Follower,
52                "leader" => return NodeRole::Leader,
53                other => {
54                    tracing::warn!(
55                        "Unknown ALLSOURCE_ROLE value '{}', defaulting to leader",
56                        other
57                    );
58                    return NodeRole::Leader;
59                }
60            }
61        }
62        if let Ok(read_only) = std::env::var("ALLSOURCE_READ_ONLY")
63            && (read_only == "true" || read_only == "1")
64        {
65            return NodeRole::Follower;
66        }
67        NodeRole::Leader
68    }
69
70    pub fn is_follower(self) -> bool {
71        self == NodeRole::Follower
72    }
73
74    fn to_u8(self) -> u8 {
75        match self {
76            NodeRole::Leader => 0,
77            NodeRole::Follower => 1,
78        }
79    }
80
81    fn from_u8(v: u8) -> Self {
82        match v {
83            1 => NodeRole::Follower,
84            _ => NodeRole::Leader,
85        }
86    }
87}
88
89impl std::fmt::Display for NodeRole {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            NodeRole::Leader => write!(f, "leader"),
93            NodeRole::Follower => write!(f, "follower"),
94        }
95    }
96}
97
98/// Thread-safe, runtime-mutable node role for failover support.
99///
100/// Wraps an `AtomicU8` so the read-only middleware and health endpoint
101/// always see the current role, even after a sentinel-triggered promotion.
102#[derive(Clone)]
103pub struct AtomicNodeRole(Arc<std::sync::atomic::AtomicU8>);
104
105impl AtomicNodeRole {
106    pub fn new(role: NodeRole) -> Self {
107        Self(Arc::new(std::sync::atomic::AtomicU8::new(role.to_u8())))
108    }
109
110    pub fn load(&self) -> NodeRole {
111        NodeRole::from_u8(self.0.load(std::sync::atomic::Ordering::Relaxed))
112    }
113
114    pub fn store(&self, role: NodeRole) {
115        self.0
116            .store(role.to_u8(), std::sync::atomic::Ordering::Relaxed);
117    }
118}
119
120/// Unified application state for all handlers
121#[derive(Clone)]
122pub struct AppState {
123    pub store: Arc<EventStore>,
124    pub auth_manager: Arc<AuthManager>,
125    pub tenant_repo: Arc<dyn TenantRepository>,
126    /// Service container for paywall domain use cases (Creator, Article, Payment, etc.)
127    pub service_container: ServiceContainer,
128    /// Node role for leader-follower replication (runtime-mutable for failover)
129    pub role: AtomicNodeRole,
130    /// WAL shipper for replication status reporting (leader only).
131    /// Wrapped in RwLock so a promoted follower can install a shipper at runtime.
132    pub wal_shipper: Arc<tokio::sync::RwLock<Option<Arc<WalShipper>>>>,
133    /// WAL receiver for replication status reporting (follower only)
134    pub wal_receiver: Option<Arc<WalReceiver>>,
135    /// Replication port used by the WAL shipper (needed for runtime promotion)
136    pub replication_port: u16,
137    /// Cluster manager for multi-node consensus and membership (optional)
138    pub cluster_manager: Option<Arc<ClusterManager>>,
139    /// Geo-replication manager for cross-region replication (optional)
140    pub geo_replication: Option<Arc<GeoReplicationManager>>,
141}
142
143// Enable extracting Arc<EventStore> from AppState
144// This allows handlers that expect State<Arc<EventStore>> to work with AppState
145impl axum::extract::FromRef<AppState> for Arc<EventStore> {
146    fn from_ref(state: &AppState) -> Self {
147        state.store.clone()
148    }
149}
150
151pub async fn serve_v1(
152    store: Arc<EventStore>,
153    auth_manager: Arc<AuthManager>,
154    tenant_repo: Arc<dyn TenantRepository>,
155    rate_limiter: Arc<RateLimiter>,
156    service_container: ServiceContainer,
157    addr: &str,
158    role: NodeRole,
159    wal_shipper: Option<Arc<WalShipper>>,
160    wal_receiver: Option<Arc<WalReceiver>>,
161    replication_port: u16,
162    cluster_manager: Option<Arc<ClusterManager>>,
163    geo_replication: Option<Arc<GeoReplicationManager>>,
164) -> anyhow::Result<()> {
165    let app_state = AppState {
166        store,
167        auth_manager: auth_manager.clone(),
168        tenant_repo,
169        service_container,
170        role: AtomicNodeRole::new(role),
171        wal_shipper: Arc::new(tokio::sync::RwLock::new(wal_shipper)),
172        wal_receiver,
173        replication_port,
174        cluster_manager,
175        geo_replication,
176    };
177
178    let auth_state = AuthState {
179        auth_manager: auth_manager.clone(),
180    };
181
182    let rate_limit_state = RateLimitState { rate_limiter };
183
184    let app = Router::new()
185        // Public routes (no auth)
186        .route("/health", get(health_v1))
187        .route("/metrics", get(super::api::prometheus_metrics))
188        // Auth routes
189        .route("/api/v1/auth/register", post(register_handler))
190        .route("/api/v1/auth/login", post(login_handler))
191        .route("/api/v1/auth/me", get(me_handler))
192        .route("/api/v1/auth/api-keys", post(create_api_key_handler))
193        .route("/api/v1/auth/api-keys", get(list_api_keys_handler))
194        .route("/api/v1/auth/api-keys/{id}", delete(revoke_api_key_handler))
195        .route("/api/v1/auth/users", get(list_users_handler))
196        .route("/api/v1/auth/users/{id}", delete(delete_user_handler))
197        // Tenant routes (protected — enterprise only)
198        ;
199    #[cfg(feature = "multi-tenant")]
200    let app = app
201        .route("/api/v1/tenants", post(create_tenant_handler))
202        .route("/api/v1/tenants", get(list_tenants_handler))
203        .route("/api/v1/tenants/{id}", get(get_tenant_handler))
204        .route("/api/v1/tenants/{id}", put(update_tenant_handler))
205        .route("/api/v1/tenants/{id}/stats", get(get_tenant_stats_handler))
206        .route("/api/v1/tenants/{id}/quotas", put(update_quotas_handler))
207        .route(
208            "/api/v1/tenants/{id}/schema-enforcement",
209            put(update_schema_enforcement_handler),
210        )
211        .route(
212            "/api/v1/tenants/{id}/deactivate",
213            post(deactivate_tenant_handler),
214        )
215        .route(
216            "/api/v1/tenants/{id}/activate",
217            post(activate_tenant_handler),
218        )
219        .route("/api/v1/tenants/{id}", delete(delete_tenant_handler));
220    let app = app
221        // Audit endpoints (admin only)
222        .route("/api/v1/audit/events", post(log_audit_event))
223        .route("/api/v1/audit/events", get(query_audit_events))
224        // Config endpoints (admin only)
225        .route("/api/v1/config", get(list_configs))
226        .route("/api/v1/config", post(set_config))
227        .route("/api/v1/config/{key}", get(get_config))
228        .route("/api/v1/config/{key}", put(update_config))
229        .route("/api/v1/config/{key}", delete(delete_config))
230        // Demo seeding
231        .route(
232            "/api/v1/demo/seed",
233            post(super::demo_api::demo_seed_handler),
234        )
235        // Event and data routes (protected by auth)
236        .route("/api/v1/events", post(super::api::ingest_event_v1))
237        .route(
238            "/api/v1/events/batch",
239            post(super::api::ingest_events_batch_v1),
240        )
241        .route("/api/v1/events/query", get(super::api::query_events))
242        .route(
243            "/api/v1/events/{event_id}",
244            get(super::api::get_event_by_id),
245        )
246        .route("/api/v1/events/stream", get(super::api::events_websocket))
247        .route("/api/v1/entities", get(super::api::list_entities))
248        .route(
249            "/api/v1/entities/duplicates",
250            get(super::api::detect_duplicates),
251        )
252        .route(
253            "/api/v1/entities/{entity_id}/state",
254            get(super::api::get_entity_state),
255        )
256        .route(
257            "/api/v1/entities/{entity_id}/snapshot",
258            get(super::api::get_entity_snapshot),
259        )
260        .route("/api/v1/stats", get(super::api::get_stats))
261        // v0.10: Stream and event type discovery endpoints
262        .route("/api/v1/streams", get(super::api::list_streams))
263        .route("/api/v1/event-types", get(super::api::list_event_types))
264        // Analytics
265        .route(
266            "/api/v1/analytics/frequency",
267            get(super::api::analytics_frequency),
268        )
269        .route(
270            "/api/v1/analytics/summary",
271            get(super::api::analytics_summary),
272        )
273        .route(
274            "/api/v1/analytics/correlation",
275            get(super::api::analytics_correlation),
276        )
277        // Snapshots
278        .route("/api/v1/snapshots", post(super::api::create_snapshot))
279        .route("/api/v1/snapshots", get(super::api::list_snapshots))
280        .route(
281            "/api/v1/snapshots/{entity_id}/latest",
282            get(super::api::get_latest_snapshot),
283        )
284        // Compaction
285        .route(
286            "/api/v1/compaction/trigger",
287            post(super::api::trigger_compaction),
288        )
289        .route(
290            "/api/v1/compaction/stats",
291            get(super::api::compaction_stats),
292        )
293        // Schemas
294        .route("/api/v1/schemas", post(super::api::register_schema))
295        .route("/api/v1/schemas", get(super::api::list_subjects))
296        .route("/api/v1/schemas/{subject}", get(super::api::get_schema))
297        .route(
298            "/api/v1/schemas/{subject}/versions",
299            get(super::api::list_schema_versions),
300        )
301        .route(
302            "/api/v1/schemas/validate",
303            post(super::api::validate_event_schema),
304        )
305        .route(
306            "/api/v1/schemas/{subject}/compatibility",
307            put(super::api::set_compatibility_mode),
308        )
309        // Replay
310        .route("/api/v1/replay", post(super::api::start_replay))
311        .route("/api/v1/replay", get(super::api::list_replays))
312        .route(
313            "/api/v1/replay/{replay_id}",
314            get(super::api::get_replay_progress),
315        )
316        .route(
317            "/api/v1/replay/{replay_id}/cancel",
318            post(super::api::cancel_replay),
319        )
320        .route(
321            "/api/v1/replay/{replay_id}",
322            delete(super::api::delete_replay),
323        )
324        // Pipelines
325        .route("/api/v1/pipelines", post(super::api::register_pipeline))
326        .route("/api/v1/pipelines", get(super::api::list_pipelines))
327        .route(
328            "/api/v1/pipelines/stats",
329            get(super::api::all_pipeline_stats),
330        )
331        .route(
332            "/api/v1/pipelines/{pipeline_id}",
333            get(super::api::get_pipeline),
334        )
335        .route(
336            "/api/v1/pipelines/{pipeline_id}",
337            delete(super::api::remove_pipeline),
338        )
339        .route(
340            "/api/v1/pipelines/{pipeline_id}/stats",
341            get(super::api::get_pipeline_stats),
342        )
343        .route(
344            "/api/v1/pipelines/{pipeline_id}/reset",
345            put(super::api::reset_pipeline),
346        )
347        // v0.7: Projection State API for Query Service integration
348        .route("/api/v1/projections", get(super::api::list_projections))
349        .route(
350            "/api/v1/projections/{name}",
351            get(super::api::get_projection),
352        )
353        .route(
354            "/api/v1/projections/{name}",
355            delete(super::api::delete_projection),
356        )
357        .route(
358            "/api/v1/projections/{name}/state",
359            get(super::api::get_projection_state_summary),
360        )
361        .route(
362            "/api/v1/projections/{name}/reset",
363            post(super::api::reset_projection),
364        )
365        .route(
366            "/api/v1/projections/{name}/pause",
367            post(super::api::pause_projection),
368        )
369        .route(
370            "/api/v1/projections/{name}/start",
371            post(super::api::start_projection),
372        )
373        .route(
374            "/api/v1/projections/{name}/{entity_id}/state",
375            get(super::api::get_projection_state),
376        )
377        .route(
378            "/api/v1/projections/{name}/{entity_id}/state",
379            post(super::api::save_projection_state),
380        )
381        .route(
382            "/api/v1/projections/{name}/{entity_id}/state",
383            put(super::api::save_projection_state),
384        )
385        .route(
386            "/api/v1/projections/{name}/bulk",
387            post(super::api::bulk_get_projection_states),
388        )
389        .route(
390            "/api/v1/projections/{name}/bulk/save",
391            post(super::api::bulk_save_projection_states),
392        )
393        // v0.11: Webhook management
394        .route("/api/v1/webhooks", post(super::api::register_webhook))
395        .route("/api/v1/webhooks", get(super::api::list_webhooks))
396        .route(
397            "/api/v1/webhooks/{webhook_id}",
398            get(super::api::get_webhook),
399        )
400        .route(
401            "/api/v1/webhooks/{webhook_id}",
402            put(super::api::update_webhook),
403        )
404        .route(
405            "/api/v1/webhooks/{webhook_id}",
406            delete(super::api::delete_webhook),
407        )
408        .route(
409            "/api/v1/webhooks/{webhook_id}/deliveries",
410            get(super::api::list_webhook_deliveries),
411        )
412        // v0.14: Durable consumer subscriptions
413        .route("/api/v1/consumers", post(super::api::register_consumer))
414        .route(
415            "/api/v1/consumers/{consumer_id}",
416            get(super::api::get_consumer),
417        )
418        .route(
419            "/api/v1/consumers/{consumer_id}/events",
420            get(super::api::poll_consumer_events),
421        )
422        .route(
423            "/api/v1/consumers/{consumer_id}/ack",
424            post(super::api::ack_consumer),
425        )
426        // v1.8: Cluster membership management API
427        .route("/api/v1/cluster/status", get(cluster_status_handler))
428        .route("/api/v1/cluster/members", get(cluster_list_members_handler))
429        .route("/api/v1/cluster/members", post(cluster_add_member_handler))
430        .route(
431            "/api/v1/cluster/members/{node_id}",
432            delete(cluster_remove_member_handler),
433        )
434        .route(
435            "/api/v1/cluster/members/{node_id}/heartbeat",
436            post(cluster_heartbeat_handler),
437        )
438        .route("/api/v1/cluster/vote", post(cluster_vote_handler))
439        .route("/api/v1/cluster/election", post(cluster_election_handler))
440        .route(
441            "/api/v1/cluster/partitions",
442            get(cluster_partitions_handler),
443        )
444        // v2.0: Advanced query features
445        .route("/api/v1/graphql", post(super::api::graphql_query))
446        .route("/api/v1/geospatial/query", post(super::api::geo_query))
447        .route("/api/v1/geospatial/stats", get(super::api::geo_stats))
448        .route(
449            "/api/v1/exactly-once/stats",
450            get(super::api::exactly_once_stats),
451        )
452        .route(
453            "/api/v1/schema-evolution/history/{event_type}",
454            get(super::api::schema_evolution_history),
455        )
456        .route(
457            "/api/v1/schema-evolution/schema/{event_type}",
458            get(super::api::schema_evolution_schema),
459        )
460        .route(
461            "/api/v1/schema-evolution/stats",
462            get(super::api::schema_evolution_stats),
463        )
464        // v1.9: Geo-replication API
465        .route("/api/v1/geo/status", get(geo_status_handler))
466        .route("/api/v1/geo/sync", post(geo_sync_handler))
467        .route("/api/v1/geo/peers", get(geo_peers_handler))
468        .route("/api/v1/geo/failover", post(geo_failover_handler));
469    // Internal endpoints for sentinel-driven failover (enterprise only)
470    #[cfg(feature = "replication")]
471    let app = app
472        .route("/internal/promote", post(promote_handler))
473        .route("/internal/repoint", post(repoint_handler));
474    let app = app;
475
476    // v0.11: Bidirectional sync protocol (embedded↔server)
477    #[cfg(feature = "embedded-sync")]
478    let app = app
479        .route("/api/v1/sync/pull", post(super::api::sync_pull_handler))
480        .route("/api/v1/sync/push", post(super::api::sync_push_handler));
481
482    let app = app
483        .with_state(app_state.clone())
484        // IMPORTANT: Middleware layers execute from bottom to top in Tower/Axum
485        // Read-only middleware runs after auth (applied before rate limit layer)
486        .layer(middleware::from_fn_with_state(
487            app_state,
488            read_only_middleware,
489        ))
490        .layer(middleware::from_fn_with_state(
491            rate_limit_state,
492            rate_limit_middleware,
493        ))
494        .layer(middleware::from_fn_with_state(auth_state, auth_middleware))
495        .layer(
496            CorsLayer::new()
497                .allow_origin(Any)
498                .allow_methods(Any)
499                .allow_headers(Any),
500        )
501        .layer(TraceLayer::new_for_http());
502
503    // Prime API — nested router with its own state (feature-gated)
504    #[cfg(feature = "prime")]
505    let app = {
506        let data_dir =
507            std::env::var("PRIME_DATA_DIR").unwrap_or_else(|_| "/tmp/prime-data".to_string());
508        match crate::prime::Prime::open(&data_dir).await {
509            Ok(prime) => {
510                let prime_state = Arc::new(super::prime_api::PrimeState { prime });
511                tracing::info!("Prime API enabled at /api/v1/prime/*");
512                app.nest(
513                    "/api/v1/prime",
514                    super::prime_api::prime_router().with_state(prime_state),
515                )
516            }
517            Err(e) => {
518                tracing::warn!("Prime API disabled: failed to open Prime: {e}");
519                app
520            }
521        }
522    };
523
524    let listener = tokio::net::TcpListener::bind(addr).await?;
525
526    // Graceful shutdown on SIGTERM (required for serverless platforms)
527    axum::serve(listener, app)
528        .with_graceful_shutdown(shutdown_signal())
529        .await?;
530
531    tracing::info!("🛑 AllSource Core shutdown complete");
532    Ok(())
533}
534
535/// Write paths that should be rejected when running as a follower.
536const WRITE_PATHS: &[&str] = &[
537    "/api/v1/events",
538    "/api/v1/events/batch",
539    "/api/v1/snapshots",
540    "/api/v1/projections/",
541    "/api/v1/schemas",
542    "/api/v1/replay",
543    "/api/v1/pipelines",
544    "/api/v1/compaction/trigger",
545    "/api/v1/audit/events",
546    "/api/v1/config",
547    "/api/v1/webhooks",
548    "/api/v1/demo/seed",
549];
550
551/// Returns true if this request is a write operation that should be blocked on followers.
552fn is_write_request(method: &axum::http::Method, path: &str) -> bool {
553    use axum::http::Method;
554    // Only POST/PUT/DELETE are writes
555    if method != Method::POST && method != Method::PUT && method != Method::DELETE {
556        return false;
557    }
558    WRITE_PATHS
559        .iter()
560        .any(|write_path| path.starts_with(write_path))
561}
562
563/// Returns true if the request targets an internal or cluster endpoint (not subject to read-only checks).
564fn is_internal_request(path: &str) -> bool {
565    path.starts_with("/internal/")
566        || path.starts_with("/api/v1/cluster/")
567        || path.starts_with("/api/v1/geo/")
568}
569
570/// Middleware that rejects write requests when the node is a follower.
571///
572/// Returns HTTP 409 Conflict with `{"error": "read_only", "message": "..."}`.
573/// Internal endpoints (`/internal/*`) are exempt — they are used by the sentinel
574/// to trigger promotion and repointing during failover.
575async fn read_only_middleware(
576    State(state): State<AppState>,
577    request: axum::extract::Request,
578    next: axum::middleware::Next,
579) -> axum::response::Response {
580    let path = request.uri().path();
581    if state.role.load().is_follower()
582        && is_write_request(request.method(), path)
583        && !is_internal_request(path)
584    {
585        return (
586            axum::http::StatusCode::CONFLICT,
587            axum::Json(serde_json::json!({
588                "error": "read_only",
589                "message": "This node is a read-only follower"
590            })),
591        )
592            .into_response();
593    }
594    next.run(request).await
595}
596
597/// Health endpoint with system stream health reporting.
598///
599/// Reports overall health plus detailed system metadata health when
600/// event-sourced system repositories are configured.
601async fn health_v1(State(state): State<AppState>) -> impl IntoResponse {
602    let has_system_repos = state.service_container.has_system_repositories();
603
604    let system_streams = if has_system_repos {
605        let (tenant_count, config_count, total_events) =
606            if let Some(store) = state.service_container.system_store() {
607                use crate::domain::value_objects::system_stream::SystemDomain;
608                (
609                    store.count_stream(SystemDomain::Tenant),
610                    store.count_stream(SystemDomain::Config),
611                    store.total_events(),
612                )
613            } else {
614                (0, 0, 0)
615            };
616
617        serde_json::json!({
618            "status": "healthy",
619            "mode": "event-sourced",
620            "total_events": total_events,
621            "tenant_events": tenant_count,
622            "config_events": config_count,
623        })
624    } else {
625        serde_json::json!({
626            "status": "disabled",
627            "mode": "in-memory",
628        })
629    };
630
631    let replication = {
632        #[cfg(feature = "replication")]
633        {
634            let shipper_guard = state.wal_shipper.read().await;
635            if let Some(ref shipper) = *shipper_guard {
636                serde_json::to_value(shipper.status()).unwrap_or_default()
637            } else if let Some(ref receiver) = state.wal_receiver {
638                serde_json::to_value(receiver.status()).unwrap_or_default()
639            } else {
640                serde_json::json!(null)
641            }
642        }
643        #[cfg(not(feature = "replication"))]
644        serde_json::json!({"edition": "community", "status": "not_available"})
645    };
646
647    let current_role = state.role.load();
648
649    Json(serde_json::json!({
650        "status": "healthy",
651        "service": "allsource-core",
652        "version": env!("CARGO_PKG_VERSION"),
653        "role": current_role,
654        "system_streams": system_streams,
655        "replication": replication,
656    }))
657}
658
659/// POST /internal/promote — Promote this follower to leader.
660///
661/// Called by the sentinel process during automated failover.
662/// Switches the node role to leader, stops WAL receiving, and starts
663/// a WAL shipper on the replication port so other followers can connect.
664#[cfg(feature = "replication")]
665async fn promote_handler(State(state): State<AppState>) -> impl IntoResponse {
666    let current_role = state.role.load();
667    if current_role == NodeRole::Leader {
668        return (
669            axum::http::StatusCode::OK,
670            Json(serde_json::json!({
671                "status": "already_leader",
672                "message": "This node is already the leader",
673            })),
674        );
675    }
676
677    tracing::info!("PROMOTE: Switching role from follower to leader");
678
679    // 1. Switch role — the read-only middleware will immediately start accepting writes
680    state.role.store(NodeRole::Leader);
681
682    // 2. Signal the WAL receiver to stop (it will stop reconnecting)
683    if let Some(ref receiver) = state.wal_receiver {
684        receiver.shutdown();
685        tracing::info!("PROMOTE: WAL receiver shutdown signalled");
686    }
687
688    // 3. Start a new WAL shipper so remaining followers can connect
689    let replication_port = state.replication_port;
690    let (mut shipper, tx) = WalShipper::new();
691    state.store.enable_wal_replication(tx);
692    shipper.set_store(Arc::clone(&state.store));
693    shipper.set_metrics(state.store.metrics());
694    let shipper = Arc::new(shipper);
695
696    // Install into AppState so health endpoint reports shipper status
697    {
698        let mut shipper_guard = state.wal_shipper.write().await;
699        *shipper_guard = Some(Arc::clone(&shipper));
700    }
701
702    // Spawn the shipper server
703    let shipper_clone = Arc::clone(&shipper);
704    tokio::spawn(async move {
705        if let Err(e) = shipper_clone.serve(replication_port).await {
706            tracing::error!("Promoted WAL shipper error: {}", e);
707        }
708    });
709
710    tracing::info!(
711        "PROMOTE: Now accepting writes. WAL shipper listening on port {}",
712        replication_port,
713    );
714
715    (
716        axum::http::StatusCode::OK,
717        Json(serde_json::json!({
718            "status": "promoted",
719            "role": "leader",
720            "replication_port": replication_port,
721        })),
722    )
723}
724
725/// POST /internal/repoint?leader=host:port — Switch replication target.
726///
727/// Called by the sentinel process to tell a follower to disconnect from
728/// the old leader and reconnect to a newly promoted leader.
729#[cfg(feature = "replication")]
730async fn repoint_handler(
731    State(state): State<AppState>,
732    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
733) -> impl IntoResponse {
734    let current_role = state.role.load();
735    if current_role != NodeRole::Follower {
736        return (
737            axum::http::StatusCode::CONFLICT,
738            Json(serde_json::json!({
739                "error": "not_follower",
740                "message": "Repoint only applies to follower nodes",
741            })),
742        );
743    }
744
745    let new_leader = match params.get("leader") {
746        Some(l) if !l.is_empty() => l.clone(),
747        _ => {
748            return (
749                axum::http::StatusCode::BAD_REQUEST,
750                Json(serde_json::json!({
751                    "error": "missing_leader",
752                    "message": "Query parameter 'leader' is required (e.g. ?leader=new-leader:3910)",
753                })),
754            );
755        }
756    };
757
758    tracing::info!("REPOINT: Switching replication target to {}", new_leader);
759
760    if let Some(ref receiver) = state.wal_receiver {
761        receiver.repoint(&new_leader);
762        tracing::info!("REPOINT: WAL receiver repointed to {}", new_leader);
763    } else {
764        tracing::warn!("REPOINT: No WAL receiver to repoint");
765    }
766
767    (
768        axum::http::StatusCode::OK,
769        Json(serde_json::json!({
770            "status": "repointed",
771            "new_leader": new_leader,
772        })),
773    )
774}
775
776// =============================================================================
777// Cluster Management Handlers (v1.8)
778// =============================================================================
779
780/// GET /api/v1/cluster/status — Get cluster status including term, leader, and members
781async fn cluster_status_handler(State(state): State<AppState>) -> impl IntoResponse {
782    let Some(ref cm) = state.cluster_manager else {
783        return (
784            axum::http::StatusCode::SERVICE_UNAVAILABLE,
785            Json(serde_json::json!({
786                "error": "cluster_not_enabled",
787                "message": "Cluster mode is not enabled on this node"
788            })),
789        );
790    };
791
792    let status = cm.status().await;
793    (
794        axum::http::StatusCode::OK,
795        Json(serde_json::to_value(status).unwrap()),
796    )
797}
798
799/// GET /api/v1/cluster/members — List all cluster members
800async fn cluster_list_members_handler(State(state): State<AppState>) -> impl IntoResponse {
801    let Some(ref cm) = state.cluster_manager else {
802        return (
803            axum::http::StatusCode::SERVICE_UNAVAILABLE,
804            Json(serde_json::json!({
805                "error": "cluster_not_enabled",
806                "message": "Cluster mode is not enabled on this node"
807            })),
808        );
809    };
810
811    let members = cm.all_members();
812    (
813        axum::http::StatusCode::OK,
814        Json(serde_json::json!({
815            "members": members,
816            "count": members.len(),
817        })),
818    )
819}
820
821/// POST /api/v1/cluster/members — Add a member to the cluster
822async fn cluster_add_member_handler(
823    State(state): State<AppState>,
824    Json(member): Json<ClusterMember>,
825) -> impl IntoResponse {
826    let Some(ref cm) = state.cluster_manager else {
827        return (
828            axum::http::StatusCode::SERVICE_UNAVAILABLE,
829            Json(serde_json::json!({
830                "error": "cluster_not_enabled",
831                "message": "Cluster mode is not enabled on this node"
832            })),
833        );
834    };
835
836    let node_id = member.node_id;
837    cm.add_member(member).await;
838
839    tracing::info!("Cluster member {} added", node_id);
840    (
841        axum::http::StatusCode::OK,
842        Json(serde_json::json!({
843            "status": "added",
844            "node_id": node_id,
845        })),
846    )
847}
848
849/// DELETE /api/v1/cluster/members/{node_id} — Remove a member from the cluster
850async fn cluster_remove_member_handler(
851    State(state): State<AppState>,
852    Path(node_id): Path<u32>,
853) -> impl IntoResponse {
854    let Some(ref cm) = state.cluster_manager else {
855        return (
856            axum::http::StatusCode::SERVICE_UNAVAILABLE,
857            Json(serde_json::json!({
858                "error": "cluster_not_enabled",
859                "message": "Cluster mode is not enabled on this node"
860            })),
861        );
862    };
863
864    match cm.remove_member(node_id).await {
865        Some(_) => {
866            tracing::info!("Cluster member {} removed", node_id);
867            (
868                axum::http::StatusCode::OK,
869                Json(serde_json::json!({
870                    "status": "removed",
871                    "node_id": node_id,
872                })),
873            )
874        }
875        None => (
876            axum::http::StatusCode::NOT_FOUND,
877            Json(serde_json::json!({
878                "error": "not_found",
879                "message": format!("Node {} not found in cluster", node_id),
880            })),
881        ),
882    }
883}
884
885/// POST /api/v1/cluster/members/{node_id}/heartbeat — Update member heartbeat
886#[derive(serde::Deserialize)]
887struct HeartbeatRequest {
888    wal_offset: u64,
889    #[serde(default = "default_true")]
890    healthy: bool,
891}
892
893fn default_true() -> bool {
894    true
895}
896
897async fn cluster_heartbeat_handler(
898    State(state): State<AppState>,
899    Path(node_id): Path<u32>,
900    Json(req): Json<HeartbeatRequest>,
901) -> impl IntoResponse {
902    let Some(ref cm) = state.cluster_manager else {
903        return (
904            axum::http::StatusCode::SERVICE_UNAVAILABLE,
905            Json(serde_json::json!({
906                "error": "cluster_not_enabled",
907                "message": "Cluster mode is not enabled on this node"
908            })),
909        );
910    };
911
912    cm.update_member_heartbeat(node_id, req.wal_offset, req.healthy);
913    (
914        axum::http::StatusCode::OK,
915        Json(serde_json::json!({
916            "status": "updated",
917            "node_id": node_id,
918        })),
919    )
920}
921
922/// POST /api/v1/cluster/vote — Handle a vote request (simplified Raft RequestVote)
923async fn cluster_vote_handler(
924    State(state): State<AppState>,
925    Json(request): Json<VoteRequest>,
926) -> impl IntoResponse {
927    let Some(ref cm) = state.cluster_manager else {
928        return (
929            axum::http::StatusCode::SERVICE_UNAVAILABLE,
930            Json(serde_json::json!({
931                "error": "cluster_not_enabled",
932                "message": "Cluster mode is not enabled on this node"
933            })),
934        );
935    };
936
937    let response = cm.handle_vote_request(&request).await;
938    (
939        axum::http::StatusCode::OK,
940        Json(serde_json::to_value(response).unwrap()),
941    )
942}
943
944/// POST /api/v1/cluster/election — Trigger a leader election (manual failover)
945async fn cluster_election_handler(State(state): State<AppState>) -> impl IntoResponse {
946    let Some(ref cm) = state.cluster_manager else {
947        return (
948            axum::http::StatusCode::SERVICE_UNAVAILABLE,
949            Json(serde_json::json!({
950                "error": "cluster_not_enabled",
951                "message": "Cluster mode is not enabled on this node"
952            })),
953        );
954    };
955
956    // Select best candidate deterministically
957    let candidate = cm.select_leader_candidate();
958
959    match candidate {
960        Some(candidate_id) => {
961            let new_term = cm.start_election().await;
962            tracing::info!(
963                "Cluster election started: term={}, candidate={}",
964                new_term,
965                candidate_id,
966            );
967
968            // If this node is the candidate, become leader immediately
969            // (In a full Raft, we'd collect votes from a majority first)
970            if candidate_id == cm.self_id() {
971                cm.become_leader(new_term).await;
972                tracing::info!("Node {} became leader at term {}", candidate_id, new_term);
973            }
974
975            (
976                axum::http::StatusCode::OK,
977                Json(serde_json::json!({
978                    "status": "election_started",
979                    "term": new_term,
980                    "candidate_id": candidate_id,
981                    "self_is_leader": candidate_id == cm.self_id(),
982                })),
983            )
984        }
985        None => (
986            axum::http::StatusCode::CONFLICT,
987            Json(serde_json::json!({
988                "error": "no_candidates",
989                "message": "No healthy members available for leader election",
990            })),
991        ),
992    }
993}
994
995/// GET /api/v1/cluster/partitions — Get partition distribution across nodes
996async fn cluster_partitions_handler(State(state): State<AppState>) -> impl IntoResponse {
997    let Some(ref cm) = state.cluster_manager else {
998        return (
999            axum::http::StatusCode::SERVICE_UNAVAILABLE,
1000            Json(serde_json::json!({
1001                "error": "cluster_not_enabled",
1002                "message": "Cluster mode is not enabled on this node"
1003            })),
1004        );
1005    };
1006
1007    let registry = cm.registry();
1008    let distribution = registry.partition_distribution();
1009    let total_partitions: usize = distribution.values().map(std::vec::Vec::len).sum();
1010
1011    (
1012        axum::http::StatusCode::OK,
1013        Json(serde_json::json!({
1014            "total_partitions": total_partitions,
1015            "node_count": registry.node_count(),
1016            "healthy_node_count": registry.healthy_node_count(),
1017            "distribution": distribution,
1018        })),
1019    )
1020}
1021
1022// =============================================================================
1023// Geo-Replication Handlers (v1.9)
1024// =============================================================================
1025
1026/// GET /api/v1/geo/status — Get geo-replication status
1027async fn geo_status_handler(State(state): State<AppState>) -> impl IntoResponse {
1028    let Some(ref geo) = state.geo_replication else {
1029        return (
1030            axum::http::StatusCode::SERVICE_UNAVAILABLE,
1031            Json(serde_json::json!({
1032                "error": "geo_replication_not_enabled",
1033                "message": "Geo-replication is not enabled on this node"
1034            })),
1035        );
1036    };
1037
1038    let status = geo.status();
1039    (
1040        axum::http::StatusCode::OK,
1041        Json(serde_json::to_value(status).unwrap()),
1042    )
1043}
1044
1045/// POST /api/v1/geo/sync — Receive replicated events from a peer region
1046async fn geo_sync_handler(
1047    State(state): State<AppState>,
1048    Json(request): Json<GeoSyncRequest>,
1049) -> impl IntoResponse {
1050    let Some(ref geo) = state.geo_replication else {
1051        return (
1052            axum::http::StatusCode::SERVICE_UNAVAILABLE,
1053            Json(serde_json::json!({
1054                "error": "geo_replication_not_enabled",
1055                "message": "Geo-replication is not enabled on this node"
1056            })),
1057        );
1058    };
1059
1060    tracing::info!(
1061        "Geo-sync received from region '{}': {} events",
1062        request.source_region,
1063        request.events.len(),
1064    );
1065
1066    let response = geo.receive_sync(&request);
1067    (
1068        axum::http::StatusCode::OK,
1069        Json(serde_json::to_value(response).unwrap()),
1070    )
1071}
1072
1073/// GET /api/v1/geo/peers — List peer regions and their health
1074async fn geo_peers_handler(State(state): State<AppState>) -> impl IntoResponse {
1075    let Some(ref geo) = state.geo_replication else {
1076        return (
1077            axum::http::StatusCode::SERVICE_UNAVAILABLE,
1078            Json(serde_json::json!({
1079                "error": "geo_replication_not_enabled",
1080                "message": "Geo-replication is not enabled on this node"
1081            })),
1082        );
1083    };
1084
1085    let status = geo.status();
1086    (
1087        axum::http::StatusCode::OK,
1088        Json(serde_json::json!({
1089            "region_id": status.region_id,
1090            "peers": status.peers,
1091        })),
1092    )
1093}
1094
1095/// POST /api/v1/geo/failover — Trigger regional failover
1096async fn geo_failover_handler(State(state): State<AppState>) -> impl IntoResponse {
1097    let Some(ref geo) = state.geo_replication else {
1098        return (
1099            axum::http::StatusCode::SERVICE_UNAVAILABLE,
1100            Json(serde_json::json!({
1101                "error": "geo_replication_not_enabled",
1102                "message": "Geo-replication is not enabled on this node"
1103            })),
1104        );
1105    };
1106
1107    match geo.select_failover_region() {
1108        Some(failover_region) => {
1109            tracing::info!(
1110                "Geo-failover: selected region '{}' as failover target",
1111                failover_region,
1112            );
1113            (
1114                axum::http::StatusCode::OK,
1115                Json(serde_json::json!({
1116                    "status": "failover_target_selected",
1117                    "failover_region": failover_region,
1118                    "message": "Region selected for failover. DNS/routing update required externally.",
1119                })),
1120            )
1121        }
1122        None => (
1123            axum::http::StatusCode::CONFLICT,
1124            Json(serde_json::json!({
1125                "error": "no_healthy_peers",
1126                "message": "No healthy peer regions available for failover",
1127            })),
1128        ),
1129    }
1130}
1131
1132/// Listen for shutdown signals (SIGTERM for serverless, SIGINT for local dev)
1133async fn shutdown_signal() {
1134    let ctrl_c = async {
1135        tokio::signal::ctrl_c()
1136            .await
1137            .expect("failed to install Ctrl+C handler");
1138    };
1139
1140    #[cfg(unix)]
1141    let terminate = async {
1142        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1143            .expect("failed to install SIGTERM handler")
1144            .recv()
1145            .await;
1146    };
1147
1148    #[cfg(not(unix))]
1149    let terminate = std::future::pending::<()>();
1150
1151    tokio::select! {
1152        () = ctrl_c => {
1153            tracing::info!("📤 Received Ctrl+C, initiating graceful shutdown...");
1154        }
1155        () = terminate => {
1156            tracing::info!("📤 Received SIGTERM, initiating graceful shutdown...");
1157        }
1158    }
1159}