Skip to main content

heldar_kernel/routes/
outbox.rs

1//! Fleet outbox foundation (open-core seam, Stage 0): the appliance-side read API over the durable,
2//! ordered transactional outbox (`outbox` table) plus a tiny unauthenticated site-identity endpoint.
3//!
4//! `GET /api/v1/outbox?since_seq=&limit=` is the cursor a future edge->cloud uplink (or an
5//! out-of-process app) polls to drain committed detection batches in `seq` order WITHOUT running a
6//! message broker on the box — the DB is the log. It is admin-only and audited. `GET /api/v1/site`
7//! reports this node's identity (`HELDAR_SITE_ID`, build version, boot time) so a fleet controller can
8//! correlate outbox cursors with the site they came from; it carries no secrets and needs no auth.
9
10use axum::extract::{Query, State};
11use axum::routing::get;
12use axum::{Json, Router};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16
17use crate::auth::{self, Principal};
18use crate::error::AppResult;
19use crate::state::AppState;
20
21pub fn router() -> Router<AppState> {
22    Router::new()
23        .route("/api/v1/outbox", get(list_outbox))
24        .route("/api/v1/site", get(site_info))
25}
26
27/// One durable outbox row (a committed detection batch). Mirrors the `outbox` table (migration 0006).
28#[derive(Debug, Serialize, sqlx::FromRow)]
29struct OutboxEntry {
30    seq: i64,
31    topic: String,
32    camera_id: Option<String>,
33    site_id: Option<String>,
34    frame_id: Option<String>,
35    task_type: Option<String>,
36    detection_count: i64,
37    created_at: DateTime<Utc>,
38}
39
40/// A page of outbox rows plus the cursor to continue from (pass `next_seq` as the next `since_seq`).
41#[derive(Debug, Serialize)]
42struct OutboxPage {
43    entries: Vec<OutboxEntry>,
44    /// Highest `seq` in this page; null when the page is empty (caller is caught up).
45    next_seq: Option<i64>,
46    count: usize,
47}
48
49#[derive(Debug, Deserialize)]
50struct OutboxQuery {
51    /// Return rows with `seq` strictly greater than this cursor (default 0 = from the start).
52    since_seq: Option<i64>,
53    /// Page size (default 100, clamped 1..1000).
54    limit: Option<i64>,
55}
56
57/// Drain the outbox in `seq` order from a cursor (admin-only, audited).
58async fn list_outbox(
59    State(st): State<AppState>,
60    principal: Principal,
61    Query(q): Query<OutboxQuery>,
62) -> AppResult<Json<OutboxPage>> {
63    principal.require(principal.can_admin(), "read the fleet outbox")?;
64    let since = q.since_seq.unwrap_or(0).max(0);
65    let limit = q.limit.unwrap_or(100).clamp(1, 1000);
66    let entries = sqlx::query_as::<_, OutboxEntry>(
67        "SELECT seq, topic, camera_id, site_id, frame_id, task_type, detection_count, created_at
68           FROM outbox
69          WHERE seq > ?
70          ORDER BY seq ASC
71          LIMIT ?",
72    )
73    .bind(since)
74    .bind(limit)
75    .fetch_all(&st.pool)
76    .await?;
77
78    let next_seq = entries.last().map(|e| e.seq);
79    let count = entries.len();
80    auth::audit(
81        &st.pool,
82        &principal,
83        "read_outbox",
84        "outbox",
85        &format!("since:{since}"),
86        json!({ "since_seq": since, "limit": limit, "returned": count }),
87    )
88    .await;
89    Ok(Json(OutboxPage {
90        entries,
91        next_seq,
92        count,
93    }))
94}
95
96/// This node's fleet identity. No auth: it exposes only public build/site metadata (no secrets).
97#[derive(Debug, Serialize)]
98struct SiteInfo {
99    site_id: Option<String>,
100    name: &'static str,
101    version: &'static str,
102    started_at: DateTime<Utc>,
103}
104
105async fn site_info(State(st): State<AppState>) -> Json<SiteInfo> {
106    Json(SiteInfo {
107        site_id: st.cfg.site_id.clone(),
108        name: "Heldar Core",
109        version: env!("CARGO_PKG_VERSION"),
110        started_at: st.started_at,
111    })
112}