Skip to main content

ijima_server/
server.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Daemon wiring: construct the auth core + store, build the router,
5//! and bind/listen.
6
7use std::sync::Arc;
8
9use ijima_core::{IjimaError, Result, Store};
10
11use crate::IjimaAuth;
12use crate::api;
13use crate::key_store;
14
15/// Daemon configuration, resolved from env vars or CLI args.
16#[derive(Debug, Clone)]
17pub struct DaemonConfig {
18    /// Bind host. Default `127.0.0.1` (`IJIMA_HOST`).
19    pub host: String,
20    /// Bind port. Default `7373` (`IJIMA_PORT`).
21    pub port: u16,
22}
23
24/// Initializes structured logging (`tracing`) if not already installed.
25/// Idempotent — safe to call from both the daemon and CLI paths.
26pub fn init_tracing() {
27    use tracing_subscriber::EnvFilter;
28    let _ = tracing_subscriber::fmt()
29        .with_env_filter(
30            EnvFilter::try_from_env("IJIMA_LOG").unwrap_or_else(|_| EnvFilter::new("ijima=info")),
31        )
32        .try_init();
33}
34
35impl Default for DaemonConfig {
36    fn default() -> Self {
37        // Env > config file > built-in defaults (see `config`).
38        let file = crate::config::load().unwrap_or_default();
39        Self {
40            host: crate::config::resolve_str("IJIMA_HOST", file.host, "127.0.0.1"),
41            port: std::env::var("IJIMA_PORT")
42                .ok()
43                .and_then(|p| p.parse().ok())
44                .or(file.port)
45                .unwrap_or(7373),
46        }
47    }
48}
49
50/// Runs the Ijima HTTP daemon: loads the issuer key (creating it on first
51/// run), opens the embedded SurrealDB store, builds the router, and
52/// serves until interrupted.
53///
54/// # Errors
55///
56/// Returns [`IjimaError`] if the key, store, or socket cannot be opened.
57/// Build the federation instance config from environment variables (the
58/// `IJIMA_INSTANCE_*` family), falling back to the single-instance default.
59/// Core stays env-free; this is the server-boundary binding (ADR
60/// `federation-control-api`).
61///
62/// - `IJIMA_INSTANCE_ID` — stable instance id (default `local`).
63/// - `IJIMA_INSTANCE_ROLE` — `unifying` | `archive` | `domain-authority` |
64///   `edge` | `airgapped` (default `unifying`).
65/// - `IJIMA_INSTANCE_SCOPES` — comma-separated `namespace:project` pairs,
66///   e.g. `local:*,shared:Dominic` (default `local:*`).
67/// - `IJIMA_CAPABILITY_POLICY_REF` — capability-policy hash/ref (default none).
68///
69/// Outbound links are not yet configurable (no peer-topology config format).
70#[cfg(feature = "federation")]
71fn federation_config_from_env() -> ijima_core::federation::InstanceFederationConfig {
72    use ijima_core::federation::{
73        AuthoritativeScope, InstanceFederationConfig, InstanceId, InstanceRole,
74    };
75    use std::str::FromStr;
76    let instance_id = std::env::var("IJIMA_INSTANCE_ID")
77        .map(InstanceId::new)
78        .unwrap_or_default();
79    let role = std::env::var("IJIMA_INSTANCE_ROLE")
80        .ok()
81        .and_then(|r| InstanceRole::from_str(&r).ok())
82        .unwrap_or(InstanceRole::Unifying);
83    let authoritative_scopes = std::env::var("IJIMA_INSTANCE_SCOPES")
84        .ok()
85        .map(|s| {
86            s.split(',')
87                .filter_map(|p| AuthoritativeScope::from_str(p.trim()).ok())
88                .collect::<Vec<_>>()
89        })
90        .filter(|v| !v.is_empty())
91        .unwrap_or_else(|| vec![AuthoritativeScope::new("local", "*")]);
92    let capability_policy_ref = std::env::var("IJIMA_CAPABILITY_POLICY_REF")
93        .ok()
94        .filter(|s| !s.is_empty());
95    InstanceFederationConfig {
96        instance_id,
97        role,
98        authoritative_scopes,
99        outbound_links: Vec::new(),
100        capability_policy_ref,
101    }
102}
103
104pub async fn serve(config: &DaemonConfig) -> Result<()> {
105    init_tracing();
106    // Config file layer — loaded once; a malformed file fails the daemon
107    // before anything opens (explicit `$IJIMA_CONFIG` must be honored).
108    let file_config = crate::config::load()?;
109    let key_path = key_store::default_key_path()?;
110    let seed = key_store::load_or_create(&key_path)?;
111    let auth = Arc::new(IjimaAuth::from_embedded_policy_with_seed(seed)?);
112
113    #[cfg(feature = "embeddings-candle")]
114    let embedder: Option<Arc<dyn ijima_core::Embedder>> = {
115        // Model resolution: env IJIMA_EMBED_MODEL > config file > default.
116        let model = crate::config::resolve_str(
117            "IJIMA_EMBED_MODEL",
118            file_config.embedding_model.clone(),
119            crate::embeddings_candle::DEFAULT_MODEL,
120        );
121        let revision = std::env::var("IJIMA_EMBED_REVISION").unwrap_or_else(|_| "main".into());
122        let e: Arc<dyn ijima_core::Embedder> = Arc::new(
123            crate::embeddings_candle::CandleEmbedder::from_hub_model(&model, &revision)?,
124        );
125        tracing::info!(model = %e.model_id(), "embedder loaded");
126        Some(e)
127    };
128    #[cfg(not(feature = "embeddings-candle"))]
129    let embedder: Option<Arc<dyn ijima_core::Embedder>> = None;
130
131    // Persistent on disk by default (SurrealKv). Data dir resolution:
132    // env $IJIMA_DIR > config file `data_dir` > ~/.ijima (see `config`).
133    let data_dir = crate::config::resolve_data_dir()?;
134    let db_path = data_dir.join("ijima.db");
135
136    #[cfg(feature = "embeddings-candle")]
137    let store_inner = Arc::new(
138        crate::SurrealStore::open_persistent_with(&db_path, embedder.clone().unwrap()).await?,
139    );
140    #[cfg(not(feature = "embeddings-candle"))]
141    let store_inner = Arc::new(crate::SurrealStore::open_persistent(&db_path).await?);
142    let store: Arc<dyn Store> = store_inner.clone();
143    let kg: Arc<dyn ijima_core::KnowledgeGraph> = store_inner;
144
145    // Hydrate the grant-revocation set from the store (WS1b): any bearer
146    // revoked on a previous boot stays dead across restarts.
147    let revocations = store.list_revocations().await?;
148    if !revocations.is_empty() {
149        tracing::info!(count = revocations.len(), "hydrated token revocations");
150    }
151    auth.hydrate_revocations(&revocations);
152
153    // Schubert geometric rate limiting (Phase 3.4). Configurable via env
154    // or config file; disabled when IJIMA_RATE_DISABLE is set (tests, CI).
155    // Capacity scales with the capability's Schubert intersection number.
156    #[cfg(feature = "rate-limit")]
157    let rate_limiter: Option<crate::rate_limit::RateLimitState> =
158        if std::env::var_os("IJIMA_RATE_DISABLE").is_some() {
159            None
160        } else {
161            let base = crate::config::resolve_f64("IJIMA_RATE_BASE", file_config.rate_base, 10.0);
162            let mult = crate::config::resolve_f64(
163                "IJIMA_RATE_MULTIPLIER",
164                file_config.rate_multiplier,
165                1.0,
166            );
167            tracing::info!(
168                base_tokens_per_second = base,
169                multiplier = mult,
170                "rate limiting enabled (Schubert intersection-number capacity)"
171            );
172            Some(crate::rate_limit::make_rate_limiter(base, mult))
173        };
174
175    let app = api::app(
176        auth,
177        store,
178        kg,
179        embedder,
180        std::sync::Arc::new(crate::redaction::Redactor::new()),
181        #[cfg(feature = "rate-limit")]
182        rate_limiter,
183        #[cfg(feature = "federation")]
184        std::sync::Arc::new(federation_config_from_env()),
185    );
186
187    let addr = format!("{}:{}", config.host, config.port);
188
189    // TLS: if both cert and key env vars are set, serve over HTTPS.
190    // Plain HTTP is the default — no config = no TLS.
191    #[cfg(feature = "tls")]
192    if let (Some(cert_path), Some(key_path)) = (
193        std::env::var_os("IJIMA_TLS_CERT"),
194        std::env::var_os("IJIMA_TLS_KEY"),
195    ) {
196        let tls_config =
197            axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
198                .await
199                .map_err(|e| IjimaError::Store {
200                    detail: format!("tls config: {e}"),
201                })?;
202        let socket_addr: std::net::SocketAddr = addr.parse().map_err(|e| IjimaError::Store {
203            detail: format!("parse {addr}: {e}"),
204        })?;
205        let listener = axum_server::bind_rustls(socket_addr, tls_config);
206        eprintln!("ijima: listening on https://{addr}");
207        listener
208            .serve(app.into_make_service())
209            .await
210            .map_err(|e| IjimaError::Store {
211                detail: format!("serve: {e}"),
212            })?;
213        return Ok(());
214    }
215
216    let listener = tokio::net::TcpListener::bind(&addr)
217        .await
218        .map_err(|e| IjimaError::Store {
219            detail: format!("bind {addr}: {e}"),
220        })?;
221    tracing::info!(addr = %addr, "ijima listening");
222    axum::serve(listener, app)
223        .await
224        .map_err(|e| IjimaError::Store {
225            detail: format!("serve: {e}"),
226        })?;
227    Ok(())
228}