Compare commits
17 Commits
feat/73-ca
...
feat/F1-th
| Author | SHA1 | Date | |
|---|---|---|---|
|
7a6f252fe0
|
|||
|
bb0d1e51b8
|
|||
|
f2ba12bbc5
|
|||
|
a9d7382be8
|
|||
|
d94c62c143
|
|||
|
cb9e7c7c2e
|
|||
|
2604b9f134
|
|||
|
178e3092d5
|
|||
|
46befde4cd
|
|||
|
cf87e156c5
|
|||
|
79073170ec
|
|||
|
71106afaf1
|
|||
|
d2dcdd6ebb
|
|||
|
222c2a6116
|
|||
|
1115bb0942
|
|||
|
63f578cb15
|
|||
|
76c90fa993
|
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
/target
|
/target
|
||||||
/bench/node_modules
|
/bench/node_modules
|
||||||
/bench/dist
|
/bench/dist
|
||||||
|
/helexa.ai/node_modules
|
||||||
|
/helexa.ai/dist
|
||||||
|
helexa.ai/.env.local
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
839
Cargo.lock
generated
@@ -9,6 +9,7 @@ members = [
|
|||||||
"crates/helexa-bench",
|
"crates/helexa-bench",
|
||||||
"crates/helexa-router",
|
"crates/helexa-router",
|
||||||
"crates/helexa-stream",
|
"crates/helexa-stream",
|
||||||
|
"crates/helexa-upstream",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -90,3 +90,18 @@ account_id = "operator"
|
|||||||
key_id = "infra"
|
key_id = "infra"
|
||||||
# No hard_cap → uncapped operator infra key (own fleet, own use). Still
|
# No hard_cap → uncapped operator infra key (own fleet, own use). Still
|
||||||
# metered for visibility.
|
# metered for visibility.
|
||||||
|
|
||||||
|
# -- Upstream (helexa mesh) entitlements client (#57) --------------------
|
||||||
|
# When enabled, a bearer key NOT found in [[entitlements.keys]] above is
|
||||||
|
# validated against the helexa-upstream authority (mesh accounts), and its
|
||||||
|
# budget is reserved/settled there. Operator-local keys (incl. the infra
|
||||||
|
# key) never leave this process. Fail-closed: if upstream is unreachable a
|
||||||
|
# request is refused (503 + Retry-After), never served un-authorized.
|
||||||
|
# Disabled by default — a standalone operator runs purely local.
|
||||||
|
[upstream]
|
||||||
|
enabled = false
|
||||||
|
# url = "https://upstream.helexa.ai"
|
||||||
|
# Shared client bearer this cortex presents (maps to an operator_id
|
||||||
|
# upstream). Override via CORTEX_UPSTREAM__BEARER in prod.
|
||||||
|
# bearer = "replace-with-operator-client-secret"
|
||||||
|
# timeout_secs = 5
|
||||||
|
|||||||
@@ -22,6 +22,36 @@ pub struct GatewayConfig {
|
|||||||
/// setups keep working until keys are configured.
|
/// setups keep working until keys are configured.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub entitlements: EntitlementsConfig,
|
pub entitlements: EntitlementsConfig,
|
||||||
|
/// helexa-upstream client (#57). When enabled, keys not found in the
|
||||||
|
/// local `[entitlements]` config are validated against the mesh
|
||||||
|
/// authority, and budget is reserved/settled there. Disabled by default
|
||||||
|
/// — a single operator runs purely local.
|
||||||
|
#[serde(default)]
|
||||||
|
pub upstream: UpstreamClientConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[upstream]` — the helexa-upstream authority client (#57). Locally
|
||||||
|
/// unrecognised bearer keys are resolved against `url`'s `/authz/v1` surface
|
||||||
|
/// (mesh accounts); local keys (operator + infra) never leave the process.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct UpstreamClientConfig {
|
||||||
|
/// Enable the upstream fallthrough. Off → purely local entitlements.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Base URL of helexa-upstream (e.g. "https://upstream.helexa.ai").
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: String,
|
||||||
|
/// Shared client bearer this cortex presents to `/authz/v1` (maps to an
|
||||||
|
/// operator_id upstream). Sent as `Authorization: Bearer <bearer>`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub bearer: String,
|
||||||
|
/// Per-call timeout (seconds) to upstream.
|
||||||
|
#[serde(default = "default_upstream_timeout")]
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_upstream_timeout() -> u64 {
|
||||||
|
5
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[entitlements]` — the local/static [`crate::entitlements::EntitlementProvider`]
|
/// `[entitlements]` — the local/static [`crate::entitlements::EntitlementProvider`]
|
||||||
@@ -129,6 +159,7 @@ impl Default for GatewayConfig {
|
|||||||
neurons: vec![],
|
neurons: vec![],
|
||||||
models_config: default_models_path(),
|
models_config: default_models_path(),
|
||||||
entitlements: EntitlementsConfig::default(),
|
entitlements: EntitlementsConfig::default(),
|
||||||
|
upstream: UpstreamClientConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,12 +81,19 @@ pub struct BudgetSnapshot {
|
|||||||
pub reserved: u64,
|
pub reserved: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authentication failure — the bearer key could not be resolved. Maps to
|
/// Authentication failure — the bearer key could not be resolved.
|
||||||
/// `401 invalid_api_key` (#49/#63).
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AuthError {
|
pub enum AuthError {
|
||||||
|
/// The key is genuinely unknown → `401 invalid_api_key` (#49/#63).
|
||||||
#[error("invalid or unknown API key")]
|
#[error("invalid or unknown API key")]
|
||||||
InvalidKey,
|
InvalidKey,
|
||||||
|
/// The authority that could resolve the key is unreachable (e.g. the
|
||||||
|
/// helexa-upstream client failed, #57). Fail **closed** but distinctly:
|
||||||
|
/// a transient outage must surface as `503 service_unavailable` +
|
||||||
|
/// `Retry-After`, never `401` — a real key must not be rejected as
|
||||||
|
/// invalid during an upstream blip.
|
||||||
|
#[error("entitlement authority unavailable; retry in {retry_after_secs}s")]
|
||||||
|
Unavailable { retry_after_secs: u64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a reservation was refused. Carries enough for the caller to build the
|
/// Why a reservation was refused. Carries enough for the caller to build the
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use axum::http::header::AUTHORIZATION;
|
|||||||
use axum::http::{HeaderMap, HeaderValue};
|
use axum::http::{HeaderMap, HeaderValue};
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use cortex_core::entitlements::{HEADER_ACCOUNT_ID, HEADER_KEY_ID};
|
use cortex_core::entitlements::{AuthError, HEADER_ACCOUNT_ID, HEADER_KEY_ID};
|
||||||
use cortex_core::error_envelope::OpenAiError;
|
use cortex_core::error_envelope::OpenAiError;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -83,14 +83,25 @@ pub async fn require_principal(
|
|||||||
req.extensions_mut().insert(principal);
|
req.extensions_mut().insert(principal);
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
}
|
}
|
||||||
// An unrecognized key only hard-fails when auth is *required*.
|
// The entitlement authority is unreachable (upstream client
|
||||||
// In allow-anonymous mode (the default) we must IGNORE it and
|
// blip, #57). Fail **closed but distinct**: a transient outage
|
||||||
// serve the request unauthenticated — otherwise the placeholder
|
// must not reject a real key as `401 invalid_api_key` — it's a
|
||||||
// keys that OpenAI-compatible clients send by default (opencode,
|
// retryable `503`. This holds regardless of require_auth: we
|
||||||
// Open WebUI, Agent Zero, litellm) would all break, even though
|
// can't safely serve a key we couldn't authorize.
|
||||||
// the operator never opted into auth. Pre-#49 the bearer was
|
Err(AuthError::Unavailable { retry_after_secs }) => {
|
||||||
// never inspected at all; this preserves that for require_auth=false.
|
envelope_response(OpenAiError::service_unavailable(
|
||||||
Err(_) => {
|
"entitlement authority temporarily unavailable",
|
||||||
|
Some(retry_after_secs),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// A genuinely unrecognized key only hard-fails when auth is
|
||||||
|
// *required*. In allow-anonymous mode (the default) we IGNORE it
|
||||||
|
// and serve unauthenticated — otherwise the placeholder keys that
|
||||||
|
// OpenAI-compatible clients send by default (opencode, Open WebUI,
|
||||||
|
// Agent Zero, litellm) would all break though the operator never
|
||||||
|
// opted into auth. Pre-#49 the bearer was never inspected; this
|
||||||
|
// preserves that for require_auth=false.
|
||||||
|
Err(AuthError::InvalidKey) => {
|
||||||
if fleet.require_auth {
|
if fleet.require_auth {
|
||||||
unauthorized("invalid API key")
|
unauthorized("invalid API key")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
112
crates/cortex-gateway/src/entitlements_chain.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//! Chained entitlement provider (#57): operator-local keys first, mesh
|
||||||
|
//! upstream for everything else.
|
||||||
|
//!
|
||||||
|
//! `resolve` tries the [`LocalEntitlementProvider`] (operator + infra keys —
|
||||||
|
//! never a network hop); only a locally-unknown key falls through to
|
||||||
|
//! [`UpstreamEntitlementProvider`]. Because the local provider treats an
|
||||||
|
//! unconfigured principal as uncapped, reserve/settle/release/snapshot must
|
||||||
|
//! **not** blindly hit local — they dispatch to whichever backend resolved
|
||||||
|
//! that account, remembered in a map keyed by `account_id` (populated at
|
||||||
|
//! resolve time).
|
||||||
|
|
||||||
|
use crate::entitlements_local::LocalEntitlementProvider;
|
||||||
|
use crate::entitlements_upstream::UpstreamEntitlementProvider;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use cortex_core::entitlements::{
|
||||||
|
AuthError, BudgetError, BudgetSnapshot, EntitlementProvider, Principal, Reservation,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum Backend {
|
||||||
|
Local,
|
||||||
|
Upstream,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ChainedEntitlementProvider {
|
||||||
|
local: LocalEntitlementProvider,
|
||||||
|
upstream: UpstreamEntitlementProvider,
|
||||||
|
/// account_id → which backend owns it, learned at resolve time.
|
||||||
|
backends: RwLock<HashMap<String, Backend>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChainedEntitlementProvider {
|
||||||
|
pub fn new(local: LocalEntitlementProvider, upstream: UpstreamEntitlementProvider) -> Self {
|
||||||
|
Self {
|
||||||
|
local,
|
||||||
|
upstream,
|
||||||
|
backends: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record(&self, account_id: &str, backend: Backend) {
|
||||||
|
self.backends
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(account_id.to_string(), backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backend that owns `account_id`. Defaults to `Upstream` for an
|
||||||
|
/// account never resolved this process-lifetime (a resolve always
|
||||||
|
/// precedes reserve in a request, so this is just a safe fallback —
|
||||||
|
/// upstream fails closed if the account is bogus).
|
||||||
|
async fn backend_for(&self, account_id: &str) -> Backend {
|
||||||
|
self.backends
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(account_id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(Backend::Upstream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EntitlementProvider for ChainedEntitlementProvider {
|
||||||
|
async fn resolve(&self, api_key: &str) -> Result<Principal, AuthError> {
|
||||||
|
match self.local.resolve(api_key).await {
|
||||||
|
Ok(p) => {
|
||||||
|
self.record(&p.account_id, Backend::Local).await;
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
Err(AuthError::InvalidKey) => {
|
||||||
|
let p = self.upstream.resolve(api_key).await?;
|
||||||
|
self.record(&p.account_id, Backend::Upstream).await;
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reserve(
|
||||||
|
&self,
|
||||||
|
principal: &Principal,
|
||||||
|
max_tokens: u64,
|
||||||
|
) -> Result<Reservation, BudgetError> {
|
||||||
|
match self.backend_for(&principal.account_id).await {
|
||||||
|
Backend::Local => self.local.reserve(principal, max_tokens).await,
|
||||||
|
Backend::Upstream => self.upstream.reserve(principal, max_tokens).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn settle(&self, reservation: Reservation, actual_tokens: u64) {
|
||||||
|
match self.backend_for(&reservation.principal.account_id).await {
|
||||||
|
Backend::Local => self.local.settle(reservation, actual_tokens).await,
|
||||||
|
Backend::Upstream => self.upstream.settle(reservation, actual_tokens).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release(&self, reservation: Reservation) {
|
||||||
|
match self.backend_for(&reservation.principal.account_id).await {
|
||||||
|
Backend::Local => self.local.release(reservation).await,
|
||||||
|
Backend::Upstream => self.upstream.release(reservation).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn snapshot(&self, principal: &Principal) -> Option<BudgetSnapshot> {
|
||||||
|
match self.backend_for(&principal.account_id).await {
|
||||||
|
Backend::Local => self.local.snapshot(principal).await,
|
||||||
|
Backend::Upstream => self.upstream.snapshot(principal).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
246
crates/cortex-gateway/src/entitlements_upstream.rs
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
//! helexa-upstream client (#57): an [`EntitlementProvider`] that resolves
|
||||||
|
//! keys and reserves/settles budget against the mesh authority's
|
||||||
|
//! `/authz/v1` surface (B2). It is "just another impl of the trait" — cortex
|
||||||
|
//! enforcement (`auth.rs`, `metering.rs`) is unchanged.
|
||||||
|
//!
|
||||||
|
//! **Fail closed.** When upstream is unreachable, `resolve` returns
|
||||||
|
//! [`AuthError::Unavailable`] (→ `503`, never `401`) and `reserve` refuses
|
||||||
|
//! with a retryable [`BudgetError::RateLimited`] — a request is never served
|
||||||
|
//! on an un-authorized key, and a real key is never rejected as invalid
|
||||||
|
//! during a blip.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use cortex_core::config::UpstreamClientConfig;
|
||||||
|
use cortex_core::entitlements::{
|
||||||
|
AuthError, BudgetError, BudgetSnapshot, EntitlementProvider, Principal, Reservation,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Retry-After (seconds) advertised when we fail closed on an upstream
|
||||||
|
/// outage.
|
||||||
|
const FAIL_CLOSED_RETRY_SECS: u64 = 5;
|
||||||
|
|
||||||
|
pub struct UpstreamEntitlementProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
bearer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PrincipalDto {
|
||||||
|
account_id: String,
|
||||||
|
key_id: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SnapshotDto {
|
||||||
|
hard_cap: Option<u64>,
|
||||||
|
spent: u64,
|
||||||
|
reserved: u64,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ResolveResp {
|
||||||
|
principal: PrincipalDto,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
snapshot: Option<SnapshotDto>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ReserveResp {
|
||||||
|
reservation_id: Option<i64>,
|
||||||
|
rejected: Option<Rejection>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
enum Rejection {
|
||||||
|
InsufficientQuota {
|
||||||
|
requested: u64,
|
||||||
|
available: u64,
|
||||||
|
},
|
||||||
|
RateLimited {
|
||||||
|
requested: u64,
|
||||||
|
available: u64,
|
||||||
|
retry_after_secs: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpstreamEntitlementProvider {
|
||||||
|
pub fn new(cfg: &UpstreamClientConfig) -> Self {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(cfg.timeout_secs))
|
||||||
|
.build()
|
||||||
|
.expect("failed to build upstream HTTP client");
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
base_url: cfg.url.trim_end_matches('/').to_string(),
|
||||||
|
bearer: cfg.bearer.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}{}", self.base_url, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EntitlementProvider for UpstreamEntitlementProvider {
|
||||||
|
async fn resolve(&self, api_key: &str) -> Result<Principal, AuthError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.url("/authz/v1/resolve"))
|
||||||
|
.bearer_auth(&self.bearer)
|
||||||
|
.json(&serde_json::json!({ "api_key": api_key }))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
let resp = match resp {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "upstream resolve unreachable; failing closed");
|
||||||
|
return Err(AuthError::Unavailable {
|
||||||
|
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if resp.status().as_u16() == 401 {
|
||||||
|
return Err(AuthError::InvalidKey);
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(AuthError::Unavailable {
|
||||||
|
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match resp.json::<ResolveResp>().await {
|
||||||
|
Ok(r) => Ok(Principal {
|
||||||
|
account_id: r.principal.account_id,
|
||||||
|
key_id: r.principal.key_id,
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "upstream resolve: bad body; failing closed");
|
||||||
|
Err(AuthError::Unavailable {
|
||||||
|
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reserve(
|
||||||
|
&self,
|
||||||
|
principal: &Principal,
|
||||||
|
max_tokens: u64,
|
||||||
|
) -> Result<Reservation, BudgetError> {
|
||||||
|
let fail_closed = || BudgetError::RateLimited {
|
||||||
|
requested: max_tokens,
|
||||||
|
available: 0,
|
||||||
|
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
|
||||||
|
};
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.url("/authz/v1/reserve"))
|
||||||
|
.bearer_auth(&self.bearer)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"account_id": principal.account_id,
|
||||||
|
"key_id": principal.key_id,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
let resp = match resp {
|
||||||
|
Ok(r) if r.status().is_success() => r,
|
||||||
|
Ok(r) => {
|
||||||
|
tracing::warn!(status = %r.status(), "upstream reserve non-2xx; failing closed");
|
||||||
|
return Err(fail_closed());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "upstream reserve unreachable; failing closed");
|
||||||
|
return Err(fail_closed());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match resp.json::<ReserveResp>().await {
|
||||||
|
Ok(ReserveResp {
|
||||||
|
reservation_id: Some(id),
|
||||||
|
..
|
||||||
|
}) => Ok(Reservation {
|
||||||
|
id: id as u64,
|
||||||
|
principal: principal.clone(),
|
||||||
|
reserved: max_tokens,
|
||||||
|
}),
|
||||||
|
Ok(ReserveResp {
|
||||||
|
rejected:
|
||||||
|
Some(Rejection::InsufficientQuota {
|
||||||
|
requested,
|
||||||
|
available,
|
||||||
|
}),
|
||||||
|
..
|
||||||
|
}) => Err(BudgetError::InsufficientQuota {
|
||||||
|
requested,
|
||||||
|
available,
|
||||||
|
}),
|
||||||
|
Ok(ReserveResp {
|
||||||
|
rejected:
|
||||||
|
Some(Rejection::RateLimited {
|
||||||
|
requested,
|
||||||
|
available,
|
||||||
|
retry_after_secs,
|
||||||
|
}),
|
||||||
|
..
|
||||||
|
}) => Err(BudgetError::RateLimited {
|
||||||
|
requested,
|
||||||
|
available,
|
||||||
|
retry_after_secs,
|
||||||
|
}),
|
||||||
|
_ => Err(fail_closed()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn settle(&self, reservation: Reservation, actual_tokens: u64) {
|
||||||
|
// Best-effort; a lost settle is reaped by the upstream sweeper (B2).
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.post(self.url("/authz/v1/settle"))
|
||||||
|
.bearer_auth(&self.bearer)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"reservation_id": reservation.id as i64,
|
||||||
|
"actual_tokens": actual_tokens,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.inspect_err(
|
||||||
|
|e| tracing::warn!(error = %e, "upstream settle failed (sweeper will reap)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release(&self, reservation: Reservation) {
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.post(self.url("/authz/v1/release"))
|
||||||
|
.bearer_auth(&self.bearer)
|
||||||
|
.json(&serde_json::json!({ "reservation_id": reservation.id as i64 }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.inspect_err(
|
||||||
|
|e| tracing::warn!(error = %e, "upstream release failed (sweeper will reap)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn snapshot(&self, principal: &Principal) -> Option<BudgetSnapshot> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.url("/authz/v1/snapshot"))
|
||||||
|
.bearer_auth(&self.bearer)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"account_id": principal.account_id,
|
||||||
|
"key_id": principal.key_id,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dto = resp.json::<SnapshotDto>().await.ok()?;
|
||||||
|
Some(BudgetSnapshot {
|
||||||
|
hard_cap: dto.hard_cap,
|
||||||
|
spent: dto.spent,
|
||||||
|
reserved: dto.reserved,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod anthropic_sse;
|
pub mod anthropic_sse;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod entitlements_chain;
|
||||||
pub mod entitlements_local;
|
pub mod entitlements_local;
|
||||||
|
pub mod entitlements_upstream;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod evictor;
|
pub mod evictor;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
use crate::entitlements_chain::ChainedEntitlementProvider;
|
||||||
use crate::entitlements_local::LocalEntitlementProvider;
|
use crate::entitlements_local::LocalEntitlementProvider;
|
||||||
|
use crate::entitlements_upstream::UpstreamEntitlementProvider;
|
||||||
use cortex_core::catalogue::ModelCatalogue;
|
use cortex_core::catalogue::ModelCatalogue;
|
||||||
use cortex_core::config::{EvictionSettings, GatewayConfig, NeuronEndpoint};
|
use cortex_core::config::{EvictionSettings, GatewayConfig, NeuronEndpoint};
|
||||||
use cortex_core::entitlements::EntitlementProvider;
|
use cortex_core::entitlements::EntitlementProvider;
|
||||||
@@ -45,8 +47,20 @@ impl CortexState {
|
|||||||
|
|
||||||
let catalogue = ModelCatalogue::load(&config.models_config);
|
let catalogue = ModelCatalogue::load(&config.models_config);
|
||||||
|
|
||||||
let entitlements: Arc<dyn EntitlementProvider> =
|
// Local provider always handles operator + infra keys. When the
|
||||||
Arc::new(LocalEntitlementProvider::from_config(&config.entitlements));
|
// upstream client is enabled (#57), wrap it in the chain so locally
|
||||||
|
// unknown keys fall through to the mesh authority; otherwise stay
|
||||||
|
// purely local.
|
||||||
|
let local = LocalEntitlementProvider::from_config(&config.entitlements);
|
||||||
|
let entitlements: Arc<dyn EntitlementProvider> = if config.upstream.enabled {
|
||||||
|
tracing::info!(url = %config.upstream.url, "upstream entitlement client enabled");
|
||||||
|
Arc::new(ChainedEntitlementProvider::new(
|
||||||
|
local,
|
||||||
|
UpstreamEntitlementProvider::new(&config.upstream),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Arc::new(local)
|
||||||
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
nodes: RwLock::new(nodes),
|
nodes: RwLock::new(nodes),
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async fn test_alias_resolves_in_chat_completions() {
|
|||||||
}],
|
}],
|
||||||
models_config: models_path.to_string_lossy().to_string(),
|
models_config: models_path.to_string_lossy().to_string(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
@@ -143,6 +144,7 @@ async fn test_aliases_surface_in_v1_models() {
|
|||||||
}],
|
}],
|
||||||
models_config: models_path.to_string_lossy().to_string(),
|
models_config: models_path.to_string_lossy().to_string(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
@@ -232,6 +234,7 @@ async fn test_alias_falls_through_for_unmapped_model() {
|
|||||||
}],
|
}],
|
||||||
models_config: models_path.to_string_lossy().to_string(),
|
models_config: models_path.to_string_lossy().to_string(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ async fn spawn_gateway(neuron_url: &str, entitlements: EntitlementsConfig) -> St
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements,
|
entitlements,
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async fn spawn_gateway(neuron_url: &str, key: ApiKeyConfig) -> (Arc<CortexState>
|
|||||||
require_auth: true,
|
require_auth: true,
|
||||||
keys: vec![key],
|
keys: vec![key],
|
||||||
},
|
},
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -430,6 +430,7 @@ pub async fn spawn_gateway_with_state(mock_url: &str) -> (Arc<CortexState>, Stri
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ async fn error_response_no_healthy_nodes() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(cortex_gateway::state::CortexState::from_config(&config));
|
let fleet = Arc::new(cortex_gateway::state::CortexState::from_config(&config));
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ fn make_fleet(endpoint: &str, defrag_after: u32) -> Arc<CortexState> {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
Arc::new(CortexState::from_config(&config))
|
Arc::new(CortexState::from_config(&config))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ async fn fleet_with(big_healthy: bool, big_devices: usize) -> Arc<CortexState> {
|
|||||||
],
|
],
|
||||||
models_config: cat.to_string_lossy().into_owned(),
|
models_config: cat.to_string_lossy().into_owned(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ async fn two_neuron_fleet(endpoint_a: &str, endpoint_b: &str) -> Arc<CortexState
|
|||||||
],
|
],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
Arc::new(CortexState::from_config(&config))
|
Arc::new(CortexState::from_config(&config))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ async fn spawn_metered_gateway(neuron_url: &str) -> (Arc<CortexState>, String) {
|
|||||||
window: CapWindow::Balance,
|
window: CapWindow::Balance,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
@@ -158,6 +159,7 @@ async fn anonymous_request_records_no_spend() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: EntitlementsConfig::default(),
|
entitlements: EntitlementsConfig::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ harness = "candle"
|
|||||||
}],
|
}],
|
||||||
models_config: cat_path.to_string_lossy().into_owned(),
|
models_config: cat_path.to_string_lossy().into_owned(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ capabilities = ["text"]
|
|||||||
}],
|
}],
|
||||||
models_config: cat_path.to_string_lossy().into_owned(),
|
models_config: cat_path.to_string_lossy().into_owned(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async fn test_poller_discovers_models() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
@@ -84,6 +85,7 @@ async fn test_poller_updates_gateway_models_endpoint() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
@@ -156,6 +158,7 @@ async fn test_models_endpoint_unions_capabilities_across_nodes() {
|
|||||||
],
|
],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
@@ -219,6 +222,7 @@ async fn test_poller_marks_unreachable_node_unhealthy() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
@@ -273,6 +277,7 @@ async fn test_poller_removes_stale_models() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
@@ -304,6 +309,7 @@ async fn test_poller_removes_stale_models() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet2 = Arc::new(CortexState::from_config(&config2));
|
let fleet2 = Arc::new(CortexState::from_config(&config2));
|
||||||
@@ -386,6 +392,7 @@ async fn test_poller_captures_activation_from_health() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
@@ -431,6 +438,7 @@ async fn test_poller_parses_recovering_status() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ async fn spawn_gateway(neuron: &str, context: usize) -> String {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
let fleet = Arc::new(CortexState::from_config(&config));
|
let fleet = Arc::new(CortexState::from_config(&config));
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ async fn test_no_healthy_nodes() {
|
|||||||
}],
|
}],
|
||||||
models_config: "/dev/null".into(),
|
models_config: "/dev/null".into(),
|
||||||
entitlements: Default::default(),
|
entitlements: Default::default(),
|
||||||
|
upstream: Default::default(),
|
||||||
};
|
};
|
||||||
let fleet = std::sync::Arc::new(cortex_gateway::state::CortexState::from_config(&config));
|
let fleet = std::sync::Arc::new(cortex_gateway::state::CortexState::from_config(&config));
|
||||||
|
|
||||||
|
|||||||
105
crates/cortex-gateway/tests/upstream_chain.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
//! B3: the chained entitlement provider (local → upstream) and fail-closed
|
||||||
|
//! semantics, exercised against a mock helexa-upstream `/authz/v1`.
|
||||||
|
|
||||||
|
use axum::{Json, Router, routing::post};
|
||||||
|
use cortex_core::config::{ApiKeyConfig, EntitlementsConfig, UpstreamClientConfig};
|
||||||
|
use cortex_core::entitlements::{AuthError, EntitlementProvider};
|
||||||
|
use cortex_gateway::entitlements_chain::ChainedEntitlementProvider;
|
||||||
|
use cortex_gateway::entitlements_local::LocalEntitlementProvider;
|
||||||
|
use cortex_gateway::entitlements_upstream::UpstreamEntitlementProvider;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
/// Mock upstream: `mesh-key` resolves to a mesh account; anything else 401.
|
||||||
|
/// reserve always grants reservation 1.
|
||||||
|
async fn spawn_mock_upstream() -> String {
|
||||||
|
async fn resolve(Json(body): Json<Value>) -> axum::response::Response {
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
if body["api_key"] == "mesh-key" {
|
||||||
|
Json(json!({"principal": {"account_id": "mesh-acct", "key_id": "mesh-key-1"}}))
|
||||||
|
.into_response()
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({"error": {"code": "invalid_api_key"}})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn reserve() -> Json<Value> {
|
||||||
|
Json(json!({ "reservation_id": 1 }))
|
||||||
|
}
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/authz/v1/resolve", post(resolve))
|
||||||
|
.route("/authz/v1/reserve", post(reserve));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_with_key() -> LocalEntitlementProvider {
|
||||||
|
let cfg = EntitlementsConfig {
|
||||||
|
require_auth: false,
|
||||||
|
keys: vec![ApiKeyConfig {
|
||||||
|
key: "local-key".into(),
|
||||||
|
account_id: "op".into(),
|
||||||
|
key_id: None,
|
||||||
|
hard_cap: None,
|
||||||
|
window: Default::default(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
LocalEntitlementProvider::from_config(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chain(local: LocalEntitlementProvider, url: &str) -> ChainedEntitlementProvider {
|
||||||
|
let upstream = UpstreamEntitlementProvider::new(&UpstreamClientConfig {
|
||||||
|
enabled: true,
|
||||||
|
url: url.to_string(),
|
||||||
|
bearer: "client-secret".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
});
|
||||||
|
ChainedEntitlementProvider::new(local, upstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_key_resolves_locally() {
|
||||||
|
let url = spawn_mock_upstream().await;
|
||||||
|
let c = chain(local_with_key(), &url);
|
||||||
|
let p = c.resolve("local-key").await.expect("local resolves");
|
||||||
|
assert_eq!(p.account_id, "op");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_key_falls_through_to_upstream() {
|
||||||
|
let url = spawn_mock_upstream().await;
|
||||||
|
let c = chain(local_with_key(), &url);
|
||||||
|
let p = c.resolve("mesh-key").await.expect("upstream resolves");
|
||||||
|
assert_eq!(p.account_id, "mesh-acct");
|
||||||
|
assert_eq!(p.key_id, "mesh-key-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_everywhere_is_invalid_key() {
|
||||||
|
let url = spawn_mock_upstream().await;
|
||||||
|
let c = chain(local_with_key(), &url);
|
||||||
|
match c.resolve("nope").await {
|
||||||
|
Err(AuthError::InvalidKey) => {}
|
||||||
|
other => panic!("expected InvalidKey, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upstream_unreachable_fails_closed_as_unavailable() {
|
||||||
|
// No mock — point at a dead port. A locally-unknown key must surface
|
||||||
|
// Unavailable (→ 503), never InvalidKey (→ 401).
|
||||||
|
let c = chain(local_with_key(), "http://127.0.0.1:1");
|
||||||
|
match c.resolve("some-mesh-key").await {
|
||||||
|
Err(AuthError::Unavailable { retry_after_secs }) => assert!(retry_after_secs > 0),
|
||||||
|
other => panic!("expected Unavailable, got {other:?}"),
|
||||||
|
}
|
||||||
|
// A local key still resolves without touching upstream.
|
||||||
|
assert_eq!(c.resolve("local-key").await.unwrap().account_id, "op");
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
figment = { workspace = true }
|
figment = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
@@ -33,3 +34,8 @@ chrono = { workspace = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Jail (isolated cwd + env) for config tests.
|
# Jail (isolated cwd + env) for config tests.
|
||||||
figment = { workspace = true, features = ["test"] }
|
figment = { workspace = true, features = ["test"] }
|
||||||
|
# Self-signed cert generation + a minimal HTTPS server for the outbound
|
||||||
|
# TLS-pinning tests (#74).
|
||||||
|
rcgen = "0.13"
|
||||||
|
rustls = "0.23"
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
|||||||
243
crates/helexa-router/src/catalogue.rs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
//! Federation catalogue (#75) — the router's aggregate `/v1/models`.
|
||||||
|
//!
|
||||||
|
//! Presents the **deduped union** of every reachable cortex's `/v1/models`
|
||||||
|
//! as the router's own catalogue, so an opencode client doing discovery
|
||||||
|
//! against the router resolves the whole federation without knowing about
|
||||||
|
//! operators or cortexes (resolves #61's "Router/discovery contract").
|
||||||
|
//!
|
||||||
|
//! Re-tiering: the fractal design is neuron ← cortex ← router. At the
|
||||||
|
//! router tier the "nodes" are **cortexes**, so the merged entry's
|
||||||
|
//! `feasible_on` / `locations` are rewritten to **operator names**, not the
|
||||||
|
//! neuron names a cortex reports. That keeps the federation view honest
|
||||||
|
//! ("served by these operators") without leaking each operator's internal
|
||||||
|
//! topology (neuron names, per-device VRAM) to end users.
|
||||||
|
//!
|
||||||
|
//! Conflict resolution when operators advertise the same model with
|
||||||
|
//! different enrichment:
|
||||||
|
//! - **`limit`** → the *tightest* (smallest `context`), so a client never
|
||||||
|
//! overflows the most-constrained operator that might serve it (same rule
|
||||||
|
//! cortex uses across its neurons).
|
||||||
|
//! - **`cost`** → the *cheapest* (lowest input, then output), the
|
||||||
|
//! federation "from" price. Richer policy (a range, region/price-aware
|
||||||
|
//! selection) couples to #68 and is left as a follow-up.
|
||||||
|
|
||||||
|
use crate::state::{CortexTopology, entry_feasible};
|
||||||
|
use cortex_core::harness::{ModelCost, ModelLimit};
|
||||||
|
use cortex_core::node::{CortexModelEntry, ModelLocation, ModelStatus};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Build the federation catalogue: the deduped union of every reachable
|
||||||
|
/// cortex's serveable models, merged across operators and sorted by id.
|
||||||
|
pub fn aggregate_models(topology: &HashMap<String, CortexTopology>) -> Vec<CortexModelEntry> {
|
||||||
|
// Iterate cortexes in name order so `feasible_on` / `locations` and the
|
||||||
|
// limit/cost tie-breaks are deterministic regardless of map ordering.
|
||||||
|
let mut cortexes: Vec<(&String, &CortexTopology)> = topology.iter().collect();
|
||||||
|
cortexes.sort_by(|a, b| a.0.cmp(b.0));
|
||||||
|
|
||||||
|
let mut merged: HashMap<String, CortexModelEntry> = HashMap::new();
|
||||||
|
for (cortex_name, t) in cortexes {
|
||||||
|
if !t.reachable {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entry in t.models.values() {
|
||||||
|
// Only surface models the cortex can actually serve — a
|
||||||
|
// catalogue-only entry no neuron can host shouldn't appear in
|
||||||
|
// the federation view.
|
||||||
|
if !entry_feasible(entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
merged
|
||||||
|
.entry(entry.id.clone())
|
||||||
|
.and_modify(|acc| merge_into(acc, cortex_name, entry))
|
||||||
|
.or_insert_with(|| router_entry(cortex_name, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<CortexModelEntry> = merged.into_values().collect();
|
||||||
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed a federation entry from the first cortex that serves the model,
|
||||||
|
/// re-tiering `feasible_on` / `locations` to the operator name.
|
||||||
|
fn router_entry(cortex: &str, e: &CortexModelEntry) -> CortexModelEntry {
|
||||||
|
CortexModelEntry {
|
||||||
|
id: e.id.clone(),
|
||||||
|
object: "model".into(),
|
||||||
|
created: e.created,
|
||||||
|
owned_by: e.owned_by.clone(),
|
||||||
|
loaded: e.loaded,
|
||||||
|
feasible_on: vec![cortex.to_string()],
|
||||||
|
locations: loaded_location(cortex, e),
|
||||||
|
capabilities: e.capabilities.clone(),
|
||||||
|
limit: e.limit.clone(),
|
||||||
|
cost: e.cost.clone(),
|
||||||
|
tool_call: e.tool_call,
|
||||||
|
reasoning: e.reasoning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold another cortex's view of the same model into the merged entry.
|
||||||
|
fn merge_into(acc: &mut CortexModelEntry, cortex: &str, e: &CortexModelEntry) {
|
||||||
|
acc.loaded |= e.loaded;
|
||||||
|
acc.feasible_on.push(cortex.to_string());
|
||||||
|
acc.locations.extend(loaded_location(cortex, e));
|
||||||
|
for cap in &e.capabilities {
|
||||||
|
if !acc.capabilities.contains(cap) {
|
||||||
|
acc.capabilities.push(cap.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc.tool_call |= e.tool_call;
|
||||||
|
acc.reasoning |= e.reasoning;
|
||||||
|
acc.limit = tightest_limit(acc.limit.take(), e.limit.clone());
|
||||||
|
acc.cost = cheapest_cost(acc.cost.take(), e.cost.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single cortex-tier location when the model is loaded at that operator;
|
||||||
|
/// empty when only cold-loadable. Neuron-level VRAM is deliberately dropped.
|
||||||
|
fn loaded_location(cortex: &str, e: &CortexModelEntry) -> Vec<ModelLocation> {
|
||||||
|
if e.loaded {
|
||||||
|
vec![ModelLocation {
|
||||||
|
node: cortex.to_string(),
|
||||||
|
status: ModelStatus::Loaded,
|
||||||
|
vram_estimate_mb: None,
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Smaller `context` wins — never advertise more headroom than the
|
||||||
|
/// most-constrained operator can honour.
|
||||||
|
fn tightest_limit(a: Option<ModelLimit>, b: Option<ModelLimit>) -> Option<ModelLimit> {
|
||||||
|
match (a, b) {
|
||||||
|
(None, x) | (x, None) => x,
|
||||||
|
(Some(a), Some(b)) => Some(if b.context < a.context { b } else { a }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheapest by (input, output) price — the federation "from" price.
|
||||||
|
fn cheapest_cost(a: Option<ModelCost>, b: Option<ModelCost>) -> Option<ModelCost> {
|
||||||
|
match (a, b) {
|
||||||
|
(None, x) | (x, None) => x,
|
||||||
|
(Some(a), Some(b)) => Some(if (b.input, b.output) < (a.input, a.output) {
|
||||||
|
b
|
||||||
|
} else {
|
||||||
|
a
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::state::CortexTopology;
|
||||||
|
|
||||||
|
fn entry(id: &str, loaded: bool, feasible: bool) -> CortexModelEntry {
|
||||||
|
CortexModelEntry {
|
||||||
|
id: id.into(),
|
||||||
|
object: "model".into(),
|
||||||
|
created: 0,
|
||||||
|
owned_by: "helexa".into(),
|
||||||
|
loaded,
|
||||||
|
feasible_on: if feasible || loaded {
|
||||||
|
vec!["some-neuron".into()]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
},
|
||||||
|
locations: vec![],
|
||||||
|
capabilities: vec![],
|
||||||
|
limit: None,
|
||||||
|
cost: None,
|
||||||
|
tool_call: false,
|
||||||
|
reasoning: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cortex(reachable: bool, entries: Vec<CortexModelEntry>) -> CortexTopology {
|
||||||
|
CortexTopology {
|
||||||
|
reachable,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
last_poll: None,
|
||||||
|
healthy_nodes: 1,
|
||||||
|
total_nodes: 1,
|
||||||
|
models: entries.into_iter().map(|e| (e.id.clone(), e)).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedupes_and_merges_availability_across_cortexes() {
|
||||||
|
let mut topo = HashMap::new();
|
||||||
|
// c-a: model loaded. c-b: same model only cold-loadable.
|
||||||
|
topo.insert("c-a".into(), cortex(true, vec![entry("m", true, true)]));
|
||||||
|
topo.insert("c-b".into(), cortex(true, vec![entry("m", false, true)]));
|
||||||
|
|
||||||
|
let out = aggregate_models(&topo);
|
||||||
|
assert_eq!(out.len(), 1, "duplicate model id collapses to one");
|
||||||
|
let m = &out[0];
|
||||||
|
assert!(m.loaded, "loaded somewhere → loaded");
|
||||||
|
// feasible_on re-tiered to operator names, both present, sorted.
|
||||||
|
assert_eq!(m.feasible_on, vec!["c-a".to_string(), "c-b".to_string()]);
|
||||||
|
// Only the loaded operator contributes a location, named by operator.
|
||||||
|
assert_eq!(m.locations.len(), 1);
|
||||||
|
assert_eq!(m.locations[0].node, "c-a");
|
||||||
|
assert_eq!(m.locations[0].vram_estimate_mb, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreachable_cortex_is_excluded() {
|
||||||
|
let mut topo = HashMap::new();
|
||||||
|
topo.insert("up".into(), cortex(true, vec![entry("m", true, true)]));
|
||||||
|
topo.insert(
|
||||||
|
"down".into(),
|
||||||
|
cortex(false, vec![entry("other", true, true)]),
|
||||||
|
);
|
||||||
|
let out = aggregate_models(&topo);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].id, "m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalogue_only_infeasible_entries_are_hidden() {
|
||||||
|
let mut topo = HashMap::new();
|
||||||
|
topo.insert("c".into(), cortex(true, vec![entry("ghost", false, false)]));
|
||||||
|
assert!(aggregate_models(&topo).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_tightest_limit_and_cheapest_cost() {
|
||||||
|
let mut a = entry("m", true, true);
|
||||||
|
a.limit = Some(ModelLimit {
|
||||||
|
context: 32_768,
|
||||||
|
input: None,
|
||||||
|
output: 4096,
|
||||||
|
});
|
||||||
|
a.cost = Some(ModelCost {
|
||||||
|
input: 0.50,
|
||||||
|
output: 1.50,
|
||||||
|
cache_read: None,
|
||||||
|
cache_write: None,
|
||||||
|
});
|
||||||
|
let mut b = entry("m", true, true);
|
||||||
|
b.limit = Some(ModelLimit {
|
||||||
|
context: 16_384, // tighter
|
||||||
|
input: None,
|
||||||
|
output: 4096,
|
||||||
|
});
|
||||||
|
b.cost = Some(ModelCost {
|
||||||
|
input: 0.20, // cheaper
|
||||||
|
output: 0.80,
|
||||||
|
cache_read: None,
|
||||||
|
cache_write: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut topo = HashMap::new();
|
||||||
|
topo.insert("c-a".into(), cortex(true, vec![a]));
|
||||||
|
topo.insert("c-b".into(), cortex(true, vec![b]));
|
||||||
|
|
||||||
|
let out = aggregate_models(&topo);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].limit.as_ref().unwrap().context, 16_384);
|
||||||
|
assert_eq!(out[0].cost.as_ref().unwrap().input, 0.20);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,21 @@ pub struct CortexEndpoint {
|
|||||||
/// (#73). `None` → no region preference applies to this cortex.
|
/// (#73). `None` → no region preference applies to this cortex.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub region: Option<String>,
|
pub region: Option<String>,
|
||||||
|
/// Path to a PEM trust anchor that **enrols** this cortex (#74): the
|
||||||
|
/// expected CA (or self-signed cert) the cortex's TLS cert must chain
|
||||||
|
/// to. When set on an `https://` endpoint, the router builds a client
|
||||||
|
/// that trusts **only** this anchor (platform roots disabled), so the
|
||||||
|
/// outbound router→cortex hop — which carries the client's bearer —
|
||||||
|
/// reaches a cert the router was told to expect, and a rogue endpoint
|
||||||
|
/// presenting any other (even publicly-valid) cert is rejected at the
|
||||||
|
/// TLS handshake. A rejected handshake surfaces as a connection error,
|
||||||
|
/// which the poller (#72) already treats as unreachable → excluded.
|
||||||
|
///
|
||||||
|
/// `None` → standard platform-root validation (use for cortexes behind
|
||||||
|
/// a publicly-trusted cert, or plaintext `http://` on a private network
|
||||||
|
/// where the WireGuard mesh is the trust boundary).
|
||||||
|
#[serde(default)]
|
||||||
|
pub tls_ca: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RouterConfig {
|
impl RouterConfig {
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ pub async fn select_cortexes(state: &RouterState, model: &str) -> Selection {
|
|||||||
let mut known_anywhere = false;
|
let mut known_anywhere = false;
|
||||||
|
|
||||||
for (name, t) in topo.iter() {
|
for (name, t) in topo.iter() {
|
||||||
let Some(status) = t.models.get(model) else {
|
let Some(entry) = t.models.get(model) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !status.feasible {
|
if !crate::state::entry_feasible(entry) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Known even via an unreachable cortex's last-good poll — lets us
|
// Known even via an unreachable cortex's last-good poll — lets us
|
||||||
@@ -74,7 +74,7 @@ pub async fn select_cortexes(state: &RouterState, model: &str) -> Selection {
|
|||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
ranked.push(Ranked {
|
ranked.push(Ranked {
|
||||||
loaded: status.loaded,
|
loaded: entry.loaded,
|
||||||
region_match,
|
region_match,
|
||||||
healthy_nodes: t.healthy_nodes,
|
healthy_nodes: t.healthy_nodes,
|
||||||
endpoint: (*ep).clone(),
|
endpoint: (*ep).clone(),
|
||||||
@@ -159,10 +159,16 @@ pub async fn dispatch(
|
|||||||
// genuine HTTP response (any status — including cortex's #63 429/400)
|
// genuine HTTP response (any status — including cortex's #63 429/400)
|
||||||
// is returned verbatim and never retried away.
|
// is returned verbatim and never retried away.
|
||||||
for ep in &candidates {
|
for ep in &candidates {
|
||||||
|
// A candidate whose pinned TLS client failed to build (#74) is
|
||||||
|
// disabled — skip it and fail over, same as an unreachable cortex.
|
||||||
|
let Some(client) = state.client_for(&ep.name) else {
|
||||||
|
tracing::warn!(cortex = %ep.name, "no TLS client (disabled); skipping candidate");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let url = format!("{}{}", ep.endpoint, path);
|
let url = format!("{}{}", ep.endpoint, path);
|
||||||
tracing::info!(cortex = %ep.name, url = %url, model = %model, "dispatching");
|
tracing::info!(cortex = %ep.name, url = %url, model = %model, "dispatching");
|
||||||
match helexa_stream::forward_streaming(
|
match helexa_stream::forward_streaming(
|
||||||
&state.http_client,
|
client,
|
||||||
&url,
|
&url,
|
||||||
headers.clone(),
|
headers.clone(),
|
||||||
body.clone(),
|
body.clone(),
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use crate::dispatch;
|
|
||||||
use crate::state::RouterState;
|
use crate::state::RouterState;
|
||||||
|
use crate::{catalogue, dispatch};
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use axum::{Json, Router, extract::State, routing::get, routing::post};
|
use axum::{Json, Router, extract::State, routing::get, routing::post};
|
||||||
use cortex_core::openai::ModelsResponse;
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -76,12 +75,15 @@ async fn health(State(state): State<Arc<RouterState>>) -> Json<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /v1/models` — empty catalogue stub. The real cross-operator union
|
/// `GET /v1/models` — the federation catalogue (#75): the deduped union of
|
||||||
/// (catalogue × topology feasibility, aggregated from each cortex) is the
|
/// every reachable cortex's `/v1/models`, so a client doing discovery
|
||||||
/// federation-catalogue issue (#75).
|
/// against the router resolves the whole federation without knowing about
|
||||||
async fn list_models() -> Json<ModelsResponse> {
|
/// operators or cortexes.
|
||||||
Json(ModelsResponse {
|
async fn list_models(State(state): State<Arc<RouterState>>) -> Json<Value> {
|
||||||
object: "list".into(),
|
let topo = state.topology.read().await;
|
||||||
data: vec![],
|
let data: Vec<Value> = catalogue::aggregate_models(&topo)
|
||||||
})
|
.iter()
|
||||||
|
.map(|e| json!(e))
|
||||||
|
.collect();
|
||||||
|
Json(json!({ "object": "list", "data": data }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
//! on capacity (epic #69). A background [`poller`] keeps a live
|
//! on capacity (epic #69). A background [`poller`] keeps a live
|
||||||
//! per-cortex topology (#72) that the dispatcher (#73) will route on.
|
//! per-cortex topology (#72) that the dispatcher (#73) will route on.
|
||||||
|
|
||||||
|
pub mod catalogue;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dispatch;
|
pub mod dispatch;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! then flipped unhealthy and excluded from routing; it recovers on the
|
//! then flipped unhealthy and excluded from routing; it recovers on the
|
||||||
//! next successful poll.
|
//! next successful poll.
|
||||||
|
|
||||||
use crate::state::{RouterModelStatus, RouterState};
|
use crate::state::RouterState;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use cortex_core::node::CortexModelEntry;
|
use cortex_core::node::CortexModelEntry;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -62,7 +62,19 @@ pub async fn poll_once(state: &RouterState) {
|
|||||||
/// reachability on its own (a cortex serving `/v1/models` is routable even
|
/// reachability on its own (a cortex serving `/v1/models` is routable even
|
||||||
/// if `/health` momentarily isn't).
|
/// if `/health` momentarily isn't).
|
||||||
async fn poll_cortex(state: &RouterState, name: &str, endpoint: &str) {
|
async fn poll_cortex(state: &RouterState, name: &str, endpoint: &str) {
|
||||||
let models = fetch_models(state, endpoint).await;
|
// A cortex whose pinned TLS client failed to build (#74) is disabled:
|
||||||
|
// there is no client to poll with, so it stays unreachable.
|
||||||
|
let Some(client) = state.client_for(name) else {
|
||||||
|
let mut topo = state.topology.write().await;
|
||||||
|
if let Some(entry) = topo.get_mut(name) {
|
||||||
|
entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
|
||||||
|
entry.reachable = false;
|
||||||
|
}
|
||||||
|
tracing::warn!(cortex = name, "no TLS client (disabled); skipping poll");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let models = fetch_models(client, endpoint).await;
|
||||||
|
|
||||||
let mut topo = state.topology.write().await;
|
let mut topo = state.topology.write().await;
|
||||||
let Some(entry) = topo.get_mut(name) else {
|
let Some(entry) = topo.get_mut(name) else {
|
||||||
@@ -71,19 +83,7 @@ async fn poll_cortex(state: &RouterState, name: &str, endpoint: &str) {
|
|||||||
|
|
||||||
match models {
|
match models {
|
||||||
Ok(models) => {
|
Ok(models) => {
|
||||||
entry.models = models
|
entry.models = models.into_iter().map(|m| (m.id.clone(), m)).collect();
|
||||||
.into_iter()
|
|
||||||
.map(|m| {
|
|
||||||
let feasible = m.loaded || !m.feasible_on.is_empty();
|
|
||||||
(
|
|
||||||
m.id,
|
|
||||||
RouterModelStatus {
|
|
||||||
loaded: m.loaded,
|
|
||||||
feasible,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
entry.reachable = true;
|
entry.reachable = true;
|
||||||
entry.consecutive_failures = 0;
|
entry.consecutive_failures = 0;
|
||||||
entry.last_poll = Some(Utc::now());
|
entry.last_poll = Some(Utc::now());
|
||||||
@@ -106,7 +106,7 @@ async fn poll_cortex(state: &RouterState, name: &str, endpoint: &str) {
|
|||||||
drop(topo);
|
drop(topo);
|
||||||
|
|
||||||
// Best-effort health (node counts). Never flips reachability.
|
// Best-effort health (node counts). Never flips reachability.
|
||||||
if let Some((healthy, total)) = fetch_health(state, endpoint).await {
|
if let Some((healthy, total)) = fetch_health(client, endpoint).await {
|
||||||
let mut topo = state.topology.write().await;
|
let mut topo = state.topology.write().await;
|
||||||
if let Some(entry) = topo.get_mut(name) {
|
if let Some(entry) = topo.get_mut(name) {
|
||||||
entry.healthy_nodes = healthy;
|
entry.healthy_nodes = healthy;
|
||||||
@@ -117,12 +117,11 @@ async fn poll_cortex(state: &RouterState, name: &str, endpoint: &str) {
|
|||||||
|
|
||||||
/// GET `/v1/models`, returning the parsed entries or a short failure reason.
|
/// GET `/v1/models`, returning the parsed entries or a short failure reason.
|
||||||
async fn fetch_models(
|
async fn fetch_models(
|
||||||
state: &RouterState,
|
client: &reqwest::Client,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
) -> Result<Vec<CortexModelEntry>, &'static str> {
|
) -> Result<Vec<CortexModelEntry>, &'static str> {
|
||||||
let url = format!("{endpoint}/v1/models");
|
let url = format!("{endpoint}/v1/models");
|
||||||
let resp = state
|
let resp = client
|
||||||
.http_client
|
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.timeout(POLL_TIMEOUT)
|
.timeout(POLL_TIMEOUT)
|
||||||
.send()
|
.send()
|
||||||
@@ -140,15 +139,9 @@ async fn fetch_models(
|
|||||||
|
|
||||||
/// GET `/health`, returning `(healthy, total)` node counts. `None` on any
|
/// GET `/health`, returning `(healthy, total)` node counts. `None` on any
|
||||||
/// failure — the caller leaves the previous counts in place.
|
/// failure — the caller leaves the previous counts in place.
|
||||||
async fn fetch_health(state: &RouterState, endpoint: &str) -> Option<(u32, u32)> {
|
async fn fetch_health(client: &reqwest::Client, endpoint: &str) -> Option<(u32, u32)> {
|
||||||
let url = format!("{endpoint}/health");
|
let url = format!("{endpoint}/health");
|
||||||
let resp = state
|
let resp = client.get(&url).timeout(POLL_TIMEOUT).send().await.ok()?;
|
||||||
.http_client
|
|
||||||
.get(&url)
|
|
||||||
.timeout(POLL_TIMEOUT)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.ok()?;
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::config::{CortexEndpoint, RouterConfig};
|
use crate::config::{CortexEndpoint, RouterConfig};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use cortex_core::node::CortexModelEntry;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
@@ -14,8 +15,13 @@ use tokio::sync::RwLock;
|
|||||||
pub struct RouterState {
|
pub struct RouterState {
|
||||||
/// Downstream cortex endpoints, as configured.
|
/// Downstream cortex endpoints, as configured.
|
||||||
pub cortexes: Vec<CortexEndpoint>,
|
pub cortexes: Vec<CortexEndpoint>,
|
||||||
/// Shared client for polling (and proxying to) cortexes.
|
/// Per-cortex HTTP client, keyed by cortex name (#74). A cortex enrolled
|
||||||
pub http_client: reqwest::Client,
|
/// with a `tls_ca` gets a client that trusts only that anchor; others
|
||||||
|
/// get a default client. A cortex whose `tls_ca` failed to load is
|
||||||
|
/// **absent** here — `client_for` returns `None` and it is never
|
||||||
|
/// polled or routed to (fail closed: a misconfigured pin must not
|
||||||
|
/// silently fall back to unpinned TLS).
|
||||||
|
clients: HashMap<String, reqwest::Client>,
|
||||||
/// This router instance's region, for dispatch geo affinity (#73).
|
/// This router instance's region, for dispatch geo affinity (#73).
|
||||||
pub region: Option<String>,
|
pub region: Option<String>,
|
||||||
/// How often the poller refreshes the topology.
|
/// How often the poller refreshes the topology.
|
||||||
@@ -42,19 +48,16 @@ pub struct CortexTopology {
|
|||||||
/// load signal; #73 refines headroom). 0/0 until first health poll.
|
/// load signal; #73 refines headroom). 0/0 until first health poll.
|
||||||
pub healthy_nodes: u32,
|
pub healthy_nodes: u32,
|
||||||
pub total_nodes: u32,
|
pub total_nodes: u32,
|
||||||
/// Per-model serveability, keyed by model id, from `/v1/models`.
|
/// The cortex's full `/v1/models` entries, keyed by model id. Stored
|
||||||
pub models: HashMap<String, RouterModelStatus>,
|
/// whole (not distilled to a loaded/feasible bool) so the federation
|
||||||
|
/// catalogue (#75) can preserve per-model `limit`/`cost`/capabilities.
|
||||||
|
pub models: HashMap<String, CortexModelEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a cortex can do with one model, distilled from its `/v1/models`
|
/// Whether a cortex can serve this model — loaded now, or feasible to
|
||||||
/// entry to the two facts the router routes on.
|
/// cold-load (its catalogue × topology says some neuron can host it).
|
||||||
#[derive(Debug, Clone)]
|
pub fn entry_feasible(entry: &CortexModelEntry) -> bool {
|
||||||
pub struct RouterModelStatus {
|
entry.loaded || !entry.feasible_on.is_empty()
|
||||||
/// The model is loaded on at least one of the cortex's neurons.
|
|
||||||
pub loaded: bool,
|
|
||||||
/// The cortex can serve it — loaded now, or feasible to cold-load
|
|
||||||
/// (catalogue × topology says some neuron can host it).
|
|
||||||
pub feasible: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RouterState {
|
impl RouterState {
|
||||||
@@ -65,15 +68,42 @@ impl RouterState {
|
|||||||
.map(|c| (c.name.clone(), CortexTopology::default()))
|
.map(|c| (c.name.clone(), CortexTopology::default()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// One client per cortex. A `tls_ca` that fails to load omits the
|
||||||
|
// cortex from the map (fail closed) rather than degrading to an
|
||||||
|
// unpinned client.
|
||||||
|
let mut clients = HashMap::new();
|
||||||
|
for c in &config.cortexes {
|
||||||
|
match build_client(c.tls_ca.as_deref()) {
|
||||||
|
Ok(client) => {
|
||||||
|
clients.insert(c.name.clone(), client);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
cortex = %c.name,
|
||||||
|
tls_ca = c.tls_ca.as_deref().unwrap_or(""),
|
||||||
|
error = %e,
|
||||||
|
"failed to build pinned TLS client; cortex disabled (fail closed)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
cortexes: config.cortexes.clone(),
|
cortexes: config.cortexes.clone(),
|
||||||
http_client: reqwest::Client::new(),
|
clients,
|
||||||
region: config.router.region.clone(),
|
region: config.router.region.clone(),
|
||||||
poll_interval: Duration::from_secs(config.router.poll_interval_secs),
|
poll_interval: Duration::from_secs(config.router.poll_interval_secs),
|
||||||
topology: RwLock::new(topology),
|
topology: RwLock::new(topology),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The HTTP client to use for `name`, or `None` if the cortex is
|
||||||
|
/// disabled (its `tls_ca` failed to load). Callers must treat `None` as
|
||||||
|
/// "not routable / not pollable".
|
||||||
|
pub fn client_for(&self, name: &str) -> Option<&reqwest::Client> {
|
||||||
|
self.clients.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
/// Names of reachable cortexes that can serve `model_id` (loaded or
|
/// Names of reachable cortexes that can serve `model_id` (loaded or
|
||||||
/// feasible to cold-load). Groundwork for capacity-aware dispatch (#73);
|
/// feasible to cold-load). Groundwork for capacity-aware dispatch (#73);
|
||||||
/// unreachable cortexes are excluded by construction.
|
/// unreachable cortexes are excluded by construction.
|
||||||
@@ -81,8 +111,34 @@ impl RouterState {
|
|||||||
let topo = self.topology.read().await;
|
let topo = self.topology.read().await;
|
||||||
topo.iter()
|
topo.iter()
|
||||||
.filter(|(_, t)| t.reachable)
|
.filter(|(_, t)| t.reachable)
|
||||||
.filter(|(_, t)| t.models.get(model_id).is_some_and(|m| m.feasible))
|
.filter(|(_, t)| t.models.get(model_id).is_some_and(entry_feasible))
|
||||||
.map(|(name, _)| name.clone())
|
.map(|(name, _)| name.clone())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a cortex HTTP client. With `tls_ca` set, the client trusts **only**
|
||||||
|
/// that PEM anchor (platform roots disabled) — pinning the router→cortex hop
|
||||||
|
/// to an enrolled cert (#74). Without it, standard platform-root validation.
|
||||||
|
pub fn build_client(tls_ca: Option<&str>) -> Result<reqwest::Client, BuildClientError> {
|
||||||
|
let mut builder = reqwest::Client::builder();
|
||||||
|
if let Some(path) = tls_ca {
|
||||||
|
let pem = std::fs::read(path).map_err(|e| BuildClientError::Read(path.to_string(), e))?;
|
||||||
|
let cert = reqwest::Certificate::from_pem(&pem).map_err(BuildClientError::Parse)?;
|
||||||
|
builder = builder
|
||||||
|
.tls_built_in_root_certs(false)
|
||||||
|
.add_root_certificate(cert);
|
||||||
|
}
|
||||||
|
builder.build().map_err(BuildClientError::Build)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a cortex's pinned client could not be built (→ cortex disabled).
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BuildClientError {
|
||||||
|
#[error("reading TLS anchor '{0}'")]
|
||||||
|
Read(String, #[source] std::io::Error),
|
||||||
|
#[error("parsing TLS anchor PEM")]
|
||||||
|
Parse(#[source] reqwest::Error),
|
||||||
|
#[error("building HTTP client")]
|
||||||
|
Build(#[source] reqwest::Error),
|
||||||
|
}
|
||||||
|
|||||||
132
crates/helexa-router/tests/catalogue.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! End-to-end federation-catalogue test for #75: poll two mock cortexes
|
||||||
|
//! that overlap on a model, then `GET /v1/models` on the router and verify
|
||||||
|
//! the deduped union with merged availability and preserved limit/cost.
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::routing::get;
|
||||||
|
use helexa_router::config::{CortexEndpoint, RouterConfig};
|
||||||
|
use helexa_router::poller::poll_once;
|
||||||
|
use helexa_router::state::RouterState;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
/// Spawn a mock cortex serving the given `/v1/models` `data` array.
|
||||||
|
async fn spawn_cortex(models: Value) -> String {
|
||||||
|
let models = Arc::new(models);
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/v1/models",
|
||||||
|
get({
|
||||||
|
let models = Arc::clone(&models);
|
||||||
|
move || {
|
||||||
|
let models = Arc::clone(&models);
|
||||||
|
async move { axum::Json(json!({ "object": "list", "data": &*models })) }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health",
|
||||||
|
get(|| async { axum::Json(json!({"status":"ok","nodes":{"healthy":1,"total":1}})) }),
|
||||||
|
);
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the router (with poller) wired to the given cortex endpoints, and
|
||||||
|
/// poll once synchronously so the topology is populated before we query.
|
||||||
|
async fn spawn_router(cortexes: Vec<CortexEndpoint>) -> String {
|
||||||
|
let cfg = RouterConfig {
|
||||||
|
cortexes,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = Arc::new(RouterState::from_config(&cfg));
|
||||||
|
poll_once(&state).await; // deterministic: fill topology now
|
||||||
|
|
||||||
|
let app = helexa_router::build_app(Arc::clone(&state));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model(id: &str, loaded: bool, feasible_on: &[&str], ctx: u64, input_cost: f64) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"object": "model",
|
||||||
|
"created": 0,
|
||||||
|
"owned_by": "helexa",
|
||||||
|
"loaded": loaded,
|
||||||
|
"feasible_on": feasible_on,
|
||||||
|
"locations": [],
|
||||||
|
"limit": { "context": ctx, "output": 4096 },
|
||||||
|
"cost": { "input": input_cost, "output": input_cost * 3.0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn federation_catalogue_dedupes_and_preserves_limit_cost() {
|
||||||
|
// cortex A: "shared" loaded (ctx 32768, $0.50) + "only-a" loaded.
|
||||||
|
let a = spawn_cortex(json!([
|
||||||
|
model("shared", true, &["beast"], 32_768, 0.50),
|
||||||
|
model("only-a", true, &["beast"], 8_192, 1.00),
|
||||||
|
]))
|
||||||
|
.await;
|
||||||
|
// cortex B: "shared" cold-loadable, tighter ctx (16384), cheaper ($0.20).
|
||||||
|
let b = spawn_cortex(json!([model("shared", false, &["benjy"], 16_384, 0.20)])).await;
|
||||||
|
|
||||||
|
let router = spawn_router(vec![
|
||||||
|
CortexEndpoint {
|
||||||
|
name: "op-a".into(),
|
||||||
|
endpoint: a,
|
||||||
|
region: None,
|
||||||
|
tls_ca: None,
|
||||||
|
},
|
||||||
|
CortexEndpoint {
|
||||||
|
name: "op-b".into(),
|
||||||
|
endpoint: b,
|
||||||
|
region: None,
|
||||||
|
tls_ca: None,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let body: Value = reqwest::get(format!("{router}/v1/models"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(body["object"], "list");
|
||||||
|
let data = body["data"].as_array().unwrap();
|
||||||
|
// Deduped union: "shared" once + "only-a".
|
||||||
|
assert_eq!(data.len(), 2);
|
||||||
|
|
||||||
|
let shared = data.iter().find(|m| m["id"] == "shared").unwrap();
|
||||||
|
// Loaded somewhere (op-a) → loaded.
|
||||||
|
assert_eq!(shared["loaded"], true);
|
||||||
|
// feasible_on re-tiered to operator names, both present, sorted.
|
||||||
|
let feasible: Vec<&str> = shared["feasible_on"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(feasible, vec!["op-a", "op-b"]);
|
||||||
|
// Tightest limit (16384) and cheapest cost ($0.20) win.
|
||||||
|
assert_eq!(shared["limit"]["context"], 16_384);
|
||||||
|
assert_eq!(shared["cost"]["input"], 0.20);
|
||||||
|
// Loaded location named by operator, no neuron VRAM leaked.
|
||||||
|
let locs = shared["locations"].as_array().unwrap();
|
||||||
|
assert_eq!(locs.len(), 1);
|
||||||
|
assert_eq!(locs[0]["node"], "op-a");
|
||||||
|
|
||||||
|
assert!(data.iter().any(|m| m["id"] == "only-a"));
|
||||||
|
}
|
||||||
@@ -12,13 +12,36 @@ use axum::http::{HeaderMap, StatusCode};
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::post;
|
use axum::routing::post;
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
|
use cortex_core::node::CortexModelEntry;
|
||||||
use helexa_router::config::{CortexEndpoint, RouterConfig};
|
use helexa_router::config::{CortexEndpoint, RouterConfig};
|
||||||
use helexa_router::dispatch::{Selection, dispatch, select_cortexes};
|
use helexa_router::dispatch::{Selection, dispatch, select_cortexes};
|
||||||
use helexa_router::state::{CortexTopology, RouterModelStatus, RouterState};
|
use helexa_router::state::{CortexTopology, RouterState};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
/// A minimal `CortexModelEntry` for MODEL with the given serveability.
|
||||||
|
fn model_entry(loaded: bool, feasible: bool) -> CortexModelEntry {
|
||||||
|
CortexModelEntry {
|
||||||
|
id: MODEL.into(),
|
||||||
|
object: "model".into(),
|
||||||
|
created: 0,
|
||||||
|
owned_by: "helexa".into(),
|
||||||
|
loaded,
|
||||||
|
feasible_on: if feasible || loaded {
|
||||||
|
vec!["n".into()]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
},
|
||||||
|
locations: vec![],
|
||||||
|
capabilities: vec![],
|
||||||
|
limit: None,
|
||||||
|
cost: None,
|
||||||
|
tool_call: false,
|
||||||
|
reasoning: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MODEL: &str = "Qwen/Qwen3-Coder-30B";
|
const MODEL: &str = "Qwen/Qwen3-Coder-30B";
|
||||||
|
|
||||||
// ── Mock cortex backend ──────────────────────────────────────────────
|
// ── Mock cortex backend ──────────────────────────────────────────────
|
||||||
@@ -91,7 +114,7 @@ async fn set_topology(
|
|||||||
) {
|
) {
|
||||||
let mut topo = state.topology.write().await;
|
let mut topo = state.topology.write().await;
|
||||||
let mut models = HashMap::new();
|
let mut models = HashMap::new();
|
||||||
models.insert(MODEL.to_string(), RouterModelStatus { loaded, feasible });
|
models.insert(MODEL.to_string(), model_entry(loaded, feasible));
|
||||||
topo.insert(
|
topo.insert(
|
||||||
name.to_string(),
|
name.to_string(),
|
||||||
CortexTopology {
|
CortexTopology {
|
||||||
@@ -110,6 +133,7 @@ fn ep(name: &str, endpoint: &str, region: Option<&str>) -> CortexEndpoint {
|
|||||||
name: name.into(),
|
name: name.into(),
|
||||||
endpoint: endpoint.into(),
|
endpoint: endpoint.into(),
|
||||||
region: region.map(str::to_string),
|
region: region.map(str::to_string),
|
||||||
|
tls_ca: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ async fn health_reports_configured_cortex_count() {
|
|||||||
name: "a".into(),
|
name: "a".into(),
|
||||||
endpoint: "https://a.example.com".into(),
|
endpoint: "https://a.example.com".into(),
|
||||||
region: None,
|
region: None,
|
||||||
|
tls_ca: None,
|
||||||
},
|
},
|
||||||
CortexEndpoint {
|
CortexEndpoint {
|
||||||
name: "b".into(),
|
name: "b".into(),
|
||||||
endpoint: "https://b.example.com".into(),
|
endpoint: "https://b.example.com".into(),
|
||||||
region: None,
|
region: None,
|
||||||
|
tls_ca: None,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
210
crates/helexa-router/tests/tls.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
//! Outbound TLS pinning tests for #74.
|
||||||
|
//!
|
||||||
|
//! Proves the router, as a TLS client to cortexes, reaches a cortex
|
||||||
|
//! presenting its **enrolled** cert and rejects one presenting an
|
||||||
|
//! unexpected (or untrusted) cert — and that a rejected handshake flows
|
||||||
|
//! through the existing reachability path (#72) to exclude the cortex.
|
||||||
|
//!
|
||||||
|
//! A minimal `tokio-rustls` HTTPS server presents a self-signed cert; the
|
||||||
|
//! router's `reqwest` client (native-tls) validates against the PEM anchor
|
||||||
|
//! enrolled in config. Server (rustls) and client (native-tls) interoperate
|
||||||
|
//! at the protocol level — what matters is the trust decision.
|
||||||
|
|
||||||
|
use helexa_router::config::{CortexEndpoint, RouterConfig};
|
||||||
|
use helexa_router::poller::poll_once;
|
||||||
|
use helexa_router::state::{RouterState, build_client};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio_rustls::TlsAcceptor;
|
||||||
|
|
||||||
|
/// A self-signed cert: PEM (for the reqwest pin file) + DER cert/key (for
|
||||||
|
/// the rustls server).
|
||||||
|
struct TestCert {
|
||||||
|
cert_pem: String,
|
||||||
|
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||||
|
key_der: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_cert() -> TestCert {
|
||||||
|
let key = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).unwrap();
|
||||||
|
TestCert {
|
||||||
|
cert_pem: key.cert.pem(),
|
||||||
|
cert_der: key.cert.der().clone(),
|
||||||
|
key_der: key.key_pair.serialize_der(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a cert PEM to a unique temp file (named by `tag`) and return the
|
||||||
|
/// path. `tag` is caller-unique (we use the bound port), so no randomness.
|
||||||
|
fn write_pem(tag: &str, pem: &str) -> String {
|
||||||
|
let path = std::env::temp_dir().join(format!("helexa-router-tls-{tag}.pem"));
|
||||||
|
let mut f = std::fs::File::create(&path).unwrap();
|
||||||
|
f.write_all(pem.as_bytes()).unwrap();
|
||||||
|
path.to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a minimal HTTPS server presenting `cert`, answering every request
|
||||||
|
/// with a canned `/v1/models`-shaped 200. Returns its `https://` base URL.
|
||||||
|
async fn spawn_https(cert: &TestCert) -> String {
|
||||||
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
|
let key = rustls::pki_types::PrivateKeyDer::Pkcs8(rustls::pki_types::PrivatePkcs8KeyDer::from(
|
||||||
|
cert.key_der.clone(),
|
||||||
|
));
|
||||||
|
let config = rustls::ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(vec![cert.cert_der.clone()], key)
|
||||||
|
.unwrap();
|
||||||
|
let acceptor = TlsAcceptor::from(Arc::new(config));
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((stream, _)) = listener.accept().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let acceptor = acceptor.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(mut tls) = acceptor.accept(stream).await {
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let _ = tls.read(&mut buf).await; // consume request line/headers
|
||||||
|
let body = "{\"object\":\"list\",\"data\":[]}";
|
||||||
|
let resp = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
let _ = tls.write_all(resp.as_bytes()).await;
|
||||||
|
let _ = tls.shutdown().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("https://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_for(url: &str) -> String {
|
||||||
|
url.rsplit(':').next().unwrap_or("0").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pinned_client_accepts_enrolled_cert_and_rejects_others() {
|
||||||
|
let server_cert = make_cert();
|
||||||
|
let other_cert = make_cert();
|
||||||
|
let url = spawn_https(&server_cert).await;
|
||||||
|
let tag = tag_for(&url);
|
||||||
|
|
||||||
|
let good_pin = write_pem(&format!("{tag}-good"), &server_cert.cert_pem);
|
||||||
|
let bad_pin = write_pem(&format!("{tag}-bad"), &other_cert.cert_pem);
|
||||||
|
|
||||||
|
// Enrolled with the server's own cert → handshake trusted → 200.
|
||||||
|
let good = build_client(Some(&good_pin)).unwrap();
|
||||||
|
let resp = good.get(format!("{url}/v1/models")).send().await;
|
||||||
|
assert!(resp.is_ok(), "enrolled cert must be accepted: {resp:?}");
|
||||||
|
assert_eq!(resp.unwrap().status(), 200);
|
||||||
|
|
||||||
|
// Enrolled with a different cert → server's cert is unexpected → reject.
|
||||||
|
let bad = build_client(Some(&bad_pin)).unwrap();
|
||||||
|
assert!(
|
||||||
|
bad.get(format!("{url}/v1/models")).send().await.is_err(),
|
||||||
|
"unexpected cert must be rejected"
|
||||||
|
);
|
||||||
|
|
||||||
|
// No enrollment (default platform roots) → self-signed cert untrusted.
|
||||||
|
let default = build_client(None).unwrap();
|
||||||
|
assert!(
|
||||||
|
default
|
||||||
|
.get(format!("{url}/v1/models"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"un-enrolled self-signed cert must be rejected by default roots"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn poller_excludes_cortex_with_unexpected_cert() {
|
||||||
|
let server_cert = make_cert();
|
||||||
|
let other_cert = make_cert();
|
||||||
|
let url = spawn_https(&server_cert).await;
|
||||||
|
let tag = tag_for(&url);
|
||||||
|
|
||||||
|
let good_pin = write_pem(&format!("{tag}-pgood"), &server_cert.cert_pem);
|
||||||
|
let bad_pin = write_pem(&format!("{tag}-pbad"), &other_cert.cert_pem);
|
||||||
|
|
||||||
|
// Cortex A enrolled correctly → reachable. Cortex B enrolled with the
|
||||||
|
// wrong cert → TLS handshake fails → excluded.
|
||||||
|
let cfg = RouterConfig {
|
||||||
|
cortexes: vec![
|
||||||
|
CortexEndpoint {
|
||||||
|
name: "good".into(),
|
||||||
|
endpoint: url.clone(),
|
||||||
|
region: None,
|
||||||
|
tls_ca: Some(good_pin),
|
||||||
|
},
|
||||||
|
CortexEndpoint {
|
||||||
|
name: "bad".into(),
|
||||||
|
endpoint: url.clone(),
|
||||||
|
region: None,
|
||||||
|
tls_ca: Some(bad_pin),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = RouterState::from_config(&cfg);
|
||||||
|
poll_once(&state).await;
|
||||||
|
|
||||||
|
let topo = state.topology.read().await;
|
||||||
|
assert!(
|
||||||
|
topo["good"].reachable,
|
||||||
|
"correctly-enrolled cortex reachable"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!topo["bad"].reachable,
|
||||||
|
"cortex presenting an unexpected cert is excluded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn misconfigured_pin_disables_cortex_fail_closed() {
|
||||||
|
// A `tls_ca` pointing at a nonexistent file must NOT fall back to an
|
||||||
|
// unpinned client — the cortex is disabled entirely.
|
||||||
|
let cfg = RouterConfig {
|
||||||
|
cortexes: vec![
|
||||||
|
CortexEndpoint {
|
||||||
|
name: "broken".into(),
|
||||||
|
endpoint: "https://127.0.0.1:1".into(),
|
||||||
|
region: None,
|
||||||
|
tls_ca: Some("/no/such/anchor.pem".into()),
|
||||||
|
},
|
||||||
|
CortexEndpoint {
|
||||||
|
name: "plain".into(),
|
||||||
|
endpoint: "http://127.0.0.1:1".into(),
|
||||||
|
region: None,
|
||||||
|
tls_ca: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let state = RouterState::from_config(&cfg);
|
||||||
|
assert!(
|
||||||
|
state.client_for("broken").is_none(),
|
||||||
|
"a cortex with an unloadable pin is disabled (fail closed)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
state.client_for("plain").is_some(),
|
||||||
|
"an un-pinned cortex still gets a client"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_client_rejects_garbage_pem() {
|
||||||
|
let path = write_pem(
|
||||||
|
"garbage",
|
||||||
|
"-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----",
|
||||||
|
);
|
||||||
|
assert!(build_client(Some(&path)).is_err());
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use axum::routing::get;
|
|||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use helexa_router::config::{CortexEndpoint, RouterConfig};
|
use helexa_router::config::{CortexEndpoint, RouterConfig};
|
||||||
use helexa_router::poller::{POLL_FAILURE_THRESHOLD, poll_once};
|
use helexa_router::poller::{POLL_FAILURE_THRESHOLD, poll_once};
|
||||||
use helexa_router::state::RouterState;
|
use helexa_router::state::{RouterState, entry_feasible};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -83,6 +83,7 @@ fn state_for(name: &str, endpoint: &str) -> RouterState {
|
|||||||
name: name.into(),
|
name: name.into(),
|
||||||
endpoint: endpoint.into(),
|
endpoint: endpoint.into(),
|
||||||
region: None,
|
region: None,
|
||||||
|
tls_ca: None,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -103,11 +104,12 @@ async fn poll_builds_live_topology() {
|
|||||||
assert!(c1.last_poll.is_some());
|
assert!(c1.last_poll.is_some());
|
||||||
assert_eq!((c1.healthy_nodes, c1.total_nodes), (2, 3));
|
assert_eq!((c1.healthy_nodes, c1.total_nodes), (2, 3));
|
||||||
|
|
||||||
// Loaded model: loaded + feasible. Catalogue-only model: feasible only.
|
// Loaded model: loaded + feasible. Catalogue-only model: feasible only
|
||||||
|
// (not loaded, but feasible_on non-empty).
|
||||||
let coder = c1.models.get("Qwen/Qwen3-Coder-30B").unwrap();
|
let coder = c1.models.get("Qwen/Qwen3-Coder-30B").unwrap();
|
||||||
assert!(coder.loaded && coder.feasible);
|
assert!(coder.loaded && entry_feasible(coder));
|
||||||
let vl = c1.models.get("Qwen/Qwen3-VL-8B").unwrap();
|
let vl = c1.models.get("Qwen/Qwen3-VL-8B").unwrap();
|
||||||
assert!(!vl.loaded && vl.feasible);
|
assert!(!vl.loaded && entry_feasible(vl));
|
||||||
drop(topo);
|
drop(topo);
|
||||||
|
|
||||||
// The routing helper sees both serveable models on the reachable cortex.
|
// The routing helper sees both serveable models on the reachable cortex.
|
||||||
|
|||||||
63
crates/helexa-upstream/Cargo.toml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
[package]
|
||||||
|
name = "helexa-upstream"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "helexa-upstream"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "helexa_upstream"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
figment = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
|
# PostgreSQL — the mesh authority's system of record. Runtime query API
|
||||||
|
# (not the compile-time `query!` macros) so the crate builds in CI without a
|
||||||
|
# live database or a committed `.sqlx` offline cache; correctness is covered
|
||||||
|
# by the gated integration tests. (Macro adoption is a later refinement once
|
||||||
|
# a dev DB + offline cache exist.)
|
||||||
|
sqlx = { version = "0.8", default-features = false, features = [
|
||||||
|
"runtime-tokio",
|
||||||
|
"tls-rustls",
|
||||||
|
"postgres",
|
||||||
|
"macros",
|
||||||
|
"migrate",
|
||||||
|
"chrono",
|
||||||
|
"uuid",
|
||||||
|
] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
subtle = "2.6"
|
||||||
|
# Web auth (B4): argon2id password hashing, JWT sessions, CSPRNG secrets,
|
||||||
|
# transactional email.
|
||||||
|
argon2 = "0.5"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
rand = "0.8"
|
||||||
|
lettre = { version = "0.11", default-features = false, features = [
|
||||||
|
"tokio1-rustls-tls",
|
||||||
|
"smtp-transport",
|
||||||
|
"builder",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# cortex-core for the shared #63 OpenAiError envelope on the authz surface.
|
||||||
|
cortex-core = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
figment = { workspace = true, features = ["test"] }
|
||||||
|
reqwest = { workspace = true }
|
||||||
137
crates/helexa-upstream/migrations/0001_init.sql
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
-- helexa-upstream initial schema (#59): accounts, keys, ledger, top-up
|
||||||
|
-- codes, served-usage. The mesh-level authority's system of record.
|
||||||
|
--
|
||||||
|
-- Token amounts are BIGINT (i64) throughout; the cortex EntitlementProvider
|
||||||
|
-- carries u64 but mesh allocations sit comfortably inside i64 and Postgres
|
||||||
|
-- has no unsigned type.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
-- ── Users (web auth: email + password) ──────────────────────────────
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email CITEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL, -- argon2id PHC string
|
||||||
|
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
-- Browser fingerprint captured at registration (#abuse). Best-effort,
|
||||||
|
-- client-supplied; the primary signal for silent multi-account
|
||||||
|
-- detection. NULL when the client could not produce one.
|
||||||
|
registration_fingerprint TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX users_registration_fingerprint_idx
|
||||||
|
ON users (registration_fingerprint)
|
||||||
|
WHERE registration_fingerprint IS NOT NULL;
|
||||||
|
|
||||||
|
-- Single-use email tokens for verification and password reset. Only the
|
||||||
|
-- sha256 of the emailed secret is stored.
|
||||||
|
CREATE TABLE email_tokens (
|
||||||
|
token_hash BYTEA PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('verify', 'reset')),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX email_tokens_user_idx ON email_tokens (user_id);
|
||||||
|
|
||||||
|
-- ── Accounts (the billable allocation ledger) ───────────────────────
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
allocation_total BIGINT NOT NULL DEFAULT 0,
|
||||||
|
allocation_spent BIGINT NOT NULL DEFAULT 0,
|
||||||
|
allocation_reserved BIGINT NOT NULL DEFAULT 0,
|
||||||
|
-- 'deactivated' is the SILENT abuse flag: keys stop authorizing but no
|
||||||
|
-- surface ever tells the user why (see resolve → 401).
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'deactivated')),
|
||||||
|
-- This account shares a registration fingerprint with >= 1 other.
|
||||||
|
fingerprint_flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
-- The no-overshoot backstop to the atomic reserve UPDATE.
|
||||||
|
CONSTRAINT accounts_no_overshoot
|
||||||
|
CHECK (allocation_spent + allocation_reserved <= allocation_total),
|
||||||
|
CONSTRAINT accounts_nonneg
|
||||||
|
CHECK (allocation_spent >= 0 AND allocation_reserved >= 0)
|
||||||
|
);
|
||||||
|
CREATE INDEX accounts_owner_idx ON accounts (owner_user_id);
|
||||||
|
|
||||||
|
-- ── API keys (Principal.key_id = api_keys.id) ───────────────────────
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
key_hash BYTEA NOT NULL, -- sha256(raw key)
|
||||||
|
key_prefix TEXT NOT NULL, -- non-secret display prefix
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'archived')),
|
||||||
|
-- Per-key sub-cap: 'hardcap' = absolute tokens; 'percent' = % of the
|
||||||
|
-- account's allocation_total (resolved to an absolute at reserve time).
|
||||||
|
limit_kind TEXT NOT NULL DEFAULT 'percent'
|
||||||
|
CHECK (limit_kind IN ('percent', 'hardcap')),
|
||||||
|
limit_value BIGINT NOT NULL DEFAULT 100,
|
||||||
|
-- serde of cortex_core::entitlements::CapWindow (Balance | Rolling).
|
||||||
|
cap_window JSONB NOT NULL DEFAULT '{"kind":"balance"}'::jsonb,
|
||||||
|
-- Per-key running ledger (mirrors the account ledger; Balance semantics
|
||||||
|
-- in this migration — rolling-window reset lands with the authz API).
|
||||||
|
key_spent BIGINT NOT NULL DEFAULT 0,
|
||||||
|
key_reserved BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT api_keys_key_nonneg
|
||||||
|
CHECK (key_spent >= 0 AND key_reserved >= 0)
|
||||||
|
);
|
||||||
|
-- A raw key resolves only while active; the hash is unique among active keys.
|
||||||
|
CREATE UNIQUE INDEX api_keys_active_hash_idx
|
||||||
|
ON api_keys (key_hash) WHERE status = 'active';
|
||||||
|
CREATE INDEX api_keys_account_idx ON api_keys (account_id);
|
||||||
|
|
||||||
|
-- ── Reservations (reserve → settle/release) ─────────────────────────
|
||||||
|
-- id is BIGSERIAL so it maps to the cortex Reservation.id (u64) verbatim,
|
||||||
|
-- with the Postgres sequence as the sole global authority.
|
||||||
|
CREATE TABLE reservations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||||
|
reserved BIGINT NOT NULL,
|
||||||
|
actual BIGINT,
|
||||||
|
state TEXT NOT NULL DEFAULT 'open'
|
||||||
|
CHECK (state IN ('open', 'settled', 'released')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
settled_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
-- The sweeper scans open reservations by age.
|
||||||
|
CREATE INDEX reservations_open_idx
|
||||||
|
ON reservations (created_at) WHERE state = 'open';
|
||||||
|
|
||||||
|
-- ── Top-up codes (hybrid allocation) ────────────────────────────────
|
||||||
|
CREATE TABLE top_up_codes (
|
||||||
|
code_hash BYTEA PRIMARY KEY, -- sha256(raw code)
|
||||||
|
value BIGINT NOT NULL, -- tokens this code grants
|
||||||
|
denomination TEXT, -- human label (e.g. "small")
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
redeemed_by UUID REFERENCES accounts(id) ON DELETE SET NULL,
|
||||||
|
redeemed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Served-usage ledger (#58 reconciliation) ────────────────────────
|
||||||
|
-- Absolute per-(operator, account, key, period) served tokens, upserted by
|
||||||
|
-- each cortex; reconciliation rolls these up for operator compensation.
|
||||||
|
CREATE TABLE served_usage (
|
||||||
|
operator_id TEXT NOT NULL,
|
||||||
|
account_id UUID NOT NULL,
|
||||||
|
key_id UUID NOT NULL,
|
||||||
|
period DATE NOT NULL,
|
||||||
|
served_tokens BIGINT NOT NULL DEFAULT 0,
|
||||||
|
reconciled_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (operator_id, account_id, key_id, period)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── Web sessions (DB-backed; alt/complement to stateless JWT) ───────
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token_hash BYTEA PRIMARY KEY, -- sha256(session token)
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX sessions_user_idx ON sessions (user_id);
|
||||||
284
crates/helexa-upstream/src/authz.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
//! `/authz/v1` — the machine surface cortex's `UpstreamEntitlementProvider`
|
||||||
|
//! (#57) consumes. It mirrors the `cortex_core::entitlements::EntitlementProvider`
|
||||||
|
//! trait 1:1 (resolve / reserve / settle / release / snapshot) over the B1
|
||||||
|
//! ledger.
|
||||||
|
//!
|
||||||
|
//! Contract notes for the cortex client:
|
||||||
|
//! - A **non-2xx** response means the authority could not give an
|
||||||
|
//! authoritative answer (bad caller auth, malformed request, server
|
||||||
|
//! error) → the client should **fail closed**.
|
||||||
|
//! - `reserve` returns **200** whether granted or budget-refused: the body
|
||||||
|
//! carries either `reservation_id` or a `rejected` discriminant. A budget
|
||||||
|
//! refusal is an authoritative answer, not a transport failure.
|
||||||
|
//! - Rejections that are genuinely auth failures use the #63 `OpenAiError`
|
||||||
|
//! envelope so they can be surfaced verbatim.
|
||||||
|
|
||||||
|
use crate::crypto::sha256;
|
||||||
|
use crate::error::envelope_response;
|
||||||
|
use crate::ledger::{self, LedgerError};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::post;
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use cortex_core::error_envelope::OpenAiError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// The operator a validated client bearer identifies (served-usage
|
||||||
|
/// attribution, #58). Inserted into request extensions by [`client_auth`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OperatorId(pub String);
|
||||||
|
|
||||||
|
/// Build the `/authz/v1` router with the client-auth layer applied.
|
||||||
|
pub fn router(state: &AppState) -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/authz/v1/resolve", post(resolve))
|
||||||
|
.route("/authz/v1/reserve", post(reserve))
|
||||||
|
.route("/authz/v1/settle", post(settle))
|
||||||
|
.route("/authz/v1/release", post(release))
|
||||||
|
.route("/authz/v1/snapshot", post(snapshot))
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
client_auth,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── client auth (shared bearer → operator_id) ───────────────────────
|
||||||
|
|
||||||
|
/// Validate the caller's `Authorization: Bearer` against the configured
|
||||||
|
/// client tokens (constant-time) and stamp the `operator_id`. When no tokens
|
||||||
|
/// are configured the surface is open (dev) and a synthetic operator is
|
||||||
|
/// used.
|
||||||
|
async fn client_auth(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
|
||||||
|
let tokens = &state.config.client_auth.tokens;
|
||||||
|
if tokens.is_empty() {
|
||||||
|
req.extensions_mut().insert(OperatorId("dev".into()));
|
||||||
|
return next.run(req).await;
|
||||||
|
}
|
||||||
|
let presented = req
|
||||||
|
.headers()
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("");
|
||||||
|
let matched = tokens
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.token.as_bytes().ct_eq(presented.as_bytes()).into());
|
||||||
|
match matched {
|
||||||
|
Some(t) => {
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(OperatorId(t.operator_id.clone()));
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
None => envelope_response(OpenAiError::invalid_api_key(
|
||||||
|
"missing or invalid client credentials",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DTOs ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ResolveReq {
|
||||||
|
api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PrincipalDto {
|
||||||
|
account_id: String,
|
||||||
|
key_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SnapshotDto {
|
||||||
|
hard_cap: Option<i64>,
|
||||||
|
spent: i64,
|
||||||
|
reserved: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ResolveResp {
|
||||||
|
principal: PrincipalDto,
|
||||||
|
snapshot: SnapshotDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ReserveReq {
|
||||||
|
account_id: String,
|
||||||
|
key_id: String,
|
||||||
|
max_tokens: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Default)]
|
||||||
|
struct ReserveResp {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
reservation_id: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
rejected: Option<Rejection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
enum Rejection {
|
||||||
|
InsufficientQuota {
|
||||||
|
requested: i64,
|
||||||
|
available: i64,
|
||||||
|
},
|
||||||
|
// Part of the frozen wire contract so the cortex client (#57) can map it
|
||||||
|
// without a later breaking change. Not yet constructed: the B1 ledger
|
||||||
|
// implements Balance caps only; rolling-window key sub-caps (which yield
|
||||||
|
// this) land in a follow-up.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
RateLimited {
|
||||||
|
requested: i64,
|
||||||
|
available: i64,
|
||||||
|
retry_after_secs: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SettleReq {
|
||||||
|
reservation_id: i64,
|
||||||
|
actual_tokens: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ReservationRef {
|
||||||
|
reservation_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SnapshotReq {
|
||||||
|
account_id: String,
|
||||||
|
key_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// `POST /authz/v1/resolve` — bearer key → principal + snapshot, or
|
||||||
|
/// `401 invalid_api_key` (also for a deactivated account: no clue).
|
||||||
|
async fn resolve(State(state): State<AppState>, Json(req): Json<ResolveReq>) -> Response {
|
||||||
|
match ledger::resolve_key(&state.pool, &sha256(&req.api_key)).await {
|
||||||
|
Ok(Some(p)) => Json(ResolveResp {
|
||||||
|
principal: PrincipalDto {
|
||||||
|
account_id: p.account_id.to_string(),
|
||||||
|
key_id: p.key_id.to_string(),
|
||||||
|
},
|
||||||
|
snapshot: SnapshotDto {
|
||||||
|
hard_cap: Some(p.hard_cap),
|
||||||
|
spent: p.key_spent,
|
||||||
|
reserved: p.key_reserved,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Ok(None) => envelope_response(OpenAiError::invalid_api_key("invalid or unknown API key")),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "resolve query failed");
|
||||||
|
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /authz/v1/reserve` — 200 with `reservation_id` (granted) or
|
||||||
|
/// `rejected` (budget). Non-2xx only for bad input / server error.
|
||||||
|
async fn reserve(State(state): State<AppState>, Json(req): Json<ReserveReq>) -> Response {
|
||||||
|
let (Ok(account_id), Ok(key_id)) = (
|
||||||
|
Uuid::parse_str(&req.account_id),
|
||||||
|
Uuid::parse_str(&req.key_id),
|
||||||
|
) else {
|
||||||
|
return bad_request("account_id and key_id must be UUIDs");
|
||||||
|
};
|
||||||
|
match ledger::reserve(&state.pool, account_id, key_id, req.max_tokens).await {
|
||||||
|
Ok(reservation_id) => Json(ReserveResp {
|
||||||
|
reservation_id: Some(reservation_id),
|
||||||
|
rejected: None,
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Err(LedgerError::InsufficientQuota {
|
||||||
|
requested,
|
||||||
|
available,
|
||||||
|
}) => Json(ReserveResp {
|
||||||
|
reservation_id: None,
|
||||||
|
rejected: Some(Rejection::InsufficientQuota {
|
||||||
|
requested,
|
||||||
|
available,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Err(LedgerError::AccountNotFound | LedgerError::KeyNotFound) => {
|
||||||
|
// Resolve succeeded earlier; the principal vanished (archived /
|
||||||
|
// deactivated). Treat as no budget — fail closed at the client.
|
||||||
|
Json(ReserveResp {
|
||||||
|
reservation_id: None,
|
||||||
|
rejected: Some(Rejection::InsufficientQuota {
|
||||||
|
requested: req.max_tokens,
|
||||||
|
available: 0,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(LedgerError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "reserve failed");
|
||||||
|
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /authz/v1/settle` — idempotent; `204`.
|
||||||
|
async fn settle(State(state): State<AppState>, Json(req): Json<SettleReq>) -> Response {
|
||||||
|
match ledger::settle(&state.pool, req.reservation_id, req.actual_tokens).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "settle failed");
|
||||||
|
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /authz/v1/release` — idempotent; `204`.
|
||||||
|
async fn release(State(state): State<AppState>, Json(req): Json<ReservationRef>) -> Response {
|
||||||
|
match ledger::release(&state.pool, req.reservation_id).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "release failed");
|
||||||
|
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /authz/v1/snapshot` — `{hard_cap, spent, reserved}` or `404`.
|
||||||
|
async fn snapshot(State(state): State<AppState>, Json(req): Json<SnapshotReq>) -> Response {
|
||||||
|
let (Ok(account_id), Ok(key_id)) = (
|
||||||
|
Uuid::parse_str(&req.account_id),
|
||||||
|
Uuid::parse_str(&req.key_id),
|
||||||
|
) else {
|
||||||
|
return bad_request("account_id and key_id must be UUIDs");
|
||||||
|
};
|
||||||
|
match ledger::snapshot(&state.pool, account_id, key_id).await {
|
||||||
|
Ok(Some((hard_cap, spent, reserved))) => Json(SnapshotDto {
|
||||||
|
hard_cap: Some(hard_cap),
|
||||||
|
spent,
|
||||||
|
reserved,
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "snapshot failed");
|
||||||
|
envelope_response(OpenAiError::service_unavailable("authority error", Some(5)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_request(msg: &str) -> Response {
|
||||||
|
envelope_response(OpenAiError::new(
|
||||||
|
400,
|
||||||
|
"invalid_request_error",
|
||||||
|
"invalid_request",
|
||||||
|
msg,
|
||||||
|
))
|
||||||
|
}
|
||||||
261
crates/helexa-upstream/src/config.rs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
//! helexa-upstream configuration: loaded from `helexa-upstream.toml` with
|
||||||
|
//! figment, `UPSTREAM_`-prefixed env overrides (mirrors the cortex/router
|
||||||
|
//! convention, e.g. `UPSTREAM_SERVER__LISTEN`, `UPSTREAM_DB__URL`).
|
||||||
|
|
||||||
|
use figment::{
|
||||||
|
Figment,
|
||||||
|
providers::{Env, Format, Toml},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpstreamConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub server: ServerSettings,
|
||||||
|
pub db: DbSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub grant: GrantSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub abuse: AbuseSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_auth: ClientAuthSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub authz: AuthzSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth: AuthSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: EmailSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[auth]` — web-session signing + token lifetimes (B4). Web sessions are
|
||||||
|
/// JWTs, distinct from inference API keys.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuthSettings {
|
||||||
|
/// HMAC secret for signing session JWTs. MUST be overridden in prod
|
||||||
|
/// (env `UPSTREAM_AUTH__JWT_SECRET`); the default is dev-only.
|
||||||
|
#[serde(default = "default_jwt_secret")]
|
||||||
|
pub jwt_secret: String,
|
||||||
|
/// Session token lifetime (seconds).
|
||||||
|
#[serde(default = "default_session_ttl")]
|
||||||
|
pub session_ttl_secs: u64,
|
||||||
|
/// Email verification / password-reset token lifetime (seconds).
|
||||||
|
#[serde(default = "default_email_token_ttl")]
|
||||||
|
pub email_token_ttl_secs: u64,
|
||||||
|
/// Public base URL of the frontend, used to build verify/reset links.
|
||||||
|
#[serde(default = "default_app_base_url")]
|
||||||
|
pub app_base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
jwt_secret: default_jwt_secret(),
|
||||||
|
session_ttl_secs: default_session_ttl(),
|
||||||
|
email_token_ttl_secs: default_email_token_ttl(),
|
||||||
|
app_base_url: default_app_base_url(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[email]` — transactional email transport for verify/reset.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EmailSettings {
|
||||||
|
/// `"log"` (dev — logs the link) or `"smtp"`.
|
||||||
|
#[serde(default = "default_email_provider")]
|
||||||
|
pub provider: String,
|
||||||
|
/// SMTP relay URL (e.g. "smtp://user:pass@host:587") when provider=smtp.
|
||||||
|
#[serde(default)]
|
||||||
|
pub smtp_url: Option<String>,
|
||||||
|
/// `From:` address.
|
||||||
|
#[serde(default = "default_from_addr")]
|
||||||
|
pub from_addr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EmailSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
provider: default_email_provider(),
|
||||||
|
smtp_url: None,
|
||||||
|
from_addr: default_from_addr(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[client_auth]` — credentials operators' cortexes present to `/authz/v1`.
|
||||||
|
/// Each token maps to an `operator_id` (served-usage attribution, #58). This
|
||||||
|
/// transport credential is distinct from end-user API keys (which ride in
|
||||||
|
/// the `resolve` body). v2 adds mTLS.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ClientAuthSettings {
|
||||||
|
/// When empty the authz surface is **open** (dev only; logged at warn).
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens: Vec<ClientToken>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClientToken {
|
||||||
|
/// Shared bearer a cortex presents.
|
||||||
|
pub token: String,
|
||||||
|
/// Operator this token identifies.
|
||||||
|
pub operator_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[authz]` — reservation lifecycle knobs.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuthzSettings {
|
||||||
|
/// Open reservations older than this are swept (released), self-healing
|
||||||
|
/// a reservation whose settle/release from cortex was lost.
|
||||||
|
#[serde(default = "default_reservation_ttl")]
|
||||||
|
pub reservation_ttl_secs: u64,
|
||||||
|
/// How often the sweeper runs.
|
||||||
|
#[serde(default = "default_sweep_interval")]
|
||||||
|
pub sweep_interval_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthzSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
reservation_ttl_secs: default_reservation_ttl(),
|
||||||
|
sweep_interval_secs: default_sweep_interval(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServerSettings {
|
||||||
|
/// Address to listen on (e.g. "0.0.0.0:8090"). Plaintext — edge nginx
|
||||||
|
/// terminates TLS, consistent with the rest of the stack.
|
||||||
|
#[serde(default = "default_listen")]
|
||||||
|
pub listen: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
listen: default_listen(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DbSettings {
|
||||||
|
/// PostgreSQL connection URL (e.g. "postgres://user:pass@host/helexa").
|
||||||
|
pub url: String,
|
||||||
|
/// Max pool connections.
|
||||||
|
#[serde(default = "default_max_connections")]
|
||||||
|
pub max_connections: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[grant]` — the flat free token grant every email-verified account
|
||||||
|
/// receives (the floor of the hybrid allocation model; top-up codes extend
|
||||||
|
/// it).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GrantSettings {
|
||||||
|
#[serde(default = "default_free_grant")]
|
||||||
|
pub free_token_grant: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GrantSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
free_token_grant: default_free_grant(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[abuse]` — silent multi-account abuse detection. When at least
|
||||||
|
/// `fingerprint_account_threshold` accounts share one registration
|
||||||
|
/// fingerprint, all of them are silently deactivated (no notice to the
|
||||||
|
/// user; deactivation only surfaces as ordinary inference rejections).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AbuseSettings {
|
||||||
|
#[serde(default = "default_fingerprint_threshold")]
|
||||||
|
pub fingerprint_account_threshold: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AbuseSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
fingerprint_account_threshold: default_fingerprint_threshold(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_listen() -> String {
|
||||||
|
"0.0.0.0:8090".into()
|
||||||
|
}
|
||||||
|
fn default_max_connections() -> u32 {
|
||||||
|
16
|
||||||
|
}
|
||||||
|
fn default_free_grant() -> i64 {
|
||||||
|
1_000_000
|
||||||
|
}
|
||||||
|
fn default_fingerprint_threshold() -> i64 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
fn default_reservation_ttl() -> u64 {
|
||||||
|
120
|
||||||
|
}
|
||||||
|
fn default_sweep_interval() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
fn default_jwt_secret() -> String {
|
||||||
|
"dev-insecure-change-me".into()
|
||||||
|
}
|
||||||
|
fn default_session_ttl() -> u64 {
|
||||||
|
7 * 24 * 3600
|
||||||
|
}
|
||||||
|
fn default_email_token_ttl() -> u64 {
|
||||||
|
24 * 3600
|
||||||
|
}
|
||||||
|
fn default_app_base_url() -> String {
|
||||||
|
"http://localhost:5173".into()
|
||||||
|
}
|
||||||
|
fn default_email_provider() -> String {
|
||||||
|
"log".into()
|
||||||
|
}
|
||||||
|
fn default_from_addr() -> String {
|
||||||
|
"helexa <no-reply@helexa.ai>".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpstreamConfig {
|
||||||
|
/// Load from a TOML file with `UPSTREAM_`-prefixed env overrides
|
||||||
|
/// (`__` nesting separator).
|
||||||
|
pub fn load(path: impl AsRef<Path>) -> Result<Self, Box<figment::Error>> {
|
||||||
|
Figment::new()
|
||||||
|
.merge(Toml::file(path))
|
||||||
|
.merge(Env::prefixed("UPSTREAM_").split("__"))
|
||||||
|
.extract()
|
||||||
|
.map_err(Box::new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
fn loads_toml_with_env_override_and_defaults() {
|
||||||
|
figment::Jail::expect_with(|jail| {
|
||||||
|
jail.create_file(
|
||||||
|
"helexa-upstream.toml",
|
||||||
|
r#"
|
||||||
|
[db]
|
||||||
|
url = "postgres://localhost/helexa"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
jail.set_env("UPSTREAM_SERVER__LISTEN", "127.0.0.1:9099");
|
||||||
|
|
||||||
|
let cfg = UpstreamConfig::load("helexa-upstream.toml").expect("load");
|
||||||
|
assert_eq!(cfg.server.listen, "127.0.0.1:9099");
|
||||||
|
assert_eq!(cfg.db.url, "postgres://localhost/helexa");
|
||||||
|
// Defaults applied when sections omitted.
|
||||||
|
assert_eq!(cfg.grant.free_token_grant, 1_000_000);
|
||||||
|
assert_eq!(cfg.abuse.fingerprint_account_threshold, 5);
|
||||||
|
assert_eq!(cfg.db.max_connections, 16);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
113
crates/helexa-upstream/src/crypto.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
//! Hashing + secret-generation helpers.
|
||||||
|
//!
|
||||||
|
//! - **Passwords** (low-entropy) → argon2id PHC strings.
|
||||||
|
//! - **API keys / top-up codes / email + session tokens** (high-entropy
|
||||||
|
//! secrets minted here) → stored only as their sha256; sha256 is the fast,
|
||||||
|
//! sufficient choice for high-entropy material.
|
||||||
|
|
||||||
|
use argon2::Argon2;
|
||||||
|
use argon2::password_hash::rand_core::OsRng as ArgonOsRng;
|
||||||
|
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// sha256 of `input`, as raw bytes (matches the `BYTEA` columns).
|
||||||
|
pub fn sha256(input: &str) -> Vec<u8> {
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update(input.as_bytes());
|
||||||
|
h.finalize().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a password with argon2id, returning a PHC string for storage.
|
||||||
|
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||||
|
let salt = SaltString::generate(&mut ArgonOsRng);
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)?
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a password against a stored PHC hash. `false` on any mismatch or
|
||||||
|
/// malformed hash (never panics).
|
||||||
|
pub fn verify_password(password: &str, phc: &str) -> bool {
|
||||||
|
match PasswordHash::new(phc) {
|
||||||
|
Ok(parsed) => Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.is_ok(),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh URL-safe high-entropy secret (256 bits) for email/session/reset
|
||||||
|
/// tokens. The caller stores only `sha256` of this and emails/returns the
|
||||||
|
/// raw value.
|
||||||
|
pub fn random_token() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||||
|
base62(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a new API key: `(raw, prefix)`. `raw` is shown to the user once;
|
||||||
|
/// only `sha256(raw)` is stored. The prefix is a non-secret display tag.
|
||||||
|
pub fn generate_api_key() -> (String, String) {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||||
|
let raw = format!("sk-helexa-{}", base62(&bytes));
|
||||||
|
// Non-secret prefix for the dashboard list (scheme + first few chars).
|
||||||
|
let prefix: String = raw.chars().take(14).collect();
|
||||||
|
(raw, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// base62 encode (0-9A-Za-z) — URL/clipboard friendly, no padding.
|
||||||
|
fn base62(bytes: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||||
|
// Treat the bytes as a big-endian integer and base62 it. 32 bytes → ~43
|
||||||
|
// chars. Simple repeated-division over a big-uint built from the bytes.
|
||||||
|
let mut digits: Vec<u8> = vec![0];
|
||||||
|
for &byte in bytes {
|
||||||
|
let mut carry = byte as u32;
|
||||||
|
for d in digits.iter_mut() {
|
||||||
|
let v = (*d as u32) * 256 + carry;
|
||||||
|
*d = (v % 62) as u8;
|
||||||
|
carry = v / 62;
|
||||||
|
}
|
||||||
|
while carry > 0 {
|
||||||
|
digits.push((carry % 62) as u8);
|
||||||
|
carry /= 62;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digits
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.map(|&d| ALPHABET[d as usize] as char)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_round_trips_and_rejects_wrong() {
|
||||||
|
let phc = hash_password("correct horse").unwrap();
|
||||||
|
assert!(verify_password("correct horse", &phc));
|
||||||
|
assert!(!verify_password("wrong", &phc));
|
||||||
|
assert!(!verify_password("correct horse", "not-a-phc-string"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_key_has_scheme_prefix_and_unique_body() {
|
||||||
|
let (raw, prefix) = generate_api_key();
|
||||||
|
assert!(raw.starts_with("sk-helexa-"));
|
||||||
|
assert!(prefix.starts_with("sk-helexa-"));
|
||||||
|
let (raw2, _) = generate_api_key();
|
||||||
|
assert_ne!(raw, raw2, "keys are unique");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_tokens_are_unique_and_nonempty() {
|
||||||
|
let a = random_token();
|
||||||
|
let b = random_token();
|
||||||
|
assert!(!a.is_empty());
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
crates/helexa-upstream/src/db.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//! PostgreSQL pool + embedded migrations.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
|
|
||||||
|
/// Connect to Postgres and run embedded migrations (`./migrations`).
|
||||||
|
pub async fn connect_and_migrate(url: &str, max_connections: u32) -> Result<PgPool> {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(max_connections)
|
||||||
|
.connect(url)
|
||||||
|
.await
|
||||||
|
.with_context(|| "connecting to PostgreSQL")?;
|
||||||
|
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.with_context(|| "running migrations")?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
64
crates/helexa-upstream/src/email.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//! Transactional email for verification + password-reset links.
|
||||||
|
//!
|
||||||
|
//! Two transports: `Log` (dev — writes the link to the log so flows are
|
||||||
|
//! testable without a relay) and `Smtp` (lettre over rustls). Built from
|
||||||
|
//! `[email]` config.
|
||||||
|
|
||||||
|
use crate::config::EmailSettings;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use lettre::message::Mailbox;
|
||||||
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum EmailSender {
|
||||||
|
/// Dev: log the message instead of sending.
|
||||||
|
Log { from: String },
|
||||||
|
Smtp {
|
||||||
|
from: String,
|
||||||
|
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailSender {
|
||||||
|
pub fn from_config(cfg: &EmailSettings) -> Result<Self> {
|
||||||
|
match cfg.provider.as_str() {
|
||||||
|
"smtp" => {
|
||||||
|
let url = cfg
|
||||||
|
.smtp_url
|
||||||
|
.as_deref()
|
||||||
|
.context("[email].smtp_url required when provider = \"smtp\"")?;
|
||||||
|
let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
|
||||||
|
.context("parsing [email].smtp_url")?
|
||||||
|
.build();
|
||||||
|
Ok(EmailSender::Smtp {
|
||||||
|
from: cfg.from_addr.clone(),
|
||||||
|
transport,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Ok(EmailSender::Log {
|
||||||
|
from: cfg.from_addr.clone(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a plaintext email. Errors are returned but the caller treats
|
||||||
|
/// send failures as non-fatal to the request (the user can re-request).
|
||||||
|
pub async fn send(&self, to: &str, subject: &str, body: &str) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
EmailSender::Log { from } => {
|
||||||
|
tracing::info!(%from, %to, %subject, body, "EMAIL (log transport)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
EmailSender::Smtp { from, transport } => {
|
||||||
|
let msg = Message::builder()
|
||||||
|
.from(from.parse::<Mailbox>().context("parsing from_addr")?)
|
||||||
|
.to(to.parse::<Mailbox>().context("parsing recipient")?)
|
||||||
|
.subject(subject)
|
||||||
|
.body(body.to_string())
|
||||||
|
.context("building message")?;
|
||||||
|
transport.send(msg).await.context("sending email")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/helexa-upstream/src/error.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//! Adapter from the shared, axum-agnostic
|
||||||
|
//! [`cortex_core::error_envelope::OpenAiError`] (#60/#63) to an axum
|
||||||
|
//! response, with `Retry-After`. The `/authz/v1` surface speaks the #63
|
||||||
|
//! envelope so cortex (an OpenAI-compatible proxy) can forward rejections
|
||||||
|
//! verbatim. (The future `/web/v1` surface uses a plain JSON error shape.)
|
||||||
|
|
||||||
|
use axum::http::{HeaderValue, StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use cortex_core::error_envelope::OpenAiError;
|
||||||
|
|
||||||
|
pub fn envelope_response(err: OpenAiError) -> Response {
|
||||||
|
let status = StatusCode::from_u16(err.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
let retry_after = err.retry_after_secs;
|
||||||
|
let mut response = (status, Json(err.body())).into_response();
|
||||||
|
if let Some(secs) = retry_after
|
||||||
|
&& let Ok(value) = HeaderValue::from_str(&secs.to_string())
|
||||||
|
{
|
||||||
|
response.headers_mut().insert(header::RETRY_AFTER, value);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
21
crates/helexa-upstream/src/handlers.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//! HTTP handlers. B1 ships `/health`; the authz (`/authz/v1`) and web
|
||||||
|
//! (`/web/v1`) surfaces land in later phases.
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/", get(health))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /health` — liveness + a database round-trip (`SELECT 1`).
|
||||||
|
async fn health(State(state): State<AppState>) -> Json<Value> {
|
||||||
|
let db_ok = sqlx::query("SELECT 1").execute(&state.pool).await.is_ok();
|
||||||
|
Json(json!({
|
||||||
|
"status": if db_ok { "ok" } else { "degraded" },
|
||||||
|
"db": if db_ok { "ok" } else { "unreachable" },
|
||||||
|
}))
|
||||||
|
}
|
||||||
338
crates/helexa-upstream/src/ledger.rs
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
//! The allocation ledger: reserve → settle/release with the no-overshoot
|
||||||
|
//! guarantee enforced by a row-locked transaction.
|
||||||
|
//!
|
||||||
|
//! Each reserve takes `SELECT … FOR UPDATE` on the account (and key) row, so
|
||||||
|
//! concurrent reserves from many cortexes serialize and `spent + reserved`
|
||||||
|
//! can never exceed the effective cap. The `accounts_no_overshoot` CHECK is
|
||||||
|
//! the DB-level backstop. Settle/release are idempotent (they only act on a
|
||||||
|
//! reservation still in `open`).
|
||||||
|
//!
|
||||||
|
//! Per-key effective cap = `min(resolved key cap, remaining account
|
||||||
|
//! allocation)`. The key cap is resolved from its `limit_kind`:
|
||||||
|
//! `hardcap` → the value verbatim; `percent` → that % of the account's
|
||||||
|
//! `allocation_total`.
|
||||||
|
//!
|
||||||
|
//! Cap-window semantics: this module implements **Balance** (non-resetting)
|
||||||
|
//! caps. Rolling-window key sub-caps (and the `RateLimited` rejection that
|
||||||
|
//! rides them) land with the authz API (B2); today an over-cap is always
|
||||||
|
//! `InsufficientQuota`.
|
||||||
|
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A bearer key resolved to its principal + a budget snapshot.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResolvedPrincipal {
|
||||||
|
pub account_id: Uuid,
|
||||||
|
pub key_id: Uuid,
|
||||||
|
/// Effective per-key absolute cap (the key sub-cap; the account cap
|
||||||
|
/// still binds at reserve time).
|
||||||
|
pub hard_cap: i64,
|
||||||
|
pub key_spent: i64,
|
||||||
|
pub key_reserved: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a key by its `sha256` hash to its principal, or `None` when the
|
||||||
|
/// key is unknown/archived **or its account is deactivated** (the silent
|
||||||
|
/// abuse flag — indistinguishable from an unknown key, by design: no clue).
|
||||||
|
pub async fn resolve_key(
|
||||||
|
pool: &PgPool,
|
||||||
|
key_hash: &[u8],
|
||||||
|
) -> Result<Option<ResolvedPrincipal>, sqlx::Error> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT k.id AS key_id, k.account_id, k.limit_kind, k.limit_value, \
|
||||||
|
k.key_spent, k.key_reserved, a.allocation_total \
|
||||||
|
FROM api_keys k JOIN accounts a ON a.id = k.account_id \
|
||||||
|
WHERE k.key_hash = $1 AND k.status = 'active' AND a.status = 'active'",
|
||||||
|
)
|
||||||
|
.bind(key_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|r| {
|
||||||
|
let total: i64 = sqlx::Row::get(&r, "allocation_total");
|
||||||
|
let limit_kind: String = sqlx::Row::get(&r, "limit_kind");
|
||||||
|
let limit_value: i64 = sqlx::Row::get(&r, "limit_value");
|
||||||
|
ResolvedPrincipal {
|
||||||
|
account_id: sqlx::Row::get(&r, "account_id"),
|
||||||
|
key_id: sqlx::Row::get(&r, "key_id"),
|
||||||
|
hard_cap: resolve_abs_cap(&limit_kind, limit_value, total),
|
||||||
|
key_spent: sqlx::Row::get(&r, "key_spent"),
|
||||||
|
key_reserved: sqlx::Row::get(&r, "key_reserved"),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-key budget snapshot `(hard_cap, spent, reserved)`, or `None` if the
|
||||||
|
/// key/account isn't an active pair.
|
||||||
|
pub async fn snapshot(
|
||||||
|
pool: &PgPool,
|
||||||
|
account_id: Uuid,
|
||||||
|
key_id: Uuid,
|
||||||
|
) -> Result<Option<(i64, i64, i64)>, sqlx::Error> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT k.limit_kind, k.limit_value, k.key_spent, k.key_reserved, a.allocation_total \
|
||||||
|
FROM api_keys k JOIN accounts a ON a.id = k.account_id \
|
||||||
|
WHERE k.id = $1 AND k.account_id = $2 AND k.status = 'active' AND a.status = 'active'",
|
||||||
|
)
|
||||||
|
.bind(key_id)
|
||||||
|
.bind(account_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|r| {
|
||||||
|
let total: i64 = sqlx::Row::get(&r, "allocation_total");
|
||||||
|
let limit_kind: String = sqlx::Row::get(&r, "limit_kind");
|
||||||
|
let limit_value: i64 = sqlx::Row::get(&r, "limit_value");
|
||||||
|
let cap = resolve_abs_cap(&limit_kind, limit_value, total);
|
||||||
|
(
|
||||||
|
cap,
|
||||||
|
sqlx::Row::get::<i64, _>(&r, "key_spent"),
|
||||||
|
sqlx::Row::get::<i64, _>(&r, "key_reserved"),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release every `open` reservation older than `max_age_secs`, returning
|
||||||
|
/// each one's reserved tokens to its account and key in a single statement.
|
||||||
|
/// The lost-settle self-heal. Returns the number swept.
|
||||||
|
pub async fn sweep_stale(pool: &PgPool, max_age_secs: i64) -> Result<u64, sqlx::Error> {
|
||||||
|
// Data-modifying CTEs: release stale rows, then fold their reserved sums
|
||||||
|
// back into accounts and api_keys. All in one atomic statement.
|
||||||
|
let result = sqlx::query(
|
||||||
|
"WITH stale AS ( \
|
||||||
|
UPDATE reservations SET state = 'released', settled_at = now() \
|
||||||
|
WHERE state = 'open' AND created_at < now() - make_interval(secs => $1) \
|
||||||
|
RETURNING account_id, key_id, reserved \
|
||||||
|
), acct AS ( \
|
||||||
|
UPDATE accounts a SET allocation_reserved = allocation_reserved - s.total \
|
||||||
|
FROM (SELECT account_id, SUM(reserved) AS total FROM stale GROUP BY account_id) s \
|
||||||
|
WHERE a.id = s.account_id \
|
||||||
|
) \
|
||||||
|
UPDATE api_keys k SET key_reserved = key_reserved - s.total \
|
||||||
|
FROM (SELECT key_id, SUM(reserved) AS total FROM stale GROUP BY key_id) s \
|
||||||
|
WHERE k.id = s.key_id",
|
||||||
|
)
|
||||||
|
.bind(max_age_secs as f64)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a key's per-key cap to an absolute token count.
|
||||||
|
///
|
||||||
|
/// `percent` is `floor(allocation_total * limit_value / 100)`; `hardcap` is
|
||||||
|
/// `limit_value` verbatim. Computed in i128 to avoid overflow, floored at 0.
|
||||||
|
pub fn resolve_abs_cap(limit_kind: &str, limit_value: i64, allocation_total: i64) -> i64 {
|
||||||
|
let cap = match limit_kind {
|
||||||
|
"percent" => (allocation_total as i128 * limit_value as i128) / 100,
|
||||||
|
_ => limit_value as i128, // "hardcap" (and any unknown → treat as absolute)
|
||||||
|
};
|
||||||
|
cap.clamp(0, i64::MAX as i128) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum LedgerError {
|
||||||
|
#[error("account not found")]
|
||||||
|
AccountNotFound,
|
||||||
|
#[error("api key not found or not active")]
|
||||||
|
KeyNotFound,
|
||||||
|
/// Account balance or a Balance-window key sub-cap is exhausted.
|
||||||
|
#[error("insufficient quota: requested {requested}, available {available}")]
|
||||||
|
InsufficientQuota { requested: i64, available: i64 },
|
||||||
|
#[error(transparent)]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserve `max_tokens` against `account_id`/`key_id`. Returns the
|
||||||
|
/// reservation id (the `BIGSERIAL`, mapped to the cortex `Reservation.id`).
|
||||||
|
pub async fn reserve(
|
||||||
|
pool: &PgPool,
|
||||||
|
account_id: Uuid,
|
||||||
|
key_id: Uuid,
|
||||||
|
max_tokens: i64,
|
||||||
|
) -> Result<i64, LedgerError> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
// Lock the account row — serializes concurrent reserves on this account.
|
||||||
|
let acct = sqlx::query(
|
||||||
|
"SELECT allocation_total, allocation_spent, allocation_reserved \
|
||||||
|
FROM accounts WHERE id = $1 AND status = 'active' FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some(acct) = acct else {
|
||||||
|
return Err(LedgerError::AccountNotFound);
|
||||||
|
};
|
||||||
|
let total: i64 = sqlx::Row::get(&acct, "allocation_total");
|
||||||
|
let spent: i64 = sqlx::Row::get(&acct, "allocation_spent");
|
||||||
|
let reserved: i64 = sqlx::Row::get(&acct, "allocation_reserved");
|
||||||
|
let account_avail = total - spent - reserved;
|
||||||
|
|
||||||
|
// Lock the key row and resolve its absolute sub-cap.
|
||||||
|
let key = sqlx::query(
|
||||||
|
"SELECT limit_kind, limit_value, key_spent, key_reserved \
|
||||||
|
FROM api_keys WHERE id = $1 AND account_id = $2 AND status = 'active' FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(key_id)
|
||||||
|
.bind(account_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some(key) = key else {
|
||||||
|
return Err(LedgerError::KeyNotFound);
|
||||||
|
};
|
||||||
|
let limit_kind: String = sqlx::Row::get(&key, "limit_kind");
|
||||||
|
let limit_value: i64 = sqlx::Row::get(&key, "limit_value");
|
||||||
|
let key_spent: i64 = sqlx::Row::get(&key, "key_spent");
|
||||||
|
let key_reserved: i64 = sqlx::Row::get(&key, "key_reserved");
|
||||||
|
let key_cap = resolve_abs_cap(&limit_kind, limit_value, total);
|
||||||
|
let key_avail = key_cap - key_spent - key_reserved;
|
||||||
|
|
||||||
|
let available = account_avail.min(key_avail).max(0);
|
||||||
|
if max_tokens > available {
|
||||||
|
// tx rolls back on drop
|
||||||
|
return Err(LedgerError::InsufficientQuota {
|
||||||
|
requested: max_tokens,
|
||||||
|
available,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: i64 = sqlx::Row::get(
|
||||||
|
&sqlx::query(
|
||||||
|
"INSERT INTO reservations (account_id, key_id, reserved, state) \
|
||||||
|
VALUES ($1, $2, $3, 'open') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(key_id)
|
||||||
|
.bind(max_tokens)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?,
|
||||||
|
"id",
|
||||||
|
);
|
||||||
|
sqlx::query("UPDATE accounts SET allocation_reserved = allocation_reserved + $1 WHERE id = $2")
|
||||||
|
.bind(max_tokens)
|
||||||
|
.bind(account_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE api_keys SET key_reserved = key_reserved + $1 WHERE id = $2")
|
||||||
|
.bind(max_tokens)
|
||||||
|
.bind(key_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settle a reservation with the actual tokens used (clamped to
|
||||||
|
/// `[0, reserved]`). Idempotent: a second settle (or settle after release)
|
||||||
|
/// is a no-op.
|
||||||
|
pub async fn settle(
|
||||||
|
pool: &PgPool,
|
||||||
|
reservation_id: i64,
|
||||||
|
actual_tokens: i64,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let row = sqlx::query(
|
||||||
|
"UPDATE reservations SET state = 'settled', settled_at = now(), \
|
||||||
|
actual = LEAST(GREATEST($2, 0), reserved) \
|
||||||
|
WHERE id = $1 AND state = 'open' \
|
||||||
|
RETURNING reserved, account_id, key_id, actual",
|
||||||
|
)
|
||||||
|
.bind(reservation_id)
|
||||||
|
.bind(actual_tokens)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Ok(()); // already settled/released, or unknown → idempotent no-op
|
||||||
|
};
|
||||||
|
let reserved: i64 = sqlx::Row::get(&row, "reserved");
|
||||||
|
let actual: i64 = sqlx::Row::get(&row, "actual");
|
||||||
|
let account_id: Uuid = sqlx::Row::get(&row, "account_id");
|
||||||
|
let key_id: Uuid = sqlx::Row::get(&row, "key_id");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE accounts SET allocation_reserved = allocation_reserved - $1, \
|
||||||
|
allocation_spent = allocation_spent + $2 WHERE id = $3",
|
||||||
|
)
|
||||||
|
.bind(reserved)
|
||||||
|
.bind(actual)
|
||||||
|
.bind(account_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE api_keys SET key_reserved = key_reserved - $1, key_spent = key_spent + $2 WHERE id = $3",
|
||||||
|
)
|
||||||
|
.bind(reserved)
|
||||||
|
.bind(actual)
|
||||||
|
.bind(key_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release a reservation, returning its full reserved amount to the
|
||||||
|
/// allocation. Idempotent.
|
||||||
|
pub async fn release(pool: &PgPool, reservation_id: i64) -> Result<(), LedgerError> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let row = sqlx::query(
|
||||||
|
"UPDATE reservations SET state = 'released', settled_at = now() \
|
||||||
|
WHERE id = $1 AND state = 'open' \
|
||||||
|
RETURNING reserved, account_id, key_id",
|
||||||
|
)
|
||||||
|
.bind(reservation_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let reserved: i64 = sqlx::Row::get(&row, "reserved");
|
||||||
|
let account_id: Uuid = sqlx::Row::get(&row, "account_id");
|
||||||
|
let key_id: Uuid = sqlx::Row::get(&row, "key_id");
|
||||||
|
|
||||||
|
sqlx::query("UPDATE accounts SET allocation_reserved = allocation_reserved - $1 WHERE id = $2")
|
||||||
|
.bind(reserved)
|
||||||
|
.bind(account_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE api_keys SET key_reserved = key_reserved - $1 WHERE id = $2")
|
||||||
|
.bind(reserved)
|
||||||
|
.bind(key_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::resolve_abs_cap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hardcap_is_verbatim() {
|
||||||
|
assert_eq!(resolve_abs_cap("hardcap", 50_000, 1_000_000), 50_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percent_is_fraction_of_allocation() {
|
||||||
|
assert_eq!(resolve_abs_cap("percent", 25, 1_000_000), 250_000);
|
||||||
|
assert_eq!(resolve_abs_cap("percent", 100, 1_000_000), 1_000_000);
|
||||||
|
// floor
|
||||||
|
assert_eq!(resolve_abs_cap("percent", 33, 10), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percent_does_not_overflow_on_large_allocation() {
|
||||||
|
// total * value would overflow i64 if not widened to i128.
|
||||||
|
let cap = resolve_abs_cap("percent", 100, i64::MAX);
|
||||||
|
assert_eq!(cap, i64::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_or_zero_clamps_to_zero() {
|
||||||
|
assert_eq!(resolve_abs_cap("hardcap", -5, 100), 0);
|
||||||
|
assert_eq!(resolve_abs_cap("percent", 0, 1_000_000), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
crates/helexa-upstream/src/lib.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! helexa-upstream — the mesh-level account/authorization authority (#59).
|
||||||
|
//!
|
||||||
|
//! The clearing house above cortex: it issues accounts and API keys, holds
|
||||||
|
//! the real token-allocation ledger, authorizes inference in real time
|
||||||
|
//! (reserve → settle, fail-closed), and tracks served usage for operator
|
||||||
|
//! reconciliation. cortex's `UpstreamEntitlementProvider` (#57) is a client
|
||||||
|
//! of the `/authz/v1` surface; the helexa.ai frontend is a client of the
|
||||||
|
//! `/web/v1` surface.
|
||||||
|
//!
|
||||||
|
//! Landed so far: B1 — schema + reserve→settle [`ledger`] (no-overshoot) +
|
||||||
|
//! `/health`. B2 — the `/authz/v1` [`authz`] surface (resolve/reserve/
|
||||||
|
//! settle/release/snapshot) with shared-bearer client auth and a
|
||||||
|
//! stale-reservation sweeper.
|
||||||
|
|
||||||
|
pub mod authz;
|
||||||
|
pub mod config;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod db;
|
||||||
|
pub mod email;
|
||||||
|
pub mod error;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod ledger;
|
||||||
|
pub mod state;
|
||||||
|
pub mod web;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use config::UpstreamConfig;
|
||||||
|
use email::EmailSender;
|
||||||
|
use state::AppState;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
/// Build the axum application.
|
||||||
|
pub fn build_app(state: AppState) -> axum::Router {
|
||||||
|
axum::Router::new()
|
||||||
|
.merge(handlers::routes())
|
||||||
|
.merge(authz::router(&state))
|
||||||
|
.merge(web::router(&state))
|
||||||
|
// The /web/v1 surface is called cross-origin by the browser SPA in
|
||||||
|
// dev; same-origin behind nginx in prod. Permissive is fine — these
|
||||||
|
// endpoints authenticate via bearer/JWT, not cookies.
|
||||||
|
.layer(CorsLayer::permissive())
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the service: connect Postgres, run migrations, spawn the
|
||||||
|
/// reservation sweeper, bind the listener.
|
||||||
|
pub async fn run(config: UpstreamConfig) -> Result<()> {
|
||||||
|
let pool = db::connect_and_migrate(&config.db.url, config.db.max_connections).await?;
|
||||||
|
let email = EmailSender::from_config(&config.email)?;
|
||||||
|
let listen = config.server.listen.clone();
|
||||||
|
let state = AppState::new(pool, config, email);
|
||||||
|
|
||||||
|
if state.config.client_auth.tokens.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"no [client_auth] tokens configured — the /authz/v1 surface is OPEN (dev only)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale-reservation sweeper: releases open reservations whose
|
||||||
|
// settle/release from cortex was lost, self-healing allocation_reserved.
|
||||||
|
spawn_sweeper(&state);
|
||||||
|
|
||||||
|
let addr = listen.parse::<std::net::SocketAddr>()?;
|
||||||
|
tracing::info!("helexa-upstream listening on {addr}");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, build_app(state)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_sweeper(state: &AppState) {
|
||||||
|
let pool = state.pool.clone();
|
||||||
|
let ttl = state.config.authz.reservation_ttl_secs as i64;
|
||||||
|
let interval = Duration::from_secs(state.config.authz.sweep_interval_secs);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
match ledger::sweep_stale(&pool, ttl).await {
|
||||||
|
Ok(n) if n > 0 => tracing::info!(swept = n, "released stale reservations"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "reservation sweep failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
46
crates/helexa-upstream/src/main.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use helexa_upstream::config::UpstreamConfig;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "helexa-upstream")]
|
||||||
|
#[command(about = "Mesh-level account & authorization authority for helexa")]
|
||||||
|
#[command(version)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Start the upstream server.
|
||||||
|
Serve {
|
||||||
|
/// Path to the config file.
|
||||||
|
#[arg(short, long, default_value = "helexa-upstream.toml")]
|
||||||
|
config: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("info,helexa_upstream=debug")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Serve { config } => {
|
||||||
|
let cfg = UpstreamConfig::load(&config)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
|
||||||
|
tracing::info!(listen = %cfg.server.listen, "starting helexa-upstream");
|
||||||
|
helexa_upstream::run(cfg).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
23
crates/helexa-upstream/src/state.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//! Shared application state.
|
||||||
|
|
||||||
|
use crate::config::UpstreamConfig;
|
||||||
|
use crate::email::EmailSender;
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: PgPool,
|
||||||
|
pub config: Arc<UpstreamConfig>,
|
||||||
|
pub email: EmailSender,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(pool: PgPool, config: UpstreamConfig, email: EmailSender) -> Self {
|
||||||
|
Self {
|
||||||
|
pool,
|
||||||
|
config: Arc::new(config),
|
||||||
|
email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
567
crates/helexa-upstream/src/web.rs
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
//! `/web/v1` — the human-facing account API the helexa.ai frontend (#F4)
|
||||||
|
//! consumes: email+password auth (register / verify / login / reset),
|
||||||
|
//! API-key CRUD with per-key limits, and the account balance. Web sessions
|
||||||
|
//! are JWTs, **distinct** from inference API keys.
|
||||||
|
//!
|
||||||
|
//! Errors use a plain JSON shape `{ "error": { "message", "code" } }` (web
|
||||||
|
//! clients, not OpenAI clients — the #63 envelope is the authz surface).
|
||||||
|
//!
|
||||||
|
//! Silent fingerprint abuse (no clue to the abuser): registration captures
|
||||||
|
//! the browser fingerprint and always succeeds; when ≥ threshold accounts
|
||||||
|
//! share one fingerprint, all are silently `deactivated` (keys then resolve
|
||||||
|
//! as ordinary `401`s at the authz surface — never a "banned" signal).
|
||||||
|
|
||||||
|
use crate::crypto::{generate_api_key, hash_password, random_token, sha256, verify_password};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::extract::{Path, Request, State};
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub fn router(state: &AppState) -> Router<AppState> {
|
||||||
|
let protected = Router::new()
|
||||||
|
.route("/web/v1/account", get(account))
|
||||||
|
.route("/web/v1/keys", get(list_keys).post(create_key))
|
||||||
|
.route("/web/v1/keys/{id}/archive", post(archive_key))
|
||||||
|
.route(
|
||||||
|
"/web/v1/keys/{id}/limit",
|
||||||
|
axum::routing::patch(update_key_limit),
|
||||||
|
)
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_session,
|
||||||
|
));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/web/v1/register", post(register))
|
||||||
|
.route("/web/v1/verify", post(verify))
|
||||||
|
.route("/web/v1/login", post(login))
|
||||||
|
.route("/web/v1/password-reset/request", post(reset_request))
|
||||||
|
.route("/web/v1/password-reset/confirm", post(reset_confirm))
|
||||||
|
.merge(protected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── errors ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum WebError {
|
||||||
|
BadRequest(&'static str),
|
||||||
|
Unauthorized,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for WebError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, code, message) = match self {
|
||||||
|
WebError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m),
|
||||||
|
WebError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", "unauthorized"),
|
||||||
|
WebError::Internal => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal_error",
|
||||||
|
"internal error",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
Json(json!({"error": {"message": message, "code": code}})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for WebError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
tracing::error!(error = %e, "web db error");
|
||||||
|
WebError::Internal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebResult<T> = Result<T, WebError>;
|
||||||
|
|
||||||
|
// ── sessions (JWT) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String, // user id
|
||||||
|
exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mint_session(state: &AppState, user_id: Uuid) -> WebResult<String> {
|
||||||
|
let exp = (Utc::now() + Duration::seconds(state.config.auth.session_ttl_secs as i64))
|
||||||
|
.timestamp() as usize;
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|_| WebError::Internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticated user id, injected by [`require_session`].
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AuthUser(Uuid);
|
||||||
|
|
||||||
|
async fn require_session(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
|
||||||
|
let token = req
|
||||||
|
.headers()
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(str::trim);
|
||||||
|
let Some(token) = token else {
|
||||||
|
return WebError::Unauthorized.into_response();
|
||||||
|
};
|
||||||
|
let decoded = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
);
|
||||||
|
match decoded
|
||||||
|
.ok()
|
||||||
|
.and_then(|d| Uuid::parse_str(&d.claims.sub).ok())
|
||||||
|
{
|
||||||
|
Some(uid) => {
|
||||||
|
req.extensions_mut().insert(AuthUser(uid));
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
None => WebError::Unauthorized.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caller's single account id.
|
||||||
|
async fn account_id_for(state: &AppState, user_id: Uuid) -> WebResult<Uuid> {
|
||||||
|
let row = sqlx::query("SELECT id FROM accounts WHERE owner_user_id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(|r| r.get::<Uuid, _>("id"))
|
||||||
|
.ok_or(WebError::Internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── auth lifecycle ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RegisterReq {
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
fingerprint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /web/v1/register` — always returns `202`, regardless of whether the
|
||||||
|
/// email was new, already taken, or fingerprint-flagged (no enumeration, no
|
||||||
|
/// abuse clue).
|
||||||
|
async fn register(State(state): State<AppState>, Json(req): Json<RegisterReq>) -> Response {
|
||||||
|
match register_inner(&state, req).await {
|
||||||
|
Ok(()) | Err(WebError::BadRequest(_)) => {}
|
||||||
|
Err(e) => return e.into_response(),
|
||||||
|
}
|
||||||
|
// Generic 202 whatever happened above (except hard server errors).
|
||||||
|
StatusCode::ACCEPTED.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register_inner(state: &AppState, req: RegisterReq) -> WebResult<()> {
|
||||||
|
if !req.email.contains('@') {
|
||||||
|
return Err(WebError::BadRequest("invalid email"));
|
||||||
|
}
|
||||||
|
if req.password.len() < 8 {
|
||||||
|
return Err(WebError::BadRequest("password too short (min 8)"));
|
||||||
|
}
|
||||||
|
let phc = hash_password(&req.password).map_err(|_| WebError::Internal)?;
|
||||||
|
|
||||||
|
// Insert the user; a duplicate email silently no-ops (no enumeration).
|
||||||
|
let user_id: Option<Uuid> = sqlx::query(
|
||||||
|
"INSERT INTO users (email, password_hash, registration_fingerprint) \
|
||||||
|
VALUES ($1, $2, $3) ON CONFLICT (email) DO NOTHING RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&req.email)
|
||||||
|
.bind(&phc)
|
||||||
|
.bind(&req.fingerprint)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.map(|r| r.get("id"));
|
||||||
|
|
||||||
|
let Some(user_id) = user_id else {
|
||||||
|
return Ok(()); // email already registered — say nothing
|
||||||
|
};
|
||||||
|
|
||||||
|
// Account with the flat free grant.
|
||||||
|
sqlx::query("INSERT INTO accounts (owner_user_id, allocation_total) VALUES ($1, $2)")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(state.config.grant.free_token_grant)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Silent fingerprint abuse handling.
|
||||||
|
if let Some(fp) = req.fingerprint.as_deref().filter(|f| !f.is_empty()) {
|
||||||
|
apply_fingerprint_policy(state, fp).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email verification link.
|
||||||
|
let token = random_token();
|
||||||
|
let expires: DateTime<Utc> =
|
||||||
|
Utc::now() + Duration::seconds(state.config.auth.email_token_ttl_secs as i64);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO email_tokens (token_hash, user_id, kind, expires_at) \
|
||||||
|
VALUES ($1, $2, 'verify', $3)",
|
||||||
|
)
|
||||||
|
.bind(sha256(&token))
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(expires)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let link = format!("{}/verify?token={token}", state.config.auth.app_base_url);
|
||||||
|
let _ = state
|
||||||
|
.email
|
||||||
|
.send(
|
||||||
|
&req.email,
|
||||||
|
"Verify your helexa account",
|
||||||
|
&format!("Welcome to helexa. Verify your email:\n\n{link}\n"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count accounts sharing `fp`; flag them, and silently deactivate all once
|
||||||
|
/// the count reaches the configured threshold. No response difference — the
|
||||||
|
/// abuser gets no signal.
|
||||||
|
async fn apply_fingerprint_policy(state: &AppState, fp: &str) -> WebResult<()> {
|
||||||
|
let count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT count(*) FROM users WHERE registration_fingerprint = $1")
|
||||||
|
.bind(fp)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if count > 1 {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE accounts SET fingerprint_flagged = true \
|
||||||
|
WHERE owner_user_id IN (SELECT id FROM users WHERE registration_fingerprint = $1)",
|
||||||
|
)
|
||||||
|
.bind(fp)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if count >= state.config.abuse.fingerprint_account_threshold {
|
||||||
|
let res = sqlx::query(
|
||||||
|
"UPDATE accounts SET status = 'deactivated' \
|
||||||
|
WHERE owner_user_id IN (SELECT id FROM users WHERE registration_fingerprint = $1)",
|
||||||
|
)
|
||||||
|
.bind(fp)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
tracing::warn!(
|
||||||
|
fingerprint = fp,
|
||||||
|
accounts = res.rows_affected(),
|
||||||
|
"silently deactivated fingerprint-abusing accounts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenReq {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /web/v1/verify` — consume a verification token, mark verified.
|
||||||
|
async fn verify(State(state): State<AppState>, Json(req): Json<TokenReq>) -> WebResult<Response> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"UPDATE email_tokens SET consumed_at = now() \
|
||||||
|
WHERE token_hash = $1 AND kind = 'verify' AND consumed_at IS NULL AND expires_at > now() \
|
||||||
|
RETURNING user_id",
|
||||||
|
)
|
||||||
|
.bind(sha256(&req.token))
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Err(WebError::BadRequest("invalid or expired token"));
|
||||||
|
};
|
||||||
|
let user_id: Uuid = row.get("user_id");
|
||||||
|
sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::OK.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginReq {
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /web/v1/login` — verify password + email-verified → session JWT.
|
||||||
|
async fn login(State(state): State<AppState>, Json(req): Json<LoginReq>) -> WebResult<Response> {
|
||||||
|
let row = sqlx::query("SELECT id, password_hash, email_verified FROM users WHERE email = $1")
|
||||||
|
.bind(&req.email)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
// Generic 401 for every failure mode (no enumeration).
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Err(WebError::Unauthorized);
|
||||||
|
};
|
||||||
|
let phc: String = row.get("password_hash");
|
||||||
|
let verified: bool = row.get("email_verified");
|
||||||
|
if !verify_password(&req.password, &phc) || !verified {
|
||||||
|
return Err(WebError::Unauthorized);
|
||||||
|
}
|
||||||
|
let user_id: Uuid = row.get("id");
|
||||||
|
let token = mint_session(&state, user_id)?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"token": token,
|
||||||
|
"expires_in": state.config.auth.session_ttl_secs,
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EmailReq {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /web/v1/password-reset/request` — always `202` (no enumeration);
|
||||||
|
/// mints + emails a reset token only if the account exists.
|
||||||
|
async fn reset_request(State(state): State<AppState>, Json(req): Json<EmailReq>) -> Response {
|
||||||
|
// The inner only ever yields `Internal` (DB failure); a missing email is
|
||||||
|
// Ok(()) so there's no enumeration. Surface 500 on a real error, else 202.
|
||||||
|
match reset_request_inner(&state, &req.email).await {
|
||||||
|
Ok(()) => StatusCode::ACCEPTED.into_response(),
|
||||||
|
Err(e) => e.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reset_request_inner(state: &AppState, email: &str) -> WebResult<()> {
|
||||||
|
let row = sqlx::query("SELECT id FROM users WHERE email = $1")
|
||||||
|
.bind(email)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else { return Ok(()) };
|
||||||
|
let user_id: Uuid = row.get("id");
|
||||||
|
let token = random_token();
|
||||||
|
let expires: DateTime<Utc> =
|
||||||
|
Utc::now() + Duration::seconds(state.config.auth.email_token_ttl_secs as i64);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO email_tokens (token_hash, user_id, kind, expires_at) \
|
||||||
|
VALUES ($1, $2, 'reset', $3)",
|
||||||
|
)
|
||||||
|
.bind(sha256(&token))
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(expires)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
let link = format!("{}/reset?token={token}", state.config.auth.app_base_url);
|
||||||
|
let _ = state
|
||||||
|
.email
|
||||||
|
.send(
|
||||||
|
email,
|
||||||
|
"Reset your helexa password",
|
||||||
|
&format!("Reset your password:\n\n{link}\n"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ResetConfirmReq {
|
||||||
|
token: String,
|
||||||
|
new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /web/v1/password-reset/confirm` — consume reset token, rotate hash.
|
||||||
|
async fn reset_confirm(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<ResetConfirmReq>,
|
||||||
|
) -> WebResult<Response> {
|
||||||
|
if req.new_password.len() < 8 {
|
||||||
|
return Err(WebError::BadRequest("password too short (min 8)"));
|
||||||
|
}
|
||||||
|
let row = sqlx::query(
|
||||||
|
"UPDATE email_tokens SET consumed_at = now() \
|
||||||
|
WHERE token_hash = $1 AND kind = 'reset' AND consumed_at IS NULL AND expires_at > now() \
|
||||||
|
RETURNING user_id",
|
||||||
|
)
|
||||||
|
.bind(sha256(&req.token))
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Err(WebError::BadRequest("invalid or expired token"));
|
||||||
|
};
|
||||||
|
let user_id: Uuid = row.get("user_id");
|
||||||
|
let phc = hash_password(&req.new_password).map_err(|_| WebError::Internal)?;
|
||||||
|
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||||
|
.bind(phc)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::OK.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── account + keys (protected) ──────────────────────────────────────
|
||||||
|
|
||||||
|
async fn account(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(user): Extension<AuthUser>,
|
||||||
|
) -> WebResult<Response> {
|
||||||
|
let acct = account_id_for(&state, user.0).await?;
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT allocation_total, allocation_spent, allocation_reserved FROM accounts WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(acct)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"account_id": acct.to_string(),
|
||||||
|
"allocation_total": row.get::<i64, _>("allocation_total"),
|
||||||
|
"allocation_spent": row.get::<i64, _>("allocation_spent"),
|
||||||
|
"allocation_reserved": row.get::<i64, _>("allocation_reserved"),
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_keys(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(user): Extension<AuthUser>,
|
||||||
|
) -> WebResult<Response> {
|
||||||
|
let acct = account_id_for(&state, user.0).await?;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, key_prefix, label, status, limit_kind, limit_value, key_spent, key_reserved, \
|
||||||
|
created_at \
|
||||||
|
FROM api_keys WHERE account_id = $1 ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(acct)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
let keys: Vec<_> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
json!({
|
||||||
|
"id": r.get::<Uuid, _>("id").to_string(),
|
||||||
|
"prefix": r.get::<String, _>("key_prefix"),
|
||||||
|
"label": r.get::<String, _>("label"),
|
||||||
|
"status": r.get::<String, _>("status"),
|
||||||
|
"limit_kind": r.get::<String, _>("limit_kind"),
|
||||||
|
"limit_value": r.get::<i64, _>("limit_value"),
|
||||||
|
"spent": r.get::<i64, _>("key_spent"),
|
||||||
|
"reserved": r.get::<i64, _>("key_reserved"),
|
||||||
|
"created_at": r.get::<DateTime<Utc>, _>("created_at").to_rfc3339(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!({ "keys": keys })).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateKeyReq {
|
||||||
|
#[serde(default)]
|
||||||
|
label: String,
|
||||||
|
/// "percent" | "hardcap" (default percent=100 → full allocation).
|
||||||
|
#[serde(default)]
|
||||||
|
limit_kind: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
limit_value: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(user): Extension<AuthUser>,
|
||||||
|
Json(req): Json<CreateKeyReq>,
|
||||||
|
) -> WebResult<Response> {
|
||||||
|
let acct = account_id_for(&state, user.0).await?;
|
||||||
|
let limit_kind = match req.limit_kind.as_deref() {
|
||||||
|
Some("hardcap") => "hardcap",
|
||||||
|
_ => "percent",
|
||||||
|
};
|
||||||
|
let limit_value = req.limit_value.unwrap_or(100).max(0);
|
||||||
|
let (raw, prefix) = generate_api_key();
|
||||||
|
let id: Uuid = sqlx::query(
|
||||||
|
"INSERT INTO api_keys (account_id, key_hash, key_prefix, label, limit_kind, limit_value) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(acct)
|
||||||
|
.bind(sha256(&raw))
|
||||||
|
.bind(&prefix)
|
||||||
|
.bind(&req.label)
|
||||||
|
.bind(limit_kind)
|
||||||
|
.bind(limit_value)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?
|
||||||
|
.get("id");
|
||||||
|
// The raw key is shown exactly once.
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(json!({
|
||||||
|
"id": id.to_string(),
|
||||||
|
"key": raw,
|
||||||
|
"prefix": prefix,
|
||||||
|
"limit_kind": limit_kind,
|
||||||
|
"limit_value": limit_value,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn archive_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(user): Extension<AuthUser>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> WebResult<Response> {
|
||||||
|
let acct = account_id_for(&state, user.0).await?;
|
||||||
|
let res = sqlx::query(
|
||||||
|
"UPDATE api_keys SET status = 'archived' WHERE id = $1 AND account_id = $2 AND status = 'active'",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(acct)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(WebError::BadRequest("no such active key"));
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdateLimitReq {
|
||||||
|
limit_kind: String,
|
||||||
|
limit_value: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_key_limit(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(user): Extension<AuthUser>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateLimitReq>,
|
||||||
|
) -> WebResult<Response> {
|
||||||
|
if req.limit_kind != "percent" && req.limit_kind != "hardcap" {
|
||||||
|
return Err(WebError::BadRequest(
|
||||||
|
"limit_kind must be percent or hardcap",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if req.limit_value < 0 {
|
||||||
|
return Err(WebError::BadRequest("limit_value must be >= 0"));
|
||||||
|
}
|
||||||
|
let acct = account_id_for(&state, user.0).await?;
|
||||||
|
let res = sqlx::query(
|
||||||
|
"UPDATE api_keys SET limit_kind = $1, limit_value = $2 WHERE id = $3 AND account_id = $4",
|
||||||
|
)
|
||||||
|
.bind(&req.limit_kind)
|
||||||
|
.bind(req.limit_value)
|
||||||
|
.bind(id)
|
||||||
|
.bind(acct)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(WebError::BadRequest("no such key"));
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT.into_response())
|
||||||
|
}
|
||||||
243
crates/helexa-upstream/tests/authz_pg.rs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
//! Integration tests for the `/authz/v1` surface against a real Postgres,
|
||||||
|
//! driving the built axum app over HTTP. Gated on `UPSTREAM_TEST_DATABASE_URL`
|
||||||
|
//! (skips cleanly when unset, so CI stays green without a DB):
|
||||||
|
//!
|
||||||
|
//! UPSTREAM_TEST_DATABASE_URL=postgres://helexa:helexa@localhost/helexa_test \
|
||||||
|
//! cargo test -p helexa-upstream --test authz_pg
|
||||||
|
|
||||||
|
use helexa_upstream::config::{ClientToken, UpstreamConfig};
|
||||||
|
use helexa_upstream::crypto::sha256;
|
||||||
|
use helexa_upstream::db::connect_and_migrate;
|
||||||
|
use helexa_upstream::state::AppState;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::Executor;
|
||||||
|
use sqlx::Row;
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const CLIENT_TOKEN: &str = "test-operator-token";
|
||||||
|
|
||||||
|
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
|
||||||
|
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
|
||||||
|
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
|
||||||
|
|
||||||
|
let mut config = UpstreamConfig {
|
||||||
|
server: Default::default(),
|
||||||
|
db: helexa_upstream::config::DbSettings {
|
||||||
|
url,
|
||||||
|
max_connections: 16,
|
||||||
|
},
|
||||||
|
grant: Default::default(),
|
||||||
|
abuse: Default::default(),
|
||||||
|
client_auth: Default::default(),
|
||||||
|
authz: Default::default(),
|
||||||
|
auth: Default::default(),
|
||||||
|
email: Default::default(),
|
||||||
|
};
|
||||||
|
config.client_auth.tokens.push(ClientToken {
|
||||||
|
token: CLIENT_TOKEN.into(),
|
||||||
|
operator_id: "op-test".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let email = helexa_upstream::email::EmailSender::from_config(&config.email).unwrap();
|
||||||
|
let state = AppState::new(pool.clone(), config, email);
|
||||||
|
let app = helexa_upstream::build_app(state);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
Some((format!("http://{addr}"), pool))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed an account with `total` allocation and an active key with raw value
|
||||||
|
/// `raw` (percent=100). Optionally deactivate the account. Returns
|
||||||
|
/// (account_id, key_id).
|
||||||
|
async fn seed_key(pool: &PgPool, total: i64, raw: &str, deactivated: bool) -> (Uuid, Uuid) {
|
||||||
|
let user_id: Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (email, password_hash, email_verified) VALUES ($1,'x',true) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(format!("u-{}@t.local", Uuid::new_v4())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
let status = if deactivated { "deactivated" } else { "active" };
|
||||||
|
let account_id: Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts (owner_user_id, allocation_total, status) VALUES ($1,$2,$3) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(total)
|
||||||
|
.bind(status),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
let key_id: Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO api_keys (account_id, key_hash, key_prefix, limit_kind, limit_value) \
|
||||||
|
VALUES ($1,$2,'sk-test','percent',100) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(sha256(raw)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
(account_id, key_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client() -> reqwest::Client {
|
||||||
|
reqwest::Client::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(
|
||||||
|
c: &reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
body: Value,
|
||||||
|
bearer: Option<&str>,
|
||||||
|
) -> reqwest::Response {
|
||||||
|
let mut req = c.post(url).json(&body);
|
||||||
|
if let Some(b) = bearer {
|
||||||
|
req = req.bearer_auth(b);
|
||||||
|
}
|
||||||
|
req.send().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_reserve_settle_round_trip() {
|
||||||
|
let Some((base, pool)) = spawn_or_skip("resolve_reserve_settle_round_trip").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let raw = format!("sk-{}", Uuid::new_v4());
|
||||||
|
let (account_id, key_id) = seed_key(&pool, 1000, &raw, false).await;
|
||||||
|
let c = client();
|
||||||
|
|
||||||
|
// resolve
|
||||||
|
let r = post(
|
||||||
|
&c,
|
||||||
|
format!("{base}/authz/v1/resolve"),
|
||||||
|
json!({"api_key": raw}),
|
||||||
|
Some(CLIENT_TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 200);
|
||||||
|
let body: Value = r.json().await.unwrap();
|
||||||
|
assert_eq!(body["principal"]["account_id"], account_id.to_string());
|
||||||
|
assert_eq!(body["principal"]["key_id"], key_id.to_string());
|
||||||
|
assert_eq!(body["snapshot"]["hard_cap"], 1000);
|
||||||
|
|
||||||
|
// reserve 400
|
||||||
|
let r = post(
|
||||||
|
&c,
|
||||||
|
format!("{base}/authz/v1/reserve"),
|
||||||
|
json!({"account_id": account_id, "key_id": key_id, "max_tokens": 400}),
|
||||||
|
Some(CLIENT_TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 200);
|
||||||
|
let body: Value = r.json().await.unwrap();
|
||||||
|
let rid = body["reservation_id"].as_i64().expect("granted");
|
||||||
|
|
||||||
|
// settle 150
|
||||||
|
let r = post(
|
||||||
|
&c,
|
||||||
|
format!("{base}/authz/v1/settle"),
|
||||||
|
json!({"reservation_id": rid, "actual_tokens": 150}),
|
||||||
|
Some(CLIENT_TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 204);
|
||||||
|
|
||||||
|
// snapshot reflects spend
|
||||||
|
let r = post(
|
||||||
|
&c,
|
||||||
|
format!("{base}/authz/v1/snapshot"),
|
||||||
|
json!({"account_id": account_id, "key_id": key_id}),
|
||||||
|
Some(CLIENT_TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let body: Value = r.json().await.unwrap();
|
||||||
|
assert_eq!(body["spent"], 150);
|
||||||
|
assert_eq!(body["reserved"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn over_cap_reserve_is_rejected_not_errored() {
|
||||||
|
let Some((base, pool)) = spawn_or_skip("over_cap_reserve_is_rejected_not_errored").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let raw = format!("sk-{}", Uuid::new_v4());
|
||||||
|
let (account_id, key_id) = seed_key(&pool, 100, &raw, false).await;
|
||||||
|
let c = client();
|
||||||
|
let r = post(
|
||||||
|
&c,
|
||||||
|
format!("{base}/authz/v1/reserve"),
|
||||||
|
json!({"account_id": account_id, "key_id": key_id, "max_tokens": 999}),
|
||||||
|
Some(CLIENT_TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 200, "budget refusal is an authoritative 200");
|
||||||
|
let body: Value = r.json().await.unwrap();
|
||||||
|
assert!(body["reservation_id"].is_null());
|
||||||
|
assert_eq!(body["rejected"]["kind"], "insufficient_quota");
|
||||||
|
assert_eq!(body["rejected"]["available"], 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deactivated_account_resolves_as_invalid_no_clue() {
|
||||||
|
let Some((base, pool)) = spawn_or_skip("deactivated_account_resolves_as_invalid_no_clue").await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let raw = format!("sk-{}", Uuid::new_v4());
|
||||||
|
seed_key(&pool, 1000, &raw, true).await; // deactivated
|
||||||
|
let c = client();
|
||||||
|
let r = post(
|
||||||
|
&c,
|
||||||
|
format!("{base}/authz/v1/resolve"),
|
||||||
|
json!({"api_key": raw}),
|
||||||
|
Some(CLIENT_TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Indistinguishable from an unknown key.
|
||||||
|
assert_eq!(r.status(), 401);
|
||||||
|
let body: Value = r.json().await.unwrap();
|
||||||
|
assert_eq!(body["error"]["code"], "invalid_api_key");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_client_auth_is_401_before_db() {
|
||||||
|
let Some((base, pool)) = spawn_or_skip("missing_client_auth_is_401_before_db").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let raw = format!("sk-{}", Uuid::new_v4());
|
||||||
|
seed_key(&pool, 1000, &raw, false).await;
|
||||||
|
let c = client();
|
||||||
|
// No bearer → rejected by client_auth.
|
||||||
|
let r = post(
|
||||||
|
&c,
|
||||||
|
format!("{base}/authz/v1/resolve"),
|
||||||
|
json!({"api_key": raw}),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 401);
|
||||||
|
// Wrong bearer → also rejected.
|
||||||
|
let r = post(
|
||||||
|
&c,
|
||||||
|
format!("{base}/authz/v1/resolve"),
|
||||||
|
json!({"api_key": raw}),
|
||||||
|
Some("wrong"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 401);
|
||||||
|
}
|
||||||
204
crates/helexa-upstream/tests/ledger_pg.rs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
//! Integration tests for the allocation ledger against a real PostgreSQL.
|
||||||
|
//!
|
||||||
|
//! Gated on `UPSTREAM_TEST_DATABASE_URL` — when unset (CI's generic runner,
|
||||||
|
//! local builds without a DB), every test logs a skip and returns, so
|
||||||
|
//! `cargo test --workspace` stays green without Postgres. Point the env var
|
||||||
|
//! at a throwaway database to exercise the no-overshoot guarantee and
|
||||||
|
//! settle/release idempotency:
|
||||||
|
//!
|
||||||
|
//! UPSTREAM_TEST_DATABASE_URL=postgres://helexa:helexa@localhost/helexa_test \
|
||||||
|
//! cargo test -p helexa-upstream --test ledger_pg
|
||||||
|
|
||||||
|
use helexa_upstream::db::connect_and_migrate;
|
||||||
|
use helexa_upstream::ledger::{self, LedgerError};
|
||||||
|
use sqlx::Executor;
|
||||||
|
use sqlx::Row;
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Returns a migrated pool, or `None` (with a skip log) when the env var is
|
||||||
|
/// unset.
|
||||||
|
async fn pool_or_skip(test: &str) -> Option<PgPool> {
|
||||||
|
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
|
||||||
|
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(
|
||||||
|
connect_and_migrate(&url, 16)
|
||||||
|
.await
|
||||||
|
.expect("connect + migrate"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed a verified user + account (with `total` allocation) + an active key
|
||||||
|
/// (percent=100 so the account cap binds). Returns (account_id, key_id).
|
||||||
|
async fn seed(pool: &PgPool, total: i64) -> (Uuid, Uuid) {
|
||||||
|
let user_id: Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (email, password_hash, email_verified) \
|
||||||
|
VALUES ($1, 'x', true) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(format!("u-{}@test.local", Uuid::new_v4())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
let account_id: Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts (owner_user_id, allocation_total) \
|
||||||
|
VALUES ($1, $2) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(total),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
let key_id: Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO api_keys (account_id, key_hash, key_prefix, limit_kind, limit_value) \
|
||||||
|
VALUES ($1, $2, 'sk-test', 'percent', 100) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(Uuid::new_v4().as_bytes().to_vec()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
(account_id, key_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn account_cols(pool: &PgPool, account_id: Uuid) -> (i64, i64) {
|
||||||
|
let row = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query("SELECT allocation_spent, allocation_reserved FROM accounts WHERE id = $1")
|
||||||
|
.bind(account_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(row.get("allocation_spent"), row.get("allocation_reserved"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_reserves_never_overshoot() {
|
||||||
|
let Some(pool) = pool_or_skip("concurrent_reserves_never_overshoot").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Allocation admits exactly 5 reservations of 100 (cap 500).
|
||||||
|
let (account_id, key_id) = seed(&pool, 500).await;
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..20 {
|
||||||
|
let pool = pool.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
ledger::reserve(&pool, account_id, key_id, 100).await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let mut ok = 0;
|
||||||
|
let mut quota = 0;
|
||||||
|
for h in handles {
|
||||||
|
match h.await.unwrap() {
|
||||||
|
Ok(_) => ok += 1,
|
||||||
|
Err(LedgerError::InsufficientQuota { .. }) => quota += 1,
|
||||||
|
Err(e) => panic!("unexpected error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(ok, 5, "exactly 5 reserves of 100 fit in a 500 allocation");
|
||||||
|
assert_eq!(quota, 15);
|
||||||
|
|
||||||
|
let (spent, reserved) = account_cols(&pool, account_id).await;
|
||||||
|
assert_eq!(spent, 0);
|
||||||
|
assert_eq!(reserved, 500, "reserved exactly the cap, never over");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn settle_is_idempotent_and_reconciles_spend() {
|
||||||
|
let Some(pool) = pool_or_skip("settle_is_idempotent_and_reconciles_spend").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (account_id, key_id) = seed(&pool, 1000).await;
|
||||||
|
let rid = ledger::reserve(&pool, account_id, key_id, 400)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Settle actual=150 (< reserved 400): spent=150, reserved back to 0.
|
||||||
|
ledger::settle(&pool, rid, 150).await.unwrap();
|
||||||
|
let (spent, reserved) = account_cols(&pool, account_id).await;
|
||||||
|
assert_eq!((spent, reserved), (150, 0));
|
||||||
|
|
||||||
|
// Second settle is a no-op.
|
||||||
|
ledger::settle(&pool, rid, 999).await.unwrap();
|
||||||
|
let (spent2, reserved2) = account_cols(&pool, account_id).await;
|
||||||
|
assert_eq!((spent2, reserved2), (150, 0), "settle is idempotent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn release_returns_reservation_and_is_idempotent() {
|
||||||
|
let Some(pool) = pool_or_skip("release_returns_reservation_and_is_idempotent").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (account_id, key_id) = seed(&pool, 1000).await;
|
||||||
|
let rid = ledger::reserve(&pool, account_id, key_id, 300)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(account_cols(&pool, account_id).await, (0, 300));
|
||||||
|
|
||||||
|
ledger::release(&pool, rid).await.unwrap();
|
||||||
|
assert_eq!(account_cols(&pool, account_id).await, (0, 0));
|
||||||
|
// Idempotent; settle-after-release also a no-op.
|
||||||
|
ledger::release(&pool, rid).await.unwrap();
|
||||||
|
ledger::settle(&pool, rid, 100).await.unwrap();
|
||||||
|
assert_eq!(account_cols(&pool, account_id).await, (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hardcap_key_subcap_binds_below_account() {
|
||||||
|
let Some(pool) = pool_or_skip("hardcap_key_subcap_binds_below_account").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Account has 1000 but the key is hard-capped at 200.
|
||||||
|
let user_id: Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (email, password_hash, email_verified) \
|
||||||
|
VALUES ($1, 'x', true) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(format!("u-{}@test.local", Uuid::new_v4())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
let account_id: Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO accounts (owner_user_id, allocation_total) VALUES ($1, 1000) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(user_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
let key_id: Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO api_keys (account_id, key_hash, key_prefix, limit_kind, limit_value) \
|
||||||
|
VALUES ($1, $2, 'sk-test', 'hardcap', 200) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(Uuid::new_v4().as_bytes().to_vec()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
|
||||||
|
ledger::reserve(&pool, account_id, key_id, 200)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
match ledger::reserve(&pool, account_id, key_id, 1).await {
|
||||||
|
Err(LedgerError::InsufficientQuota { available, .. }) => assert_eq!(available, 0),
|
||||||
|
other => panic!("expected InsufficientQuota, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
296
crates/helexa-upstream/tests/web_pg.rs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
//! Integration tests for the `/web/v1` account API + the silent fingerprint
|
||||||
|
//! abuse policy, driving the built app over HTTP against a real Postgres.
|
||||||
|
//! Gated on `UPSTREAM_TEST_DATABASE_URL` (skips cleanly when unset).
|
||||||
|
|
||||||
|
use helexa_upstream::config::{ClientToken, UpstreamConfig};
|
||||||
|
use helexa_upstream::crypto::sha256;
|
||||||
|
use helexa_upstream::db::connect_and_migrate;
|
||||||
|
use helexa_upstream::email::EmailSender;
|
||||||
|
use helexa_upstream::state::AppState;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sqlx::Executor;
|
||||||
|
use sqlx::Row;
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
|
||||||
|
const CLIENT_TOKEN: &str = "web-test-operator-token";
|
||||||
|
|
||||||
|
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
|
||||||
|
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
|
||||||
|
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
|
||||||
|
let mut config = UpstreamConfig {
|
||||||
|
server: Default::default(),
|
||||||
|
db: helexa_upstream::config::DbSettings {
|
||||||
|
url,
|
||||||
|
max_connections: 16,
|
||||||
|
},
|
||||||
|
grant: Default::default(),
|
||||||
|
abuse: Default::default(),
|
||||||
|
client_auth: Default::default(),
|
||||||
|
authz: Default::default(),
|
||||||
|
auth: Default::default(),
|
||||||
|
email: Default::default(), // Log transport
|
||||||
|
};
|
||||||
|
config.client_auth.tokens.push(ClientToken {
|
||||||
|
token: CLIENT_TOKEN.into(),
|
||||||
|
operator_id: "op-web".into(),
|
||||||
|
});
|
||||||
|
let email = EmailSender::from_config(&config.email).unwrap();
|
||||||
|
let state = AppState::new(pool.clone(), config, email);
|
||||||
|
let app = helexa_upstream::build_app(state);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
Some((format!("http://{addr}"), pool))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_email() -> String {
|
||||||
|
format!("u-{}@test.local", uuid::Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(url: String, body: Value, bearer: Option<&str>) -> reqwest::Response {
|
||||||
|
let c = reqwest::Client::new();
|
||||||
|
let mut req = c.post(url).json(&body);
|
||||||
|
if let Some(b) = bearer {
|
||||||
|
req = req.bearer_auth(b);
|
||||||
|
}
|
||||||
|
req.send().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn verify_endpoint_consumes_token_once() {
|
||||||
|
let Some((base, pool)) = spawn_or_skip("verify_endpoint_consumes_token_once").await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let email = unique_email();
|
||||||
|
// Register, then mint a verify token directly (the raw token is only in
|
||||||
|
// the email; here we insert a known one to drive the endpoint).
|
||||||
|
assert_eq!(
|
||||||
|
post(
|
||||||
|
format!("{base}/web/v1/register"),
|
||||||
|
json!({"email": email, "password": "password123"}),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
202
|
||||||
|
);
|
||||||
|
let user_id: uuid::Uuid = pool
|
||||||
|
.fetch_one(sqlx::query("SELECT id FROM users WHERE email = $1").bind(&email))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
let raw = "verify-raw-token-xyz";
|
||||||
|
pool.execute(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO email_tokens (token_hash, user_id, kind, expires_at) \
|
||||||
|
VALUES ($1, $2, 'verify', now() + interval '1 hour')",
|
||||||
|
)
|
||||||
|
.bind(sha256(raw))
|
||||||
|
.bind(user_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
post(format!("{base}/web/v1/verify"), json!({"token": raw}), None)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
// Consumed → second attempt fails.
|
||||||
|
assert_eq!(
|
||||||
|
post(format!("{base}/web/v1/verify"), json!({"token": raw}), None)
|
||||||
|
.await
|
||||||
|
.status(),
|
||||||
|
400
|
||||||
|
);
|
||||||
|
|
||||||
|
let verified: bool = pool
|
||||||
|
.fetch_one(sqlx::query("SELECT email_verified FROM users WHERE id = $1").bind(user_id))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("email_verified");
|
||||||
|
assert!(verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn account_lifecycle_and_key_resolves_then_archives() {
|
||||||
|
let Some((base, pool)) =
|
||||||
|
spawn_or_skip("account_lifecycle_and_key_resolves_then_archives").await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let email = unique_email();
|
||||||
|
post(
|
||||||
|
format!("{base}/web/v1/register"),
|
||||||
|
json!({"email": email, "password": "password123"}),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Bypass the email step for the login/key portion.
|
||||||
|
pool.execute(
|
||||||
|
sqlx::query("UPDATE users SET email_verified = true WHERE email = $1").bind(&email),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// login → session JWT
|
||||||
|
let r = post(
|
||||||
|
format!("{base}/web/v1/login"),
|
||||||
|
json!({"email": email, "password": "password123"}),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 200);
|
||||||
|
let token = r.json::<Value>().await.unwrap()["token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// create key (raw shown once)
|
||||||
|
let r = post(
|
||||||
|
format!("{base}/web/v1/keys"),
|
||||||
|
json!({"label": "laptop"}),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 201);
|
||||||
|
let body: Value = r.json().await.unwrap();
|
||||||
|
let raw_key = body["key"].as_str().unwrap().to_string();
|
||||||
|
let key_id = body["id"].as_str().unwrap().to_string();
|
||||||
|
assert!(raw_key.starts_with("sk-helexa-"));
|
||||||
|
|
||||||
|
// account balance reflects the free grant
|
||||||
|
let r = reqwest::Client::new()
|
||||||
|
.get(format!("{base}/web/v1/account"))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.json::<Value>().await.unwrap()["allocation_total"],
|
||||||
|
1_000_000
|
||||||
|
);
|
||||||
|
|
||||||
|
// list keys shows the prefix, never the raw secret
|
||||||
|
let r = reqwest::Client::new()
|
||||||
|
.get(format!("{base}/web/v1/keys"))
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let listed = r.json::<Value>().await.unwrap();
|
||||||
|
let k = &listed["keys"][0];
|
||||||
|
assert_eq!(k["id"], key_id);
|
||||||
|
assert!(k.get("key").is_none(), "raw secret never listed");
|
||||||
|
assert!(k["prefix"].as_str().unwrap().starts_with("sk-helexa-"));
|
||||||
|
|
||||||
|
// the key authorizes at the authz surface
|
||||||
|
let r = post(
|
||||||
|
format!("{base}/authz/v1/resolve"),
|
||||||
|
json!({"api_key": raw_key}),
|
||||||
|
Some(CLIENT_TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 200);
|
||||||
|
|
||||||
|
// archive → the key no longer resolves
|
||||||
|
let r = post(
|
||||||
|
format!("{base}/web/v1/keys/{key_id}/archive"),
|
||||||
|
json!({}),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 204);
|
||||||
|
let r = post(
|
||||||
|
format!("{base}/authz/v1/resolve"),
|
||||||
|
json!({"api_key": raw_key}),
|
||||||
|
Some(CLIENT_TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fingerprint_abuse_silently_deactivates_all_no_clue() {
|
||||||
|
let Some((base, pool)) =
|
||||||
|
spawn_or_skip("fingerprint_abuse_silently_deactivates_all_no_clue").await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let fp = format!("fp-{}", uuid::Uuid::new_v4());
|
||||||
|
|
||||||
|
// 5 registrations sharing one fingerprint — every one returns a normal 202.
|
||||||
|
let mut emails = Vec::new();
|
||||||
|
for _ in 0..5 {
|
||||||
|
let email = unique_email();
|
||||||
|
let r = post(
|
||||||
|
format!("{base}/web/v1/register"),
|
||||||
|
json!({"email": email, "password": "password123", "fingerprint": fp}),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), 202, "registration always looks successful");
|
||||||
|
emails.push(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silent effect: all 5 accounts are deactivated + flagged.
|
||||||
|
let (deactivated, flagged): (i64, i64) = {
|
||||||
|
let row = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
count(*) FILTER (WHERE a.status = 'deactivated') AS d, \
|
||||||
|
count(*) FILTER (WHERE a.fingerprint_flagged) AS f \
|
||||||
|
FROM accounts a JOIN users u ON u.id = a.owner_user_id \
|
||||||
|
WHERE u.registration_fingerprint = $1",
|
||||||
|
)
|
||||||
|
.bind(&fp),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(row.get("d"), row.get("f"))
|
||||||
|
};
|
||||||
|
assert_eq!(deactivated, 5, "all sharing accounts silently deactivated");
|
||||||
|
assert_eq!(flagged, 5);
|
||||||
|
|
||||||
|
// No clue at the authz surface: a key on a deactivated account resolves
|
||||||
|
// as an ordinary 401, indistinguishable from an unknown key.
|
||||||
|
let acct: uuid::Uuid = pool
|
||||||
|
.fetch_one(
|
||||||
|
sqlx::query(
|
||||||
|
"SELECT a.id FROM accounts a JOIN users u ON u.id = a.owner_user_id \
|
||||||
|
WHERE u.registration_fingerprint = $1 LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&fp),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get("id");
|
||||||
|
let raw = "sk-helexa-deactivated-probe";
|
||||||
|
pool.execute(
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO api_keys (account_id, key_hash, key_prefix) VALUES ($1, $2, 'sk-helexa-')",
|
||||||
|
)
|
||||||
|
.bind(acct)
|
||||||
|
.bind(sha256(raw)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let r = post(
|
||||||
|
format!("{base}/authz/v1/resolve"),
|
||||||
|
json!({"api_key": raw}),
|
||||||
|
Some(CLIENT_TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
r.status(),
|
||||||
|
401,
|
||||||
|
"deactivated account's key looks like any invalid key"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,14 +17,22 @@ listen = "0.0.0.0:8088"
|
|||||||
# -- Downstream cortexes -------------------------------------------------
|
# -- Downstream cortexes -------------------------------------------------
|
||||||
# Each [[cortexes]] entry is an operator-run cortex the router may dispatch
|
# Each [[cortexes]] entry is an operator-run cortex the router may dispatch
|
||||||
# to. The router forwards the client's bearer verbatim (auth stays at
|
# to. The router forwards the client's bearer verbatim (auth stays at
|
||||||
# cortex) and routes on capacity. Outbound TLS to each cortex is verified.
|
# cortex) and routes on capacity (preferring matching `region`).
|
||||||
#
|
#
|
||||||
# The skeleton only loads this list; capacity/catalogue polling and
|
# Outbound TLS pinning (optional): set `tls_ca` to a PEM trust anchor that
|
||||||
# capacity-aware dispatch arrive in later issues.
|
# enrols this cortex — the CA (or self-signed cert) its TLS cert must chain
|
||||||
|
# to. The router then trusts ONLY that anchor for this cortex (platform
|
||||||
|
# roots disabled), so the router->cortex hop (which carries the client's
|
||||||
|
# bearer) reaches the cert you expect and a rogue endpoint presenting any
|
||||||
|
# other cert is rejected at the handshake. A cortex whose `tls_ca` fails to
|
||||||
|
# load is disabled (fail closed). Omit `tls_ca` for a publicly-trusted cert
|
||||||
|
# or plaintext http:// on a private (e.g. WireGuard) network.
|
||||||
|
|
||||||
# [[cortexes]]
|
# [[cortexes]]
|
||||||
# name = "lair-cafe"
|
# name = "lair-cafe"
|
||||||
# endpoint = "https://cortex.lair.cafe"
|
# endpoint = "https://cortex.lair.cafe"
|
||||||
|
# region = "eu-west"
|
||||||
|
# tls_ca = "/etc/helexa-router/pins/lair-cafe.pem"
|
||||||
|
|
||||||
# [[cortexes]]
|
# [[cortexes]]
|
||||||
# name = "example-operator"
|
# name = "example-operator"
|
||||||
|
|||||||
52
helexa-upstream.example.toml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# helexa-upstream.example.toml — mesh-level account/authorization authority
|
||||||
|
#
|
||||||
|
# Copy to helexa-upstream.toml and adjust. Env overrides use the UPSTREAM_
|
||||||
|
# prefix with __ separators, e.g. UPSTREAM_DB__URL=postgres://...
|
||||||
|
|
||||||
|
[server]
|
||||||
|
# Plaintext listener; edge nginx terminates TLS (consistent with the stack).
|
||||||
|
listen = "0.0.0.0:8090"
|
||||||
|
|
||||||
|
[db]
|
||||||
|
# PostgreSQL connection URL. Required.
|
||||||
|
url = "postgres://helexa:helexa@localhost/helexa_upstream"
|
||||||
|
# max_connections = 16
|
||||||
|
|
||||||
|
[grant]
|
||||||
|
# Flat free token grant every email-verified account receives (the floor of
|
||||||
|
# the hybrid allocation; single-use top-up codes extend it).
|
||||||
|
# free_token_grant = 1000000
|
||||||
|
|
||||||
|
[abuse]
|
||||||
|
# When this many accounts share one registration fingerprint, all are
|
||||||
|
# silently deactivated (no notice to the user).
|
||||||
|
# fingerprint_account_threshold = 5
|
||||||
|
|
||||||
|
# -- Client auth: credentials operators' cortexes present to /authz/v1.
|
||||||
|
# Each token maps to an operator_id (served-usage attribution). When no
|
||||||
|
# tokens are configured the authz surface is OPEN (dev only). Distinct from
|
||||||
|
# end-user API keys, which ride inside the resolve request body.
|
||||||
|
# [[client_auth.tokens]]
|
||||||
|
# token = "replace-with-a-strong-shared-secret"
|
||||||
|
# operator_id = "lair-cafe"
|
||||||
|
|
||||||
|
[authz]
|
||||||
|
# Open reservations older than this are swept (released), self-healing a
|
||||||
|
# reservation whose settle/release from a cortex was lost.
|
||||||
|
# reservation_ttl_secs = 120
|
||||||
|
# sweep_interval_secs = 60
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
# HMAC secret for signing web-session JWTs. MUST be overridden in prod via
|
||||||
|
# UPSTREAM_AUTH__JWT_SECRET; the built-in default is dev-only.
|
||||||
|
# jwt_secret = "change-me"
|
||||||
|
# session_ttl_secs = 604800 # 7 days
|
||||||
|
# email_token_ttl_secs = 86400 # 24 hours
|
||||||
|
# Frontend base URL used to build verify/reset links in emails.
|
||||||
|
app_base_url = "https://helexa.ai"
|
||||||
|
|
||||||
|
[email]
|
||||||
|
# "log" (dev: logs the link) or "smtp".
|
||||||
|
provider = "log"
|
||||||
|
# smtp_url = "smtp://user:pass@smtp.example.com:587"
|
||||||
|
from_addr = "helexa <no-reply@helexa.ai>"
|
||||||
20
helexa.ai/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# helexa.ai frontend env. Copy to .env.local for local dev (gitignored).
|
||||||
|
|
||||||
|
# Mesh data-plane (helexa-router, OpenAI-compatible inference). In dev,
|
||||||
|
# vite proxies /v1 and /health here.
|
||||||
|
VITE_ROUTER_BASE_URL=http://localhost:8088
|
||||||
|
|
||||||
|
# Account control-plane (helexa-upstream). In dev, vite proxies /api here
|
||||||
|
# (rewritten to /web/v1).
|
||||||
|
VITE_ACCOUNT_BASE_URL=http://localhost:8090
|
||||||
|
|
||||||
|
# Public-beta banner.
|
||||||
|
VITE_PUBLIC_BETA=true
|
||||||
|
|
||||||
|
# Models for the chat workspace (F3+).
|
||||||
|
# VITE_ANON_MODEL=...
|
||||||
|
# VITE_DEFAULT_MODEL=...
|
||||||
|
|
||||||
|
# Develop the account dashboard (F4) against an in-browser mock before the
|
||||||
|
# upstream account API ships.
|
||||||
|
# VITE_USE_MOCK_ACCOUNT_API=true
|
||||||
6
helexa.ai/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.tsbuildinfo
|
||||||
34
helexa.ai/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# helexa.ai
|
||||||
|
|
||||||
|
The public-beta frontend for the helexa mesh: a chat-first landing experience
|
||||||
|
(anonymous + authenticated, with all chat history kept client-side in
|
||||||
|
IndexedDB — no server-side history), a `/mission` page on European digital
|
||||||
|
sovereignty, and full account self-service (register, recover, manage API
|
||||||
|
keys, set per-key limits, redeem top-up codes) against `helexa-upstream`.
|
||||||
|
|
||||||
|
Vite + React (SWC) + TypeScript + react-bootstrap + react-router + react-i18next.
|
||||||
|
Lives as a top-level folder in the cortex monorepo; it is **not** a Cargo crate.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd helexa.ai
|
||||||
|
npm install
|
||||||
|
cp .env.example .env.local # adjust backend URLs
|
||||||
|
npm run dev # vite dev server, proxies /v1+/health → router, /api → upstream
|
||||||
|
```
|
||||||
|
|
||||||
|
Other scripts: `npm run build` (`tsc -b && vite build` → `dist/`), `npm run
|
||||||
|
preview`, `npm run lint`, `npm run typecheck`.
|
||||||
|
|
||||||
|
In dev, `vite.config.ts` proxies the mesh data-plane (helexa-router) and the
|
||||||
|
account control-plane (helexa-upstream) same-origin. Run a local router
|
||||||
|
(`cargo run -p helexa-router`) for the chat path and a local helexa-upstream
|
||||||
|
for the account path.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
F0 scaffold. Theming + i18n (33 languages, usage-ordered selector), the
|
||||||
|
`/mission` page, the chat workspace (Dexie + streaming), and the account
|
||||||
|
dashboard land in subsequent phases — see
|
||||||
|
`~/.claude/plans/we-need-to-plan-modular-graham.md`.
|
||||||
23
helexa.ai/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(["dist"]),
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
24
helexa.ai/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>helexa.ai</title>
|
||||||
|
<meta name="title" content="helexa.ai" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="helexa — near-frontier AI on a sovereign, operator-run mesh. Chat now; bring your own key."
|
||||||
|
/>
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://helexa.ai/" />
|
||||||
|
<meta property="og:title" content="helexa.ai" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="helexa — near-frontier AI on a sovereign, operator-run mesh."
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4115
helexa.ai/package-lock.json
generated
Normal file
43
helexa.ai/package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "helexa.ai",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
|
"i18n:check": "node ./scripts/check-i18n-keys.mjs",
|
||||||
|
"i18n:meta": "node ./scripts/check-i18n-metadata.mjs",
|
||||||
|
"i18n:lang-labels": "node ./scripts/check-i18n-lang-labels.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fingerprintjs/fingerprintjs": "^4.6.2",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"dexie": "^4.2.0",
|
||||||
|
"dexie-react-hooks": "^4.2.0",
|
||||||
|
"i18next": "^25.7.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-bootstrap": "^2.10.10",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-i18next": "^16.4.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-router-dom": "^7.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.2.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
helexa.ai/public/banner.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
helexa.ai/public/bg-logo-right.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
helexa.ai/public/logo.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
helexa.ai/public/people-mesh-1200x630.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
helexa.ai/public/people-mesh-1536x1024.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
helexa.ai/public/person-helix-1200x630.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
helexa.ai/public/person-helix-1536x1024.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
332
helexa.ai/scripts/check-i18n-keys.mjs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple i18n key consistency checker.
|
||||||
|
*
|
||||||
|
* Compares translation JSONs for all configured languages against the English
|
||||||
|
* baseline, for each namespace (common, home, chat).
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* - 0: all good
|
||||||
|
* - 1: inconsistencies found or unexpected error
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import url from "url";
|
||||||
|
|
||||||
|
// Adjust these if your i18n structure changes.
|
||||||
|
const ROOT = path.resolve(
|
||||||
|
path.dirname(url.fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
);
|
||||||
|
const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources");
|
||||||
|
|
||||||
|
// Namespaces to validate.
|
||||||
|
const NAMESPACES = ["common", "home", "chat"];
|
||||||
|
|
||||||
|
// Languages to validate should track SUPPORTED_LANGUAGES in src/i18n/languages.ts.
|
||||||
|
// NOTE: This list is intentionally narrower than SUPPORTED_LANGUAGES and does not
|
||||||
|
// enforce that every supported language has wired resources. That enforcement is
|
||||||
|
// implemented further below by checking the i18n index.
|
||||||
|
const LANGUAGES = [
|
||||||
|
"bg",
|
||||||
|
"de",
|
||||||
|
"el",
|
||||||
|
"en",
|
||||||
|
"es",
|
||||||
|
"et",
|
||||||
|
"fr",
|
||||||
|
"it",
|
||||||
|
"pt",
|
||||||
|
"ro",
|
||||||
|
"ru",
|
||||||
|
];
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to read/parse JSON at ${filePath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walk an object and collect all key paths using dot-notation.
|
||||||
|
* Arrays are traversed structurally but their indexes are not part of the key path
|
||||||
|
* (we only care that the shape exists, not array lengths).
|
||||||
|
*/
|
||||||
|
function collectKeyPaths(obj, prefix = "") {
|
||||||
|
const keys = new Set();
|
||||||
|
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj !== "object") {
|
||||||
|
if (prefix) keys.add(prefix);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is an array, walk its elements but don't add indices to the path.
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
obj.forEach((item) => {
|
||||||
|
for (const childKey of collectKeyPaths(item, prefix)) {
|
||||||
|
keys.add(childKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain object
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const nextPrefix = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
for (const childKey of collectKeyPaths(v, nextPrefix)) {
|
||||||
|
keys.add(childKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keys.add(nextPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffKeys(baseSet, targetSet) {
|
||||||
|
const missing = [];
|
||||||
|
const extra = [];
|
||||||
|
|
||||||
|
for (const k of baseSet) {
|
||||||
|
if (!targetSet.has(k)) missing.push(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of targetSet) {
|
||||||
|
if (!baseSet.has(k)) extra.push(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
missing.sort();
|
||||||
|
extra.sort();
|
||||||
|
|
||||||
|
return { missing, extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
function logHeader(title) {
|
||||||
|
// Simple console formatting without external deps.
|
||||||
|
console.log("\n" + "=".repeat(title.length));
|
||||||
|
console.log(title);
|
||||||
|
console.log("=".repeat(title.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
let hadIssues = false;
|
||||||
|
|
||||||
|
console.log("helexa.ai i18n key consistency check");
|
||||||
|
console.log(`Root: ${ROOT}`);
|
||||||
|
console.log(`Resources dir: ${RESOURCES_DIR}`);
|
||||||
|
console.log(`Languages: ${LANGUAGES.join(", ")}`);
|
||||||
|
console.log(`Namespaces: ${NAMESPACES.join(", ")}`);
|
||||||
|
|
||||||
|
// --- Wiring check: ensure each SUPPORTED_LANGUAGES entry has resources registered ---
|
||||||
|
//
|
||||||
|
// This prevents cases where a language is:
|
||||||
|
// - present in LanguageCode and SUPPORTED_LANGUAGES
|
||||||
|
// - has translation JSONs under src/i18n/resources/<code>/
|
||||||
|
// but is *not* wired into the i18n `resources` object (and thus silently
|
||||||
|
// falls back to English at runtime).
|
||||||
|
try {
|
||||||
|
const languagesTs = fs.readFileSync(
|
||||||
|
path.join(ROOT, "src", "i18n", "languages.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const i18nIndexTs = fs.readFileSync(
|
||||||
|
path.join(ROOT, "src", "i18n", "index.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse SUPPORTED_LANGUAGES from languages.ts
|
||||||
|
const marker = "export const SUPPORTED_LANGUAGES";
|
||||||
|
const start = languagesTs.indexOf(marker);
|
||||||
|
if (start === -1) {
|
||||||
|
throw new Error("Could not find `SUPPORTED_LANGUAGES` in languages.ts");
|
||||||
|
}
|
||||||
|
const after = languagesTs.slice(start);
|
||||||
|
const bracketIndex = after.indexOf("[");
|
||||||
|
const closingIndex = after.indexOf("];");
|
||||||
|
if (bracketIndex === -1 || closingIndex === -1) {
|
||||||
|
throw new Error("Malformed SUPPORTED_LANGUAGES array");
|
||||||
|
}
|
||||||
|
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||||
|
const supportedFromTs = new Set();
|
||||||
|
const codeRegex = /"([^"]+)"/g;
|
||||||
|
let m;
|
||||||
|
while ((m = codeRegex.exec(arraySlice)) !== null) {
|
||||||
|
supportedFromTs.add(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SUPPORTED_LANGUAGES from TS for wiring check: ${Array.from(
|
||||||
|
supportedFromTs,
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Languages that are intentionally "future" and may not yet have
|
||||||
|
// resources wired in. Keep them out of the hard failure path so
|
||||||
|
// CI does not break while their translations are still pending.
|
||||||
|
const FUTURE_LANGUAGES = new Set(["ig", "om", "so", "ti", "wo"]);
|
||||||
|
|
||||||
|
// Parse i18n resources object keys from index.ts.
|
||||||
|
// We look for a block like:
|
||||||
|
// const resources: Resource = {
|
||||||
|
// en: { ... },
|
||||||
|
// fr: { ... },
|
||||||
|
// };
|
||||||
|
const resourcesMarker = "const resources: Resource = {";
|
||||||
|
const resStart = i18nIndexTs.indexOf(resourcesMarker);
|
||||||
|
if (resStart === -1) {
|
||||||
|
throw new Error("Could not find `const resources: Resource` in index.ts");
|
||||||
|
}
|
||||||
|
const resAfter = i18nIndexTs.slice(resStart + resourcesMarker.length);
|
||||||
|
const resEndIndex = resAfter.indexOf("};");
|
||||||
|
if (resEndIndex === -1) {
|
||||||
|
throw new Error("Malformed resources object in index.ts");
|
||||||
|
}
|
||||||
|
const resourcesBlock = resAfter.slice(0, resEndIndex);
|
||||||
|
|
||||||
|
// Extract top-level language keys: lines starting with two spaces then <code>:
|
||||||
|
// Example: " en: {" or " fr: {"
|
||||||
|
const wiredLangs = new Set();
|
||||||
|
const lineRegex = /^\s*([a-z]{2}):\s*{\s*$/gm;
|
||||||
|
let lm;
|
||||||
|
while ((lm = lineRegex.exec(resourcesBlock)) !== null) {
|
||||||
|
wiredLangs.add(lm[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Languages wired in i18n resources: ${Array.from(wiredLangs)
|
||||||
|
.sort()
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now compare SUPPORTED_LANGUAGES vs wired resources.
|
||||||
|
const missingWiring = [];
|
||||||
|
for (const code of supportedFromTs) {
|
||||||
|
if (FUTURE_LANGUAGES.has(code)) {
|
||||||
|
// These are explicitly allowed to be missing until their
|
||||||
|
// translations and wiring land.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!wiredLangs.has(code)) {
|
||||||
|
missingWiring.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingWiring.length > 0) {
|
||||||
|
hadIssues = true;
|
||||||
|
console.error(
|
||||||
|
"\nERROR: Some SUPPORTED_LANGUAGES codes are not wired into the i18n `resources` object in src/i18n/index.ts:",
|
||||||
|
);
|
||||||
|
for (const code of missingWiring.sort()) {
|
||||||
|
console.error(` - ${code}`);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
"These languages will silently fall back to English at runtime. Ensure that:",
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
" 1) ./resources/<code>/{common,home,chat}.json exist, and",
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
" 2) They are imported and registered in the `resources` object.",
|
||||||
|
);
|
||||||
|
console.error("");
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"OK: Every SUPPORTED_LANGUAGES entry (excluding future languages) is wired into the i18n resources object.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
hadIssues = true;
|
||||||
|
console.error(
|
||||||
|
"ERROR: Failed while checking SUPPORTED_LANGUAGES wiring in i18n index:",
|
||||||
|
err.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ns of NAMESPACES) {
|
||||||
|
logHeader(`Namespace: ${ns}`);
|
||||||
|
|
||||||
|
const basePath = path.join(RESOURCES_DIR, "en", `${ns}.json`);
|
||||||
|
let baseJson;
|
||||||
|
try {
|
||||||
|
baseJson = readJson(basePath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
hadIssues = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseKeys = collectKeyPaths(baseJson);
|
||||||
|
console.log(`Baseline (en) keys: ${baseKeys.size}`);
|
||||||
|
|
||||||
|
for (const lang of LANGUAGES) {
|
||||||
|
if (lang === "en") continue;
|
||||||
|
|
||||||
|
const langPath = path.join(RESOURCES_DIR, lang, `${ns}.json`);
|
||||||
|
if (!fs.existsSync(langPath)) {
|
||||||
|
console.error(` [${lang}] MISSING file: ${langPath}`);
|
||||||
|
hadIssues = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let langJson;
|
||||||
|
try {
|
||||||
|
langJson = readJson(langPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` [${lang}] ${err.message}`);
|
||||||
|
hadIssues = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const langKeys = collectKeyPaths(langJson);
|
||||||
|
const { missing, extra } = diffKeys(baseKeys, langKeys);
|
||||||
|
|
||||||
|
if (missing.length === 0 && extra.length === 0) {
|
||||||
|
console.log(` [${lang}] OK (keys: ${langKeys.size})`);
|
||||||
|
} else {
|
||||||
|
hadIssues = true;
|
||||||
|
console.log(` [${lang}] Issues found:`);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.log(" Missing keys (present in en, absent here):");
|
||||||
|
for (const k of missing) {
|
||||||
|
console.log(` - ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extra.length > 0) {
|
||||||
|
console.log(" Extra keys (present here, absent in en):");
|
||||||
|
for (const k of extra) {
|
||||||
|
console.log(` + ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
if (hadIssues) {
|
||||||
|
console.error("i18n check completed with inconsistencies.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log("i18n check completed successfully. All keys are consistent.");
|
||||||
|
process.exitCode = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Unexpected error while running i18n check:", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
249
helexa.ai/scripts/check-i18n-lang-labels.mjs
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* helexa.ai i18n language label consistency check
|
||||||
|
*
|
||||||
|
* This script validates that for every supported language code in
|
||||||
|
* `SUPPORTED_LANGUAGES`:
|
||||||
|
*
|
||||||
|
* 1. The English `common.lang` map (`src/i18n/resources/en/common.json`)
|
||||||
|
* contains a human-readable label at `lang.<code>`.
|
||||||
|
* 2. Optionally (and more leniently), other languages that define a
|
||||||
|
* `lang` block do not omit supported language codes.
|
||||||
|
*
|
||||||
|
* At minimum, it enforces that the English UI always has labels for all
|
||||||
|
* supported languages, since the header language selector renders
|
||||||
|
* `t("lang.<code>")` using the active language.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* - 0: all checks pass
|
||||||
|
* - 1: one or more inconsistencies found
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import url from "url";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
|
||||||
|
const I18N_DIR = path.join(ROOT, "src", "i18n");
|
||||||
|
const LANGUAGES_TS = path.join(I18N_DIR, "languages.ts");
|
||||||
|
const EN_COMMON_JSON = path.join(I18N_DIR, "resources", "en", "common.json");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility: read a file as UTF-8 or throw with a helpful message.
|
||||||
|
*/
|
||||||
|
function readFileOrDie(filePath) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filePath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to read ${filePath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SUPPORTED_LANGUAGES from languages.ts.
|
||||||
|
*
|
||||||
|
* Expects a definition like:
|
||||||
|
* export const SUPPORTED_LANGUAGES: LanguageCode[] = [
|
||||||
|
* "en",
|
||||||
|
* "bg",
|
||||||
|
* ];
|
||||||
|
*/
|
||||||
|
function parseSupportedLanguages(source) {
|
||||||
|
const marker = "export const SUPPORTED_LANGUAGES";
|
||||||
|
const start = source.indexOf(marker);
|
||||||
|
if (start === -1) {
|
||||||
|
throw new Error("Could not find `SUPPORTED_LANGUAGES` in languages.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = source.slice(start);
|
||||||
|
const bracketIndex = after.indexOf("[");
|
||||||
|
const closingIndex = after.indexOf("];");
|
||||||
|
if (bracketIndex === -1 || closingIndex === -1) {
|
||||||
|
throw new Error("Malformed SUPPORTED_LANGUAGES array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||||
|
const codes = new Set();
|
||||||
|
const regex = /"([^"]+)"/g;
|
||||||
|
let m;
|
||||||
|
while ((m = regex.exec(arraySlice)) !== null) {
|
||||||
|
codes.add(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codes.size === 0) {
|
||||||
|
throw new Error("No entries found in SUPPORTED_LANGUAGES");
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely read and parse a JSON file.
|
||||||
|
*/
|
||||||
|
function readJsonOrDie(filePath) {
|
||||||
|
const raw = readFileOrDie(filePath);
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to parse JSON at ${filePath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute set difference: a \ b
|
||||||
|
*/
|
||||||
|
function difference(a, b) {
|
||||||
|
const result = new Set();
|
||||||
|
for (const x of a) {
|
||||||
|
if (!b.has(x)) result.add(x);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: sorted array from a Set.
|
||||||
|
*/
|
||||||
|
function toSortedArray(set) {
|
||||||
|
return [...set].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
console.log("helexa.ai i18n language label check");
|
||||||
|
console.log(`Root: ${ROOT}`);
|
||||||
|
console.log(`Languages file: ${LANGUAGES_TS}`);
|
||||||
|
console.log(`English common.json: ${EN_COMMON_JSON}`);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
let hadIssues = false;
|
||||||
|
|
||||||
|
// 1) Load SUPPORTED_LANGUAGES
|
||||||
|
let languagesSource;
|
||||||
|
try {
|
||||||
|
languagesSource = readFileOrDie(LANGUAGES_TS);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let supportedLanguages;
|
||||||
|
try {
|
||||||
|
supportedLanguages = parseSupportedLanguages(languagesSource);
|
||||||
|
console.log(
|
||||||
|
`SUPPORTED_LANGUAGES (${supportedLanguages.size}): ${toSortedArray(
|
||||||
|
supportedLanguages,
|
||||||
|
).join(", ")}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`ERROR: ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
// 2) Load English common.json and extract lang map
|
||||||
|
let enCommon;
|
||||||
|
try {
|
||||||
|
enCommon = readJsonOrDie(EN_COMMON_JSON);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`ERROR: ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enLangMap = enCommon?.lang ?? {};
|
||||||
|
if (!enLangMap || typeof enLangMap !== "object") {
|
||||||
|
console.error("ERROR: `en/common.json` does not contain a `lang` object.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enLangKeys = new Set(Object.keys(enLangMap));
|
||||||
|
console.log(
|
||||||
|
`English lang map keys (${enLangKeys.size}): ${toSortedArray(
|
||||||
|
enLangKeys,
|
||||||
|
).join(", ")}`,
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
// 3) Ensure every supported language has a corresponding key in en.lang
|
||||||
|
const missingInEnglish = difference(supportedLanguages, enLangKeys);
|
||||||
|
if (missingInEnglish.size > 0) {
|
||||||
|
hadIssues = true;
|
||||||
|
console.error(
|
||||||
|
"ERROR: The following supported languages are missing labels in `en/common.json` under `lang`:",
|
||||||
|
);
|
||||||
|
for (const code of toSortedArray(missingInEnglish)) {
|
||||||
|
console.error(` - lang.${code}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"OK: Every supported language has a `lang.<code>` entry in `en/common.json`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
// 4) Optional: scan other locales for informational consistency
|
||||||
|
// (non-fatal, just warnings).
|
||||||
|
const resourcesDir = path.join(I18N_DIR, "resources");
|
||||||
|
let otherLocales = [];
|
||||||
|
try {
|
||||||
|
otherLocales = fs
|
||||||
|
.readdirSync(resourcesDir, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory() && d.name !== "en")
|
||||||
|
.map((d) => d.name);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`WARN: Could not list locales in ${resourcesDir}: ${err.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const locale of otherLocales) {
|
||||||
|
const filePath = path.join(resourcesDir, locale, "common.json");
|
||||||
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
|
||||||
|
let localeJson;
|
||||||
|
try {
|
||||||
|
localeJson = readJsonOrDie(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`WARN: Failed to read ${filePath}: ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const langObj = localeJson?.lang;
|
||||||
|
if (!langObj || typeof langObj !== "object") {
|
||||||
|
// Not all locales need to maintain a full lang map; skip silently.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeKeys = new Set(Object.keys(langObj));
|
||||||
|
const missingHere = difference(supportedLanguages, localeKeys);
|
||||||
|
if (missingHere.size > 0) {
|
||||||
|
console.warn(
|
||||||
|
`WARN: Locale '${locale}' is missing some supported language labels under "lang":`,
|
||||||
|
);
|
||||||
|
for (const code of toSortedArray(missingHere)) {
|
||||||
|
console.warn(` - lang.${code}`);
|
||||||
|
}
|
||||||
|
console.warn("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadIssues) {
|
||||||
|
console.error("Language label check completed with inconsistencies.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log("Language label check completed successfully.");
|
||||||
|
process.exitCode = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Unexpected error while running language label check:", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
327
helexa.ai/scripts/check-i18n-metadata.mjs
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* helexa.ai i18n metadata consistency check
|
||||||
|
*
|
||||||
|
* This script validates that:
|
||||||
|
* - Every `LanguageCode` used in the project appears in `TRANSLATION_PRIORITY`
|
||||||
|
* - Every `TRANSLATION_PRIORITY` entry refers to a valid `LanguageCode`
|
||||||
|
* - `REMAINING_LANGUAGES` is a subset of `LanguageCode`
|
||||||
|
* - `REMAINING_LANGUAGES` is disjoint from `SUPPORTED_LANGUAGES`
|
||||||
|
*
|
||||||
|
* It is intentionally implemented as a standalone Node script (no TypeScript
|
||||||
|
* build step required) and does a very lightweight parse of the TypeScript
|
||||||
|
* source files to avoid pulling in a full TS compiler.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* - 0: all checks pass
|
||||||
|
* - 1: one or more metadata inconsistencies found
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import url from "url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve project root as the directory containing this script.
|
||||||
|
*/
|
||||||
|
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
|
||||||
|
const I18N_DIR = path.join(ROOT, "src", "i18n");
|
||||||
|
const LANGUAGES_TS = path.join(I18N_DIR, "languages.ts");
|
||||||
|
const PRIORITY_TS = path.join(I18N_DIR, "translation-priority.ts");
|
||||||
|
|
||||||
|
function readFileOrDie(filePath) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filePath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to read ${filePath}: ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract union members from a definition like:
|
||||||
|
*
|
||||||
|
* export type LanguageCode =
|
||||||
|
* | "en"
|
||||||
|
* | "bg"
|
||||||
|
* | "cs";
|
||||||
|
*/
|
||||||
|
function parseLanguageCodeUnion(source) {
|
||||||
|
const start = source.indexOf("export type LanguageCode");
|
||||||
|
if (start === -1) {
|
||||||
|
throw new Error("Could not find `export type LanguageCode` in languages.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = source.slice(start);
|
||||||
|
const eqIndex = after.indexOf("=");
|
||||||
|
if (eqIndex === -1) {
|
||||||
|
throw new Error("Malformed LanguageCode definition (no '=')");
|
||||||
|
}
|
||||||
|
|
||||||
|
const unionBlock = after.slice(eqIndex + 1);
|
||||||
|
const semicolonIndex = unionBlock.indexOf(";");
|
||||||
|
const unionSlice = semicolonIndex === -1 ? unionBlock : unionBlock.slice(0, semicolonIndex);
|
||||||
|
|
||||||
|
const codes = new Set();
|
||||||
|
const regex = /\|\s*"([^"]+)"/g;
|
||||||
|
let m;
|
||||||
|
while ((m = regex.exec(unionSlice)) !== null) {
|
||||||
|
codes.add(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codes.size === 0) {
|
||||||
|
throw new Error("No language codes found in LanguageCode union");
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SUPPORTED_LANGUAGES from languages.ts
|
||||||
|
*
|
||||||
|
* export const SUPPORTED_LANGUAGES: LanguageCode[] = [
|
||||||
|
* "en",
|
||||||
|
* "bg",
|
||||||
|
* ];
|
||||||
|
*/
|
||||||
|
function parseSupportedLanguages(source) {
|
||||||
|
const marker = "export const SUPPORTED_LANGUAGES";
|
||||||
|
const start = source.indexOf(marker);
|
||||||
|
if (start === -1) {
|
||||||
|
throw new Error("Could not find `SUPPORTED_LANGUAGES` in languages.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = source.slice(start);
|
||||||
|
const bracketIndex = after.indexOf("[");
|
||||||
|
const closingIndex = after.indexOf("];");
|
||||||
|
if (bracketIndex === -1 || closingIndex === -1) {
|
||||||
|
throw new Error("Malformed SUPPORTED_LANGUAGES array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||||
|
const codes = new Set();
|
||||||
|
const regex = /"([^"]+)"/g;
|
||||||
|
let m;
|
||||||
|
while ((m = regex.exec(arraySlice)) !== null) {
|
||||||
|
codes.add(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codes.size === 0) {
|
||||||
|
throw new Error("No entries found in SUPPORTED_LANGUAGES");
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse REMAINING_LANGUAGES from translation-priority.ts
|
||||||
|
*
|
||||||
|
* export const REMAINING_LANGUAGES: LanguageCode[] = [
|
||||||
|
* "tr",
|
||||||
|
* "pl",
|
||||||
|
* ];
|
||||||
|
*/
|
||||||
|
function parseRemainingLanguages(source) {
|
||||||
|
const marker = "export const REMAINING_LANGUAGES";
|
||||||
|
const start = source.indexOf(marker);
|
||||||
|
if (start === -1) {
|
||||||
|
// It's valid for this list to not exist; treat as empty if missing
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = source.slice(start);
|
||||||
|
const bracketIndex = after.indexOf("[");
|
||||||
|
const closingIndex = after.indexOf("];");
|
||||||
|
if (bracketIndex === -1 || closingIndex === -1) {
|
||||||
|
throw new Error("Malformed REMAINING_LANGUAGES array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||||
|
const codes = new Set();
|
||||||
|
const regex = /"([^"]+)"/g;
|
||||||
|
let m;
|
||||||
|
while ((m = regex.exec(arraySlice)) !== null) {
|
||||||
|
codes.add(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse TRANSLATION_PRIORITY entries from translation-priority.ts
|
||||||
|
*
|
||||||
|
* export const TRANSLATION_PRIORITY: TranslationPriorityEntry[] = [
|
||||||
|
* {
|
||||||
|
* code: "tr",
|
||||||
|
* bucket: "high",
|
||||||
|
* nativeSpeakers: "70–90M",
|
||||||
|
* },
|
||||||
|
* ...
|
||||||
|
* ];
|
||||||
|
*/
|
||||||
|
function parseTranslationPriority(source) {
|
||||||
|
const marker = "export const TRANSLATION_PRIORITY";
|
||||||
|
const start = source.indexOf(marker);
|
||||||
|
if (start === -1) {
|
||||||
|
throw new Error("Could not find `TRANSLATION_PRIORITY` in translation-priority.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = source.slice(start);
|
||||||
|
const bracketIndex = after.indexOf("[");
|
||||||
|
const closingIndex = after.indexOf("];");
|
||||||
|
if (bracketIndex === -1 || closingIndex === -1) {
|
||||||
|
throw new Error("Malformed TRANSLATION_PRIORITY array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||||
|
|
||||||
|
// Simple heuristic: find code: "<value>" inside objects
|
||||||
|
const codes = new Set();
|
||||||
|
const regex = /code:\s*"([^"]+)"/g;
|
||||||
|
let m;
|
||||||
|
while ((m = regex.exec(arraySlice)) !== null) {
|
||||||
|
codes.add(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codes.size === 0) {
|
||||||
|
throw new Error("No `code` entries found in TRANSLATION_PRIORITY");
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function difference(a, b) {
|
||||||
|
const result = new Set();
|
||||||
|
for (const x of a) {
|
||||||
|
if (!b.has(x)) result.add(x);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSortedArray(set) {
|
||||||
|
return [...set].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
console.log("helexa.ai i18n metadata consistency check");
|
||||||
|
console.log(`Root: ${ROOT}`);
|
||||||
|
console.log(`Languages file: ${LANGUAGES_TS}`);
|
||||||
|
console.log(`Priority file: ${PRIORITY_TS}`);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const languagesSource = readFileOrDie(LANGUAGES_TS);
|
||||||
|
const prioritySource = readFileOrDie(PRIORITY_TS);
|
||||||
|
|
||||||
|
let hadIssues = false;
|
||||||
|
|
||||||
|
let languageCodes;
|
||||||
|
let supportedLanguages;
|
||||||
|
let remainingLanguages;
|
||||||
|
let priorityCodes;
|
||||||
|
|
||||||
|
try {
|
||||||
|
languageCodes = parseLanguageCodeUnion(languagesSource);
|
||||||
|
console.log(`LanguageCode entries: ${languageCodes.size}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`ERROR: ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
supportedLanguages = parseSupportedLanguages(languagesSource);
|
||||||
|
console.log(`SUPPORTED_LANGUAGES entries: ${supportedLanguages.size}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`ERROR: ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
remainingLanguages = parseRemainingLanguages(prioritySource);
|
||||||
|
console.log(`REMAINING_LANGUAGES entries: ${remainingLanguages.size}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`ERROR: ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
priorityCodes = parseTranslationPriority(prioritySource);
|
||||||
|
console.log(`TRANSLATION_PRIORITY entries: ${priorityCodes.size}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`ERROR: ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
// 1) Every LanguageCode should have a TRANSLATION_PRIORITY entry
|
||||||
|
const missingInPriority = difference(languageCodes, priorityCodes);
|
||||||
|
if (missingInPriority.size > 0) {
|
||||||
|
hadIssues = true;
|
||||||
|
console.error("ERROR: The following LanguageCode values are missing from TRANSLATION_PRIORITY:");
|
||||||
|
for (const code of toSortedArray(missingInPriority)) {
|
||||||
|
console.error(` - ${code}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("OK: All LanguageCode values are present in TRANSLATION_PRIORITY.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Every TRANSLATION_PRIORITY code must be a valid LanguageCode
|
||||||
|
const unknownPriorityCodes = difference(priorityCodes, languageCodes);
|
||||||
|
if (unknownPriorityCodes.size > 0) {
|
||||||
|
hadIssues = true;
|
||||||
|
console.error("ERROR: The following TRANSLATION_PRIORITY codes are not present in LanguageCode:");
|
||||||
|
for (const code of toSortedArray(unknownPriorityCodes)) {
|
||||||
|
console.error(` - ${code}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("OK: All TRANSLATION_PRIORITY codes are valid LanguageCode values.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) REMAINING_LANGUAGES must be subset of LanguageCode
|
||||||
|
const remainingNotInLanguageCode = difference(remainingLanguages, languageCodes);
|
||||||
|
if (remainingNotInLanguageCode.size > 0) {
|
||||||
|
hadIssues = true;
|
||||||
|
console.error("ERROR: The following REMAINING_LANGUAGES entries are not in LanguageCode:");
|
||||||
|
for (const code of toSortedArray(remainingNotInLanguageCode)) {
|
||||||
|
console.error(` - ${code}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("OK: All REMAINING_LANGUAGES are valid LanguageCode values.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) REMAINING_LANGUAGES must be disjoint from SUPPORTED_LANGUAGES
|
||||||
|
const remainingThatAreSupported = difference(remainingLanguages, difference(remainingLanguages, supportedLanguages));
|
||||||
|
// Equivalent to intersection(remainingLanguages, supportedLanguages)
|
||||||
|
if (remainingThatAreSupported.size > 0) {
|
||||||
|
hadIssues = true;
|
||||||
|
console.error("ERROR: The following REMAINING_LANGUAGES are already in SUPPORTED_LANGUAGES:");
|
||||||
|
for (const code of toSortedArray(remainingThatAreSupported)) {
|
||||||
|
console.error(` - ${code}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("OK: REMAINING_LANGUAGES does not include any SUPPORTED_LANGUAGES.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
if (hadIssues) {
|
||||||
|
console.error("i18n metadata check completed with inconsistencies.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log("i18n metadata check completed successfully. All metadata is consistent.");
|
||||||
|
process.exitCode = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Unexpected error while running i18n metadata check:", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
411
helexa.ai/src/App.css
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
:root {
|
||||||
|
--color-bg: #f5f7fb;
|
||||||
|
--color-bg-elevated: #ffffff;
|
||||||
|
--color-bg-subtle: #eef1f7;
|
||||||
|
--color-border-subtle: rgba(15, 23, 42, 0.12);
|
||||||
|
--color-text: #020617;
|
||||||
|
--color-text-muted: #64748b;
|
||||||
|
--color-accent: #22d3ee;
|
||||||
|
--color-accent-hot: #ec4899;
|
||||||
|
--color-accent-soft: rgba(34, 211, 238, 0.12);
|
||||||
|
--color-navbar-bg: rgba(255, 255, 255, 0.85);
|
||||||
|
--color-footer-bg: rgba(255, 255, 255, 0.9);
|
||||||
|
--shadow-soft: 0 18px 45px rgba(15, 23, 42, 0.08);
|
||||||
|
--transition-fast: 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme overrides, driven by ThemeProvider via [data-theme] */
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--color-bg: #000618;
|
||||||
|
--color-bg-elevated: #020617;
|
||||||
|
--color-bg-subtle: #020617;
|
||||||
|
--color-border-subtle: rgba(148, 163, 184, 0.24);
|
||||||
|
--color-text: #e2e8f0;
|
||||||
|
--color-text-muted: #cbd5f5;
|
||||||
|
--color-accent: #22d3ee;
|
||||||
|
--color-accent-soft: rgba(34, 211, 238, 0.18);
|
||||||
|
--color-navbar-bg: rgba(2, 6, 23, 0.92);
|
||||||
|
--color-footer-bg: rgba(2, 6, 23, 0.96);
|
||||||
|
--shadow-soft: 0 22px 55px rgba(15, 23, 42, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global base styles */
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"SF Pro Text",
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Basic RTL reset: when the root document is RTL, ensure logical direction */
|
||||||
|
html[dir="rtl"] body {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography helpers */
|
||||||
|
|
||||||
|
.app-main h1,
|
||||||
|
.app-main h2,
|
||||||
|
.app-main h3,
|
||||||
|
.app-main h4,
|
||||||
|
.app-main h5 {
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main p.lead {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hot pink accent utility for occasional emphasis */
|
||||||
|
.text-hot-pink {
|
||||||
|
color: var(--color-accent-hot) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header / Navbar */
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background-color: var(--color-navbar-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 1px 0 var(--color-border-subtle);
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .navbar-brand:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .nav-link {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .nav-link:hover,
|
||||||
|
.app-header .nav-link:focus {
|
||||||
|
color: var(--color-accent);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .nav-link.active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .btn.btn-outline-secondary {
|
||||||
|
--bs-btn-color: var(--color-text-muted);
|
||||||
|
--bs-btn-border-color: var(--color-border-subtle);
|
||||||
|
--bs-btn-hover-bg: var(--color-accent-soft);
|
||||||
|
--bs-btn-hover-border-color: var(--color-accent);
|
||||||
|
--bs-btn-hover-color: var(--color-accent);
|
||||||
|
--bs-btn-focus-shadow-rgb: 34, 211, 238;
|
||||||
|
padding-inline: 0.55rem;
|
||||||
|
padding-block: 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Language dropdown: theme-aware styling */
|
||||||
|
.app-header .dropdown-menu-dark {
|
||||||
|
--bs-dropdown-bg: var(--color-bg-elevated);
|
||||||
|
--bs-dropdown-link-color: var(--color-text);
|
||||||
|
--bs-dropdown-link-hover-bg: var(--color-accent-soft);
|
||||||
|
--bs-dropdown-link-hover-color: var(--color-accent);
|
||||||
|
--bs-dropdown-link-active-bg: var(--color-accent-soft);
|
||||||
|
--bs-dropdown-link-active-color: var(--color-accent);
|
||||||
|
--bs-dropdown-border-color: var(--color-border-subtle);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent language dropdown from overflowing off-screen in RTL */
|
||||||
|
html[dir="rtl"] .app-header .dropdown-menu-end {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .dropdown-menu-dark .dropdown-item.active,
|
||||||
|
.app-header .dropdown-menu-dark .dropdown-item:active {
|
||||||
|
background-color: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .dropdown-menu-dark-context .dropdown-toggle.btn-secondary {
|
||||||
|
--bs-btn-bg: transparent;
|
||||||
|
--bs-btn-border-color: var(--color-border-subtle);
|
||||||
|
--bs-btn-color: var(--color-text-muted);
|
||||||
|
--bs-btn-hover-bg: var(--color-accent-soft);
|
||||||
|
--bs-btn-hover-border-color: var(--color-accent);
|
||||||
|
--bs-btn-hover-color: var(--color-accent);
|
||||||
|
--bs-btn-focus-shadow-rgb: 34, 211, 238;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .btn.btn-outline-secondary svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic helper class for mirroring directional icons in RTL.
|
||||||
|
Used by DirectionalIcon when `mirrorInRtl` is enabled. */
|
||||||
|
html[dir="rtl"] .diricon-mirror-rtl {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provide a visible toggler icon when using default Bootstrap styles */
|
||||||
|
.navbar-toggler {
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggler-icon {
|
||||||
|
background-image: none;
|
||||||
|
position: relative;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggler-icon::before,
|
||||||
|
.navbar-toggler-icon::after,
|
||||||
|
.navbar-toggler-icon span {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--color-text);
|
||||||
|
transition:
|
||||||
|
transform 150ms ease-out,
|
||||||
|
opacity 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggler-icon::before {
|
||||||
|
top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggler-icon::after {
|
||||||
|
bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
background-color: var(--color-footer-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 -1px 0 var(--color-border-subtle);
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content layout */
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
max-width: 1120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When in RTL mode, flip common flex directions and alignment where needed */
|
||||||
|
html[dir="rtl"] .app-root {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] .app-header .navbar-brand {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] .app-header .nav-link {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] .app-header .d-flex.align-items-center.gap-2 {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] .app-main .card-body {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure footer content also respects RTL direction */
|
||||||
|
html[dir="rtl"] .app-footer {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .card,
|
||||||
|
.app-main .card-body {
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic utility styles */
|
||||||
|
|
||||||
|
.surface-elevated {
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mesh / principle icon containers */
|
||||||
|
.mesh-icon,
|
||||||
|
.principle-icon {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
background: radial-gradient(
|
||||||
|
circle,
|
||||||
|
var(--color-accent-soft),
|
||||||
|
transparent 60%
|
||||||
|
);
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principle-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA background texture */
|
||||||
|
.join-mesh-cta {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at top,
|
||||||
|
rgba(34, 211, 238, 0.18),
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
url("/bg-logo-right.png") center/cover no-repeat,
|
||||||
|
var(--color-bg-elevated);
|
||||||
|
background-blend-mode: screen, normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-accent {
|
||||||
|
background-color: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure muted text uses our theme variable instead of Bootstrap defaults */
|
||||||
|
.text-muted,
|
||||||
|
small {
|
||||||
|
color: var(--color-text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small cyan dot used inside hero and CTA badges */
|
||||||
|
.bg-cyan-500-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle,
|
||||||
|
var(--color-accent) 0%,
|
||||||
|
transparent 65%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar theming (WebKit) */
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(148, 163, 184, 0.6);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] *::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(148, 163, 184, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks / preformatted text placeholders */
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code {
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
"Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: var(--color-bg-subtle);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small-screen adjustments */
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.app-main {
|
||||||
|
padding-left: 1rem !important;
|
||||||
|
padding-right: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .navbar-brand {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
helexa.ai/src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import { Container } from "react-bootstrap";
|
||||||
|
import ThemeProvider from "./layout/ThemeProvider";
|
||||||
|
import Header from "./components/Header";
|
||||||
|
import Footer from "./components/Footer";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
// F1 composition root: theme + router + layout shell. The chat workspace
|
||||||
|
// (`/`, F3), `/mission` (F2), and the auth/account routes (F4) replace these
|
||||||
|
// placeholders in later phases.
|
||||||
|
function Placeholder({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<Container className="py-5 flex-grow-1">
|
||||||
|
<h1 className="mb-2">{title}</h1>
|
||||||
|
<p className="text-muted">helexa public beta — coming online.</p>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="d-flex flex-column min-vh-100">
|
||||||
|
<Header />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Placeholder title="Chat" />} />
|
||||||
|
<Route path="/mission" element={<Placeholder title="Mission" />} />
|
||||||
|
</Routes>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
helexa.ai/src/components/DirectionalIcon.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { isRtlLanguage, type LanguageCode } from "../i18n/languages";
|
||||||
|
|
||||||
|
export type Direction = "forward" | "back";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DirectionalIcon
|
||||||
|
*
|
||||||
|
* Small helper component to render direction-aware icons that respect
|
||||||
|
* the current UI writing direction (LTR vs RTL).
|
||||||
|
*
|
||||||
|
* Usage example:
|
||||||
|
*
|
||||||
|
* <DirectionalIcon
|
||||||
|
* direction="forward"
|
||||||
|
* ltrIcon={FaArrowRight}
|
||||||
|
* rtlIcon={FaArrowLeft}
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* - `direction="forward"` means “toward the natural reading direction”
|
||||||
|
* (right in LTR, left in RTL).
|
||||||
|
* - `direction="back"` means the opposite (left in LTR, right in RTL).
|
||||||
|
*
|
||||||
|
* You can either:
|
||||||
|
* - pass explicit `ltrIcon` and `rtlIcon` React components, or
|
||||||
|
* - pass a single `icon` component and set `mirrorInRtl` to flip it
|
||||||
|
* horizontally when in RTL (via CSS transform).
|
||||||
|
*
|
||||||
|
* In most cases, using explicit LTR / RTL icons is clearer and avoids
|
||||||
|
* surprises with asymmetric icon shapes.
|
||||||
|
*/
|
||||||
|
export interface DirectionalIconProps {
|
||||||
|
/**
|
||||||
|
* Logical direction relative to reading order.
|
||||||
|
* - "forward": in the direction of the text flow
|
||||||
|
* - "back": opposite the direction of the text flow
|
||||||
|
*/
|
||||||
|
direction: Direction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon component to use for LTR contexts (e.g. FaArrowRight).
|
||||||
|
*/
|
||||||
|
ltrIcon?: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon component to use for RTL contexts (e.g. FaArrowLeft).
|
||||||
|
*/
|
||||||
|
rtlIcon?: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single base icon component. When provided together with
|
||||||
|
* `mirrorInRtl={true}`, it will be mirrored horizontally in RTL.
|
||||||
|
*/
|
||||||
|
icon?: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to flip the `icon` horizontally in RTL.
|
||||||
|
* Ignored if both `ltrIcon` and `rtlIcon` are supplied.
|
||||||
|
*/
|
||||||
|
mirrorInRtl?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional size forwarded to the rendered icon.
|
||||||
|
*/
|
||||||
|
size?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional className to apply to the rendered icon.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if current language is RTL based on i18next language code.
|
||||||
|
*
|
||||||
|
* Delegates to the shared `isRtlLanguage` helper from i18n/languages.ts
|
||||||
|
* so that all RTL logic lives in one place.
|
||||||
|
*/
|
||||||
|
const isRtlLanguageCode = (code: string | undefined | null): boolean => {
|
||||||
|
if (!code) return false;
|
||||||
|
const lang = code.split("-")[0].toLowerCase() as LanguageCode;
|
||||||
|
return isRtlLanguage(lang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DirectionalIcon: React.FC<DirectionalIconProps> = ({
|
||||||
|
direction,
|
||||||
|
ltrIcon: LtrIcon,
|
||||||
|
rtlIcon: RtlIcon,
|
||||||
|
icon: BaseIcon,
|
||||||
|
mirrorInRtl = false,
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const isRtl = isRtlLanguageCode(i18n.language);
|
||||||
|
|
||||||
|
// If explicit LTR/RTL icons are provided, prefer those.
|
||||||
|
if (LtrIcon && RtlIcon) {
|
||||||
|
const IconComponent =
|
||||||
|
(direction === "forward" && !isRtl) || (direction === "back" && isRtl)
|
||||||
|
? LtrIcon
|
||||||
|
: RtlIcon;
|
||||||
|
|
||||||
|
return <IconComponent size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: single base icon, optionally mirrored in RTL.
|
||||||
|
if (!BaseIcon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldMirror =
|
||||||
|
mirrorInRtl &&
|
||||||
|
((direction === "forward" && isRtl) || (direction === "back" && !isRtl));
|
||||||
|
|
||||||
|
const combinedClassName = [
|
||||||
|
className,
|
||||||
|
shouldMirror ? "diricon-mirror-rtl" : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return <BaseIcon size={size} className={combinedClassName} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DirectionalIcon;
|
||||||
23
helexa.ai/src/components/Footer.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footer
|
||||||
|
*
|
||||||
|
* Simple application footer used in the main layout.
|
||||||
|
* Renders a subtle, theme-aware bar with copyright text.
|
||||||
|
*/
|
||||||
|
const Footer: React.FC = () => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="app-footer border-top py-3 mt-auto">
|
||||||
|
<div className="container-fluid text-center text-muted small">
|
||||||
|
<span>{t("footer.copyright", { year })}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
140
helexa.ai/src/components/Header.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Link, NavLink } from "react-router-dom";
|
||||||
|
import { Navbar, Container, Nav, Button, Dropdown } from "react-bootstrap";
|
||||||
|
import { FaRegMoon, FaRegSun } from "react-icons/fa6";
|
||||||
|
import { useTheme } from "../layout/theme";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AUTONYM_MAP, type LanguageCode, isRtlLanguage } from "../i18n/languages";
|
||||||
|
import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top navigation: brand, primary routes (chat at `/`, `/mission`), an
|
||||||
|
* auth-aware cluster (stubbed until F4 wires sessions), the theme toggle,
|
||||||
|
* and the language selector.
|
||||||
|
*
|
||||||
|
* The language picker is ordered by **estimated usage**
|
||||||
|
* (getLanguageOptionsByUsage), not alphabetically — a deliberate choice that
|
||||||
|
* foregrounds helexa's international grounding. Each item shows the autonym
|
||||||
|
* (language in its own script) plus a secondary label in the current
|
||||||
|
* language; RTL-aware alignment.
|
||||||
|
*/
|
||||||
|
const Header: React.FC = () => {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { t, i18n } = useTranslation("common");
|
||||||
|
|
||||||
|
const currentLanguage: LanguageCode = (i18n.language.split("-")[0] ||
|
||||||
|
"en") as LanguageCode;
|
||||||
|
const isRtl = isRtlLanguage(currentLanguage);
|
||||||
|
const languageOptions = getLanguageOptionsByUsage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar
|
||||||
|
expand="lg"
|
||||||
|
className="app-header border-bottom"
|
||||||
|
variant={theme === "dark" ? "dark" : "light"}
|
||||||
|
>
|
||||||
|
<Container fluid>
|
||||||
|
<Navbar.Brand
|
||||||
|
as={Link}
|
||||||
|
to="/"
|
||||||
|
className="d-flex align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="helexa logo"
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
style={{ borderRadius: "999px" }}
|
||||||
|
/>
|
||||||
|
<span className="fw-semibold text-uppercase small tracking-wide">
|
||||||
|
{t("app.name")}
|
||||||
|
</span>
|
||||||
|
</Navbar.Brand>
|
||||||
|
|
||||||
|
<Navbar.Toggle aria-controls="main-navbar" />
|
||||||
|
|
||||||
|
<Navbar.Collapse id="main-navbar">
|
||||||
|
<Nav className="me-auto">
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
end
|
||||||
|
className={({ isActive }): string =>
|
||||||
|
isActive ? "nav-link active" : "nav-link"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("nav.chat")}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/mission"
|
||||||
|
className={({ isActive }): string =>
|
||||||
|
isActive ? "nav-link active" : "nav-link"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("nav.mission")}
|
||||||
|
</NavLink>
|
||||||
|
</Nav>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{/* Auth cluster — plain links until F4 wires session state. */}
|
||||||
|
<NavLink to="/login" className="nav-link">
|
||||||
|
{t("nav.login")}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/register" className="nav-link">
|
||||||
|
{t("nav.register")}
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline-secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={
|
||||||
|
theme === "dark"
|
||||||
|
? t("theme.toggle.toLight")
|
||||||
|
: t("theme.toggle.toDark")
|
||||||
|
}
|
||||||
|
className="d-inline-flex align-items-center justify-content-center"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <FaRegSun size={16} /> : <FaRegMoon size={16} />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
align={isRtl ? "start" : "end"}
|
||||||
|
className={theme === "dark" ? "dropdown-menu-dark-context" : ""}
|
||||||
|
>
|
||||||
|
<Dropdown.Toggle
|
||||||
|
size="sm"
|
||||||
|
variant={theme === "dark" ? "secondary" : "outline-secondary"}
|
||||||
|
id="language-switcher"
|
||||||
|
>
|
||||||
|
<span className="me-1" aria-hidden="true">
|
||||||
|
文A
|
||||||
|
</span>
|
||||||
|
<span>{AUTONYM_MAP[currentLanguage]}</span>
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu
|
||||||
|
className={theme === "dark" ? "dropdown-menu-dark" : ""}
|
||||||
|
>
|
||||||
|
{languageOptions.map(({ code, autonym }) => (
|
||||||
|
<Dropdown.Item
|
||||||
|
key={code}
|
||||||
|
active={code === currentLanguage}
|
||||||
|
onClick={() => void i18n.changeLanguage(code)}
|
||||||
|
className="d-flex align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>{autonym}</span>
|
||||||
|
<span className="text-muted small fw-light">
|
||||||
|
· {t(`lang.${code}`)}
|
||||||
|
</span>
|
||||||
|
</Dropdown.Item>
|
||||||
|
))}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</Navbar.Collapse>
|
||||||
|
</Container>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
360
helexa.ai/src/i18n/index.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import i18n, { type Resource } from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import {
|
||||||
|
SUPPORTED_LANGUAGES,
|
||||||
|
normalizeLocaleToLanguage,
|
||||||
|
isRtlLanguage,
|
||||||
|
} from "./languages";
|
||||||
|
import type { LanguageCode } from "./languages";
|
||||||
|
|
||||||
|
// Core languages
|
||||||
|
import enCommon from "./resources/en/common.json";
|
||||||
|
import ruCommon from "./resources/ru/common.json";
|
||||||
|
import enHome from "./resources/en/home.json";
|
||||||
|
import ruHome from "./resources/ru/home.json";
|
||||||
|
import enChat from "./resources/en/chat.json";
|
||||||
|
import ruChat from "./resources/ru/chat.json";
|
||||||
|
|
||||||
|
// Scandinavian & Nordic languages
|
||||||
|
import daCommon from "./resources/da/common.json";
|
||||||
|
import daHome from "./resources/da/home.json";
|
||||||
|
import daChat from "./resources/da/chat.json";
|
||||||
|
|
||||||
|
import fiCommon from "./resources/fi/common.json";
|
||||||
|
import fiHome from "./resources/fi/home.json";
|
||||||
|
import fiChat from "./resources/fi/chat.json";
|
||||||
|
|
||||||
|
import noCommon from "./resources/no/common.json";
|
||||||
|
import noHome from "./resources/no/home.json";
|
||||||
|
import noChat from "./resources/no/chat.json";
|
||||||
|
|
||||||
|
import svCommon from "./resources/sv/common.json";
|
||||||
|
import svHome from "./resources/sv/home.json";
|
||||||
|
import svChat from "./resources/sv/chat.json";
|
||||||
|
|
||||||
|
import bgCommon from "./resources/bg/common.json";
|
||||||
|
import bgHome from "./resources/bg/home.json";
|
||||||
|
import bgChat from "./resources/bg/chat.json";
|
||||||
|
|
||||||
|
import etCommon from "./resources/et/common.json";
|
||||||
|
import etHome from "./resources/et/home.json";
|
||||||
|
import etChat from "./resources/et/chat.json";
|
||||||
|
|
||||||
|
// African & MENA languages
|
||||||
|
import swCommon from "./resources/sw/common.json";
|
||||||
|
import swHome from "./resources/sw/home.json";
|
||||||
|
import swChat from "./resources/sw/chat.json";
|
||||||
|
|
||||||
|
import arCommon from "./resources/ar/common.json";
|
||||||
|
import arHome from "./resources/ar/home.json";
|
||||||
|
import arChat from "./resources/ar/chat.json";
|
||||||
|
|
||||||
|
import faCommon from "./resources/fa/common.json";
|
||||||
|
import faHome from "./resources/fa/home.json";
|
||||||
|
import faChat from "./resources/fa/chat.json";
|
||||||
|
|
||||||
|
import haCommon from "./resources/ha/common.json";
|
||||||
|
import haHome from "./resources/ha/home.json";
|
||||||
|
import haChat from "./resources/ha/chat.json";
|
||||||
|
|
||||||
|
import amCommon from "./resources/am/common.json";
|
||||||
|
import amHome from "./resources/am/home.json";
|
||||||
|
import amChat from "./resources/am/chat.json";
|
||||||
|
|
||||||
|
import yoCommon from "./resources/yo/common.json";
|
||||||
|
import yoHome from "./resources/yo/home.json";
|
||||||
|
import yoChat from "./resources/yo/chat.json";
|
||||||
|
|
||||||
|
import zuCommon from "./resources/zu/common.json";
|
||||||
|
import zuHome from "./resources/zu/home.json";
|
||||||
|
import zuChat from "./resources/zu/chat.json";
|
||||||
|
|
||||||
|
// Darija (Moroccan Arabic)
|
||||||
|
import maCommon from "./resources/ma/common.json";
|
||||||
|
import maHome from "./resources/ma/home.json";
|
||||||
|
import maChat from "./resources/ma/chat.json";
|
||||||
|
|
||||||
|
// European / other languages
|
||||||
|
import esCommon from "./resources/es/common.json";
|
||||||
|
import esHome from "./resources/es/home.json";
|
||||||
|
import esChat from "./resources/es/chat.json";
|
||||||
|
|
||||||
|
import frCommon from "./resources/fr/common.json";
|
||||||
|
import frHome from "./resources/fr/home.json";
|
||||||
|
import frChat from "./resources/fr/chat.json";
|
||||||
|
|
||||||
|
import deCommon from "./resources/de/common.json";
|
||||||
|
import deHome from "./resources/de/home.json";
|
||||||
|
import deChat from "./resources/de/chat.json";
|
||||||
|
|
||||||
|
import elCommon from "./resources/el/common.json";
|
||||||
|
import elHome from "./resources/el/home.json";
|
||||||
|
import elChat from "./resources/el/chat.json";
|
||||||
|
|
||||||
|
import itCommon from "./resources/it/common.json";
|
||||||
|
import itHome from "./resources/it/home.json";
|
||||||
|
import itChat from "./resources/it/chat.json";
|
||||||
|
|
||||||
|
import heCommon from "./resources/he/common.json";
|
||||||
|
import heHome from "./resources/he/home.json";
|
||||||
|
import heChat from "./resources/he/chat.json";
|
||||||
|
|
||||||
|
import ptCommon from "./resources/pt/common.json";
|
||||||
|
import ptHome from "./resources/pt/home.json";
|
||||||
|
import ptChat from "./resources/pt/chat.json";
|
||||||
|
|
||||||
|
import roCommon from "./resources/ro/common.json";
|
||||||
|
import roHome from "./resources/ro/home.json";
|
||||||
|
import roChat from "./resources/ro/chat.json";
|
||||||
|
|
||||||
|
import kaCommon from "./resources/ka/common.json";
|
||||||
|
import kaHome from "./resources/ka/home.json";
|
||||||
|
import kaChat from "./resources/ka/chat.json";
|
||||||
|
|
||||||
|
import trCommon from "./resources/tr/common.json";
|
||||||
|
import trHome from "./resources/tr/home.json";
|
||||||
|
import trChat from "./resources/tr/chat.json";
|
||||||
|
|
||||||
|
import plCommon from "./resources/pl/common.json";
|
||||||
|
import plHome from "./resources/pl/home.json";
|
||||||
|
import plChat from "./resources/pl/chat.json";
|
||||||
|
|
||||||
|
import ukCommon from "./resources/uk/common.json";
|
||||||
|
import ukHome from "./resources/uk/home.json";
|
||||||
|
import ukChat from "./resources/uk/chat.json";
|
||||||
|
|
||||||
|
import nlCommon from "./resources/nl/common.json";
|
||||||
|
import nlHome from "./resources/nl/home.json";
|
||||||
|
import nlChat from "./resources/nl/chat.json";
|
||||||
|
|
||||||
|
import srCommon from "./resources/sr/common.json";
|
||||||
|
import srHome from "./resources/sr/home.json";
|
||||||
|
import srChat from "./resources/sr/chat.json";
|
||||||
|
|
||||||
|
import kkCommon from "./resources/kk/common.json";
|
||||||
|
import kkHome from "./resources/kk/home.json";
|
||||||
|
import kkChat from "./resources/kk/chat.json";
|
||||||
|
|
||||||
|
import uzCommon from "./resources/uz/common.json";
|
||||||
|
import uzHome from "./resources/uz/home.json";
|
||||||
|
import uzChat from "./resources/uz/chat.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application translation resources, split by language and namespace.
|
||||||
|
*
|
||||||
|
* - `common`: shared UI elements (navigation, theme toggle, etc.)
|
||||||
|
* - `home`: marketing / narrative copy on the landing page
|
||||||
|
* - `chat`: copy for the chat workspace
|
||||||
|
*/
|
||||||
|
const resources: Resource = {
|
||||||
|
en: {
|
||||||
|
common: enCommon,
|
||||||
|
home: enHome,
|
||||||
|
chat: enChat,
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
common: ruCommon,
|
||||||
|
home: ruHome,
|
||||||
|
chat: ruChat,
|
||||||
|
},
|
||||||
|
bg: {
|
||||||
|
common: bgCommon,
|
||||||
|
home: bgHome,
|
||||||
|
chat: bgChat,
|
||||||
|
},
|
||||||
|
da: {
|
||||||
|
common: daCommon,
|
||||||
|
home: daHome,
|
||||||
|
chat: daChat,
|
||||||
|
},
|
||||||
|
et: {
|
||||||
|
common: etCommon,
|
||||||
|
home: etHome,
|
||||||
|
chat: etChat,
|
||||||
|
},
|
||||||
|
fi: {
|
||||||
|
common: fiCommon,
|
||||||
|
home: fiHome,
|
||||||
|
chat: fiChat,
|
||||||
|
},
|
||||||
|
kk: {
|
||||||
|
common: kkCommon,
|
||||||
|
home: kkHome,
|
||||||
|
chat: kkChat,
|
||||||
|
},
|
||||||
|
uz: {
|
||||||
|
common: uzCommon,
|
||||||
|
home: uzHome,
|
||||||
|
chat: uzChat,
|
||||||
|
},
|
||||||
|
|
||||||
|
// African & MENA languages (LTR unless marked RTL via isRtlLanguage)
|
||||||
|
sw: {
|
||||||
|
common: swCommon,
|
||||||
|
home: swHome,
|
||||||
|
chat: swChat,
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
common: arCommon,
|
||||||
|
home: arHome,
|
||||||
|
chat: arChat,
|
||||||
|
},
|
||||||
|
fa: {
|
||||||
|
common: faCommon,
|
||||||
|
home: faHome,
|
||||||
|
chat: faChat,
|
||||||
|
},
|
||||||
|
ha: {
|
||||||
|
common: haCommon,
|
||||||
|
home: haHome,
|
||||||
|
chat: haChat,
|
||||||
|
},
|
||||||
|
am: {
|
||||||
|
common: amCommon,
|
||||||
|
home: amHome,
|
||||||
|
chat: amChat,
|
||||||
|
},
|
||||||
|
yo: {
|
||||||
|
common: yoCommon,
|
||||||
|
home: yoHome,
|
||||||
|
chat: yoChat,
|
||||||
|
},
|
||||||
|
zu: {
|
||||||
|
common: zuCommon,
|
||||||
|
home: zuHome,
|
||||||
|
chat: zuChat,
|
||||||
|
},
|
||||||
|
ma: {
|
||||||
|
common: maCommon,
|
||||||
|
home: maHome,
|
||||||
|
chat: maChat,
|
||||||
|
},
|
||||||
|
|
||||||
|
// European & other languages
|
||||||
|
es: {
|
||||||
|
common: esCommon,
|
||||||
|
home: esHome,
|
||||||
|
chat: esChat,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
common: frCommon,
|
||||||
|
home: frHome,
|
||||||
|
chat: frChat,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
common: deCommon,
|
||||||
|
home: deHome,
|
||||||
|
chat: deChat,
|
||||||
|
},
|
||||||
|
el: {
|
||||||
|
common: elCommon,
|
||||||
|
home: elHome,
|
||||||
|
chat: elChat,
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
common: itCommon,
|
||||||
|
home: itHome,
|
||||||
|
chat: itChat,
|
||||||
|
},
|
||||||
|
he: {
|
||||||
|
common: heCommon,
|
||||||
|
home: heHome,
|
||||||
|
chat: heChat,
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
common: ptCommon,
|
||||||
|
home: ptHome,
|
||||||
|
chat: ptChat,
|
||||||
|
},
|
||||||
|
ro: {
|
||||||
|
common: roCommon,
|
||||||
|
home: roHome,
|
||||||
|
chat: roChat,
|
||||||
|
},
|
||||||
|
ka: {
|
||||||
|
common: kaCommon,
|
||||||
|
home: kaHome,
|
||||||
|
chat: kaChat,
|
||||||
|
},
|
||||||
|
tr: {
|
||||||
|
common: trCommon,
|
||||||
|
home: trHome,
|
||||||
|
chat: trChat,
|
||||||
|
},
|
||||||
|
pl: {
|
||||||
|
common: plCommon,
|
||||||
|
home: plHome,
|
||||||
|
chat: plChat,
|
||||||
|
},
|
||||||
|
uk: {
|
||||||
|
common: ukCommon,
|
||||||
|
home: ukHome,
|
||||||
|
chat: ukChat,
|
||||||
|
},
|
||||||
|
nl: {
|
||||||
|
common: nlCommon,
|
||||||
|
home: nlHome,
|
||||||
|
chat: nlChat,
|
||||||
|
},
|
||||||
|
sr: {
|
||||||
|
common: srCommon,
|
||||||
|
home: srHome,
|
||||||
|
chat: srChat,
|
||||||
|
},
|
||||||
|
no: {
|
||||||
|
common: noCommon,
|
||||||
|
home: noHome,
|
||||||
|
chat: noChat,
|
||||||
|
},
|
||||||
|
sv: {
|
||||||
|
common: svCommon,
|
||||||
|
home: svHome,
|
||||||
|
chat: svChat,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine initial language from browser, normalised to language-only.
|
||||||
|
const browserLang: LanguageCode =
|
||||||
|
typeof navigator !== "undefined"
|
||||||
|
? normalizeLocaleToLanguage(navigator.language)
|
||||||
|
: "en";
|
||||||
|
|
||||||
|
// Keep document direction (ltr/rtl) in sync with the active language.
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.documentElement.dir = isRtlLanguage(browserLang) ? "rtl" : "ltr";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize i18next with React bindings.
|
||||||
|
*
|
||||||
|
* This module is imported once in src/main.tsx before any React
|
||||||
|
* rendering so that `useTranslation` is ready everywhere.
|
||||||
|
*/
|
||||||
|
i18n.use(initReactI18next).init({
|
||||||
|
resources,
|
||||||
|
lng: browserLang,
|
||||||
|
fallbackLng: "en",
|
||||||
|
supportedLngs: SUPPORTED_LANGUAGES,
|
||||||
|
ns: ["common", "home", "chat"],
|
||||||
|
defaultNS: "common",
|
||||||
|
// Because we control the keys and interpolate only simple values.
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
// For now we stay language-only; we already normalise the browser locale.
|
||||||
|
load: "languageOnly",
|
||||||
|
// Be explicit about react options for clarity.
|
||||||
|
react: {
|
||||||
|
useSuspense: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure that when the language changes at runtime, document direction
|
||||||
|
// tracks the new language's natural writing direction.
|
||||||
|
i18n.on("languageChanged", (lng) => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const lang = normalizeLocaleToLanguage(lng);
|
||||||
|
document.documentElement.dir = isRtlLanguage(lang) ? "rtl" : "ltr";
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
232
helexa.ai/src/i18n/languages.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import type { Resource } from "i18next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported language codes for the application.
|
||||||
|
*
|
||||||
|
* For the foreseeable future we deliberately stay at the language level
|
||||||
|
* (e.g. "en", "ru") rather than full locales (e.g. "en-GB") to keep
|
||||||
|
* translation overhead manageable.
|
||||||
|
*
|
||||||
|
* When you add a new language:
|
||||||
|
* - Add its code to `SUPPORTED_LANGUAGES`
|
||||||
|
* - Add its autonym to `AUTONYM_MAP`
|
||||||
|
* - Add its resources to the i18n configuration
|
||||||
|
*/
|
||||||
|
export type LanguageCode =
|
||||||
|
| "en"
|
||||||
|
| "bg"
|
||||||
|
| "cs"
|
||||||
|
| "da"
|
||||||
|
| "de"
|
||||||
|
| "el"
|
||||||
|
| "es"
|
||||||
|
| "he"
|
||||||
|
| "et"
|
||||||
|
| "ar"
|
||||||
|
| "fa"
|
||||||
|
| "fi"
|
||||||
|
| "sw"
|
||||||
|
| "ha"
|
||||||
|
| "am"
|
||||||
|
| "yo"
|
||||||
|
| "zu"
|
||||||
|
| "fr"
|
||||||
|
| "ma"
|
||||||
|
| "ga"
|
||||||
|
| "hr"
|
||||||
|
| "hu"
|
||||||
|
| "is"
|
||||||
|
| "it"
|
||||||
|
| "ka"
|
||||||
|
| "lt"
|
||||||
|
| "lv"
|
||||||
|
| "mt"
|
||||||
|
| "nl"
|
||||||
|
| "no"
|
||||||
|
| "pl"
|
||||||
|
| "pt"
|
||||||
|
| "ro"
|
||||||
|
| "ru"
|
||||||
|
| "sk"
|
||||||
|
| "sl"
|
||||||
|
| "sr"
|
||||||
|
| "sv"
|
||||||
|
| "tr"
|
||||||
|
| "uk"
|
||||||
|
| "bs"
|
||||||
|
| "mk"
|
||||||
|
| "kk"
|
||||||
|
| "uz"
|
||||||
|
| "ig"
|
||||||
|
| "om"
|
||||||
|
| "so"
|
||||||
|
| "ti"
|
||||||
|
| "wo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered list of languages enabled in the UI.
|
||||||
|
*
|
||||||
|
* For now you can keep `SUPPORTED_LANGUAGES` in sync with the
|
||||||
|
* actually configured i18n resources (e.g. ["en", "ru"]) and grow
|
||||||
|
* it as translations land.
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_LANGUAGES: LanguageCode[] = [
|
||||||
|
"bg",
|
||||||
|
"da",
|
||||||
|
"de",
|
||||||
|
"el",
|
||||||
|
"en",
|
||||||
|
"es",
|
||||||
|
"et",
|
||||||
|
"fi",
|
||||||
|
"fr",
|
||||||
|
"he",
|
||||||
|
"it",
|
||||||
|
"ka",
|
||||||
|
"kk",
|
||||||
|
"nl",
|
||||||
|
"no",
|
||||||
|
"sv",
|
||||||
|
"uz",
|
||||||
|
"ar",
|
||||||
|
"fa",
|
||||||
|
"sw",
|
||||||
|
"ha",
|
||||||
|
"am",
|
||||||
|
"yo",
|
||||||
|
"zu",
|
||||||
|
"ma",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"ro",
|
||||||
|
"ru",
|
||||||
|
"sr",
|
||||||
|
"tr",
|
||||||
|
"uk",
|
||||||
|
// Future Afro‑European / Eurasian candidates; keep out of SUPPORTED_LANGUAGES until translated:
|
||||||
|
// "ig", // Igbo
|
||||||
|
// "om", // Oromo
|
||||||
|
// "so", // Somali
|
||||||
|
// "ti", // Tigrinya
|
||||||
|
// "wo", // Wolof
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autonym map.
|
||||||
|
*
|
||||||
|
* Each language is named in its own language so that a user only
|
||||||
|
* needs to know their own language to find it in the selector.
|
||||||
|
*/
|
||||||
|
export const AUTONYM_MAP: Record<LanguageCode, string> = {
|
||||||
|
en: "English",
|
||||||
|
bg: "български",
|
||||||
|
cs: "čeština",
|
||||||
|
da: "dansk",
|
||||||
|
de: "Deutsch",
|
||||||
|
el: "Ελληνικά",
|
||||||
|
es: "español",
|
||||||
|
et: "eesti",
|
||||||
|
he: "עברית",
|
||||||
|
ar: "العربية",
|
||||||
|
fa: "فارسی",
|
||||||
|
sw: "Kiswahili",
|
||||||
|
ha: "Hausa",
|
||||||
|
am: "አማርኛ",
|
||||||
|
yo: "Yorùbá",
|
||||||
|
zu: "isiZulu",
|
||||||
|
ma: "Darija",
|
||||||
|
fi: "suomi",
|
||||||
|
fr: "français",
|
||||||
|
ga: "Gaeilge",
|
||||||
|
hr: "hrvatski",
|
||||||
|
hu: "magyar",
|
||||||
|
is: "íslenska",
|
||||||
|
it: "italiano",
|
||||||
|
lt: "lietuvių",
|
||||||
|
lv: "latviešu",
|
||||||
|
mt: "Malti",
|
||||||
|
nl: "Nederlands",
|
||||||
|
no: "norsk",
|
||||||
|
pl: "polski",
|
||||||
|
pt: "português",
|
||||||
|
ro: "română",
|
||||||
|
ru: "русский",
|
||||||
|
sk: "slovenčina",
|
||||||
|
sl: "slovenščina",
|
||||||
|
sr: "српски",
|
||||||
|
sv: "svenska",
|
||||||
|
tr: "Türkçe",
|
||||||
|
uk: "українська",
|
||||||
|
bs: "bosanski",
|
||||||
|
mk: "македонски",
|
||||||
|
ka: "ქართული", // Georgian
|
||||||
|
kk: "қазақ тілі", // Kazakh
|
||||||
|
uz: "oʻzbekcha", // Uzbek
|
||||||
|
ig: "Igbo", // Igbo
|
||||||
|
om: "Afaan Oromoo", // Oromo
|
||||||
|
so: "Af-Soomaali", // Somali
|
||||||
|
ti: "ትግርኛ", // Tigrinya
|
||||||
|
wo: "Wolof", // Wolof
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a full locale (e.g. "en-GB") down to a `LanguageCode`.
|
||||||
|
*
|
||||||
|
* - Uses the first segment of the locale (before "-")
|
||||||
|
* - Falls back to "en" if the language is unsupported or invalid
|
||||||
|
*/
|
||||||
|
export const normalizeLocaleToLanguage = (
|
||||||
|
locale: string | null | undefined,
|
||||||
|
): LanguageCode => {
|
||||||
|
if (!locale) return "en";
|
||||||
|
const lang = locale.split("-")[0]?.toLowerCase() ?? "en";
|
||||||
|
|
||||||
|
if (SUPPORTED_LANGUAGES.includes(lang as LanguageCode)) {
|
||||||
|
return lang as LanguageCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "en";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stable list of language options for UI components
|
||||||
|
* such as dropdowns.
|
||||||
|
*/
|
||||||
|
export type LanguageOption = {
|
||||||
|
code: LanguageCode;
|
||||||
|
autonym: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLanguageOptions = (): LanguageOption[] =>
|
||||||
|
[...SUPPORTED_LANGUAGES]
|
||||||
|
.map((code) => ({
|
||||||
|
code,
|
||||||
|
autonym: AUTONYM_MAP[code],
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.autonym.localeCompare(b.autonym));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to derive i18next `supportedLngs` from our language codes,
|
||||||
|
* so configuration can import from this module instead of hardcoding
|
||||||
|
* the list in multiple places.
|
||||||
|
*/
|
||||||
|
export const getSupportedLngsForI18Next = (): Resource["en"] extends never
|
||||||
|
? string[]
|
||||||
|
: string[] => {
|
||||||
|
// i18next accepts string[], while we keep a stricter LanguageCode[]
|
||||||
|
return [...SUPPORTED_LANGUAGES];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Languages whose natural writing direction is right-to-left.
|
||||||
|
*
|
||||||
|
* This is used by layout code (outside this module) to switch
|
||||||
|
* document direction and RTL-aware styling when needed.
|
||||||
|
*/
|
||||||
|
export const RTL_LANGUAGES: LanguageCode[] = ["he", "ar", "fa", "ma"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to check whether a given language code is RTL.
|
||||||
|
*/
|
||||||
|
export const isRtlLanguage = (code: LanguageCode): boolean =>
|
||||||
|
RTL_LANGUAGES.includes(code);
|
||||||
9
helexa.ai/src/i18n/resources/am/chat.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"title": "የውይይት ቦታ",
|
||||||
|
"badge": "ውይይት",
|
||||||
|
"lead": "ይህ የውይይት እይታ ነው። የውይይት ሎጂክዎን እና የተጠቃሚ በስተጀርባ ክፍሎችን ወደዚህ ገፅ ያገናኙ።",
|
||||||
|
"transcriptPlaceholder": "የውይይቱ ሪኮርድ እዚህ ይታያል። የሞዴሉን እና የተጠቃሚውን መልዕክቶች በሚንቀሳቀስ ኮንቴይነር ውስጥ ያቀርቡ፣ приወይም በዙር ዙር በመከፈል ማቅረብ ይችላሉ።",
|
||||||
|
"inputPlaceholder": "ውይይትን ለመጀምር መልዕክት ይፃፉ…",
|
||||||
|
"send": "መላክ",
|
||||||
|
"clear": "ማጽዳት"
|
||||||
|
}
|
||||||
63
helexa.ai/src/i18n/resources/am/common.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "helexa.ai"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "መነሻ ገፅ",
|
||||||
|
"docs": "ሰነዶች",
|
||||||
|
"chat": "ውይይት",
|
||||||
|
"mission": "Mission",
|
||||||
|
"login": "Sign in",
|
||||||
|
"register": "Sign up",
|
||||||
|
"account": "Account",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"toggle": {
|
||||||
|
"toLight": "ወደ ብርሃን ሁኔታ መቀየር",
|
||||||
|
"toDark": "ወደ ጨለማ ሁኔታ መቀየር"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"bg": "ቡልጋሪኛ",
|
||||||
|
"de": "ጀርመንኛ",
|
||||||
|
"el": "ግሪከኛ",
|
||||||
|
"en": "እንግሊዝኛ",
|
||||||
|
"es": "ስፓኒሽኛ",
|
||||||
|
"et": "ኤስቶኒያን",
|
||||||
|
"fr": "ፈረንሳይኛ",
|
||||||
|
"he": "እብራስጥ",
|
||||||
|
"it": "ጣሊያንኛ",
|
||||||
|
"nl": "ደችኛ",
|
||||||
|
"da": "ዴኒሽኛ",
|
||||||
|
"fi": "ፊኒሽኛ",
|
||||||
|
"no": "ኖርዌጂያንኛ",
|
||||||
|
"sv": "ስዊድንኛ",
|
||||||
|
"ar": "ዐርቢኛ",
|
||||||
|
"fa": "ፐርሺያኛ",
|
||||||
|
"sw": "ስዋሂሊኛ",
|
||||||
|
"ha": "ሃውሳኛ",
|
||||||
|
"am": "አማርኛ",
|
||||||
|
"yo": "ዮሩባ",
|
||||||
|
"zu": "ዙሉ",
|
||||||
|
"ma": "ዳሪጃ",
|
||||||
|
"ig": "ኢግቦኛ",
|
||||||
|
"ka": "ጊዮርጂያንኛ",
|
||||||
|
"kk": "ካዛክኛ",
|
||||||
|
"om": "ኦሮሞኛ",
|
||||||
|
"so": "ሶማሊኛ",
|
||||||
|
"ti": "ትግርኛ",
|
||||||
|
"uz": "ኡዝቤክኛ",
|
||||||
|
"wo": "ዎሎፍኛ",
|
||||||
|
"pl": "ፖሊሽኛ",
|
||||||
|
"pt": "ፖርቱጋልኛ",
|
||||||
|
"ro": "ሮማኒያን",
|
||||||
|
"ru": "ራሽኛ",
|
||||||
|
"sr": "ሰርቢኛ",
|
||||||
|
"tr": "ቱርክኛ",
|
||||||
|
"uk": "ዩክሬንኛ"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {{year}} helexa.ai"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
helexa.ai/src/i18n/resources/am/home.json
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"badge": "አዲስ የአእምሮ ቅርጽ",
|
||||||
|
"title": "አዲስ የአእምሮ ቅርጽ",
|
||||||
|
"lead": "Helexa በተገናኙ ነጻ ኦፕሬተሮች የሚነሳ ራሷን የሚያደርግ የኤ.አይ. መረብ ነው። ክፍት ነው። ተበታተነ። በመደጋገም ይቀየራል።",
|
||||||
|
"ctaJoinMesh": "ወደ መረቡ ይቀላቀሉ",
|
||||||
|
"ctaFollowProject": "ፕሮጀክቱን ይከተሉ",
|
||||||
|
"subcopy": "AI ክፍት፣ ጠንካራ እና የሚጋራ መሆን አለበት በሚሉ ለኦፕሬተሮች፣ ለአበልጣጫዎች እና ለማህበረሰቦች ተሠርቷል።",
|
||||||
|
"imageAlt": "የHelexa ሄሊክስ ምስል"
|
||||||
|
},
|
||||||
|
|
||||||
|
"intent": {
|
||||||
|
"title": "Helexa ለምን አለች",
|
||||||
|
"p1": "AI በምድር ላይ በጣም ኃይለኛው መሠረታዊ መዋቅር እየሆነ ነው። ነገር ግን ዛሬ፣ ያ ኃይል በግል ቅድሚያዎች፣ በጂኦግራፊያዊ ገደቦች እና በደካማ ኢኮኖሚዎች የተሳሰበ በጥቂት ኩባንያዎች ውስጥ ተሰብስቧል።",
|
||||||
|
"p2Intro": "Helexa የተለየ ነገር ታስባለች፦",
|
||||||
|
"bullet1": "ከአንድ ቦታ ሳይሆን ከሁሉም ቦታ የሚያድግ አእምሮ።",
|
||||||
|
"bullet2": "ማንኛውም ሰው ሊያስተዋውቅበትና ሊጠቀምበት የሚችለው መረብ።",
|
||||||
|
"bullet3": "በትእዛዝ ሳይሆን በፍላጎት ላይ የሚማር ስርዓት።",
|
||||||
|
"bullet4": "ማህበረሰቦችን ለመተካት ሳይሆን የሚጠናከል ቴክኖሎጂ።",
|
||||||
|
"closing": "Helexa መድረክ አይደለችም። ደመና አይደለችም።\nመረብ ነች — የተወለደ እና የሚሻሻል የነጻ ኦፕሬተሮች መረብ ሆና አዲስ ዓይነት አእምሮ የምታቀና."
|
||||||
|
},
|
||||||
|
|
||||||
|
"whyNow": {
|
||||||
|
"title": "ለAI የሚለው የመቀየር ጊዜ",
|
||||||
|
"problemTitle": "ችግኙ",
|
||||||
|
"problemBullet1": "AI ከቀድሞው ማንኛውም ቴክኖሎጂ የማይተካ ፍጥነት በመሰብሰብ ላይ ነው።",
|
||||||
|
"problemBullet2": "የኮምፒውተር ኃይል መዳረሻ ችሎታን ያወራል፣ ይህም መዳረሻ ግን በቀስታ እየተጠበቀ ነው።",
|
||||||
|
"problemBullet3": "የወጪ መከልከያዎች ምርምር እና የኢንተርፕራይዝ ጀማሪዎችን እና ማህበረሰቦችን እያወጡ ነው።",
|
||||||
|
"problemBullet4": "የጄኦፖለቲካ እና የህግ ግፊት የዓለም አቀፍ መዳረሻን እያቀረበ ነው።",
|
||||||
|
"problemBullet5": "የሞዴሎች ፈጣሪዎችና የሀርድዌር ኦፕሬተሮች ብዙ ጊዜ በራሳቸው የሚፈጥሩትን ዋጋ አያጋሩም።",
|
||||||
|
"opportunityTitle": "እድሉ",
|
||||||
|
"opportunityIntro": "ግን የተሰራጨ ዓለም የሚቻል ነው።",
|
||||||
|
"opportunityBullet1": "ሺዎች የሚቆጠሩ የGPU ካርዶች በዓለም ዙሪያ አሁንም በትንሽ እየተጠቀሙባቸው ነው።",
|
||||||
|
"opportunityBullet2": "ኦፕሬተሮች ለየሚሰጡት ኮምፒውተር ኃይል እውነተኛ እና ፍትሃዊ ክፍያ ይፈልጋሉ።",
|
||||||
|
"opportunityBullet3": "ዲቨሎፐሮች ክፍት እና ከመቆጣጠር የተጠበቀ መሠረታዊ መዋቅር ይፈልጋሉ።",
|
||||||
|
"opportunityBullet4": "ማህበረሰቦች በዲጂታል ስርዓቶቻቸው ውስጥ ስብስብ መንግስታዊነትን እና ጽኑ መሆን ይፈልጋሉ።",
|
||||||
|
"opportunityBullet5": "የAI እድገት ባለመለኪያ ሁኔታ ከባለሙያ ደመናዎች በላይ ቀድሞ ሄዷል — አዳዲስ ቅርጾች ያስፈልጋሉ።",
|
||||||
|
"opportunityClosing": "Helexa እነዚህ ኃይሎች በአንድ ጊዜ የሚገናኙበት ጊዜ ነው።"
|
||||||
|
},
|
||||||
|
|
||||||
|
"howItWorks": {
|
||||||
|
"title": "መረቡ እንዴት እንደሚሰራ",
|
||||||
|
"operators": {
|
||||||
|
"eyebrow": "ኦፕሬተሮች ኖዶችን ይያዙ",
|
||||||
|
"title": "ማንኛውም ሰው የኮምፒውተር ኃይል ሊያቀርብ ይችላል።",
|
||||||
|
"body": "ኦፕሬተሮች የHelexa ኖዶችን ያስኬዳሉ። የማንኛውንም ሞዴል እንዲሰሩ ያስችላሉ። በሀርድዌሩና በኢኮኖሚያዊ ውሳኔዎቻቸው ላይ ቁጥጥር ይጠብቃሉ። ምንም የፈቃድ ሂደት የለም፣ ጠባቂ መከላከያ የለም።"
|
||||||
|
},
|
||||||
|
"routing": {
|
||||||
|
"eyebrow": "መረቡ አእምሮን ይመራል",
|
||||||
|
"title": "ፍላጎት በመረቡ ውስጥ ይፈሳሳል።",
|
||||||
|
"body": "Helexa እየማረከች የቆየበት አቅም ወዴት እንደሚገኝ፣ ፍላጎት የሚጨምርበት ቦታ የት እንደሆነ እና ምን ኖድ ለማቅረብ ተስማሚ እንደሆነ ትማራለች። መረቡ በተፈጥሯዊ መንገድ ይለመዳል — እያደገ ያለ ሄሊክስ እንደሆነ።"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"eyebrow": "ዋጋው ወደ ኋላ ይመለሳል",
|
||||||
|
"title": "ሥራ ታረጋገጠ። ክፍያ ፍትሃዊ ነው።",
|
||||||
|
"body": "እያንዳንዱ ስራ ክሪፕቶግራፊ የተፈረመ ደረሰኝ ይይዛል። ኦፕሬተሮች በሚሰጡት አእምሮ ላይ ገንዘብ ያገኛሉ። የመድረክ ታክስ የለም። ግልጽ ካልሆነ የክፍያ ሂደት የለም።"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"principles": {
|
||||||
|
"title": "በመድረኮች ላይ ሳይሆን በመርሆዎች ላይ የተገነባ",
|
||||||
|
"distributed": {
|
||||||
|
"title": "በንድፍ የተበታተነ",
|
||||||
|
"body": "አንድ የብቻ የእርምጃ መደበቂያ የለም። ማዕከላዊ ሥልጣን የለም። በእያንዳንዱ አዲስ ኦፕሬተር ጋር የሚጠናከር መረብ ነው።"
|
||||||
|
},
|
||||||
|
"participation": {
|
||||||
|
"title": "ተሳትፎ ለሁሉም ክፍት",
|
||||||
|
"body": "ኮምፒውተር ኃይል ካለህ ልዩ ልዩ ድርሻ ሊያስተዋውቅ ትችላለህ። መረቡ ሁሉንም ይቀበላል — ከኤጀጅ መሣሪያዎች፣ እስከ የቤት አገልጋዮች እና የዳታ ማእከላት ድረስ።"
|
||||||
|
},
|
||||||
|
"fairness": {
|
||||||
|
"title": "ፍትህና ተመልካችነት",
|
||||||
|
"body": "ገቢዎች በእውነተኛ ስራ ላይ ተመስርተዋል፣ በክሪፕቶግራፊ መሠረት ተረጋግጠዋል። ጥቁር ሳጥን የለም። የተደበቀ ክፍያ የለም።"
|
||||||
|
},
|
||||||
|
"evolving": {
|
||||||
|
"title": "የሚያድግ አእምሮ",
|
||||||
|
"body": "መረቡ ከፍላጎት ይማራል። ሞዴሎች የሚፈለጉበት ቦታ ላይ ይጫናሉ። አእምሮ በተባባሪነት ይበተናል።"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"roadAhead": {
|
||||||
|
"title": "Helexa ምን ለመሆን እየተሻሻለች ነው",
|
||||||
|
"p1": "ለሁሉም የሚያገለግል የዓለም አቀፍ የአእምሮ ንብርብር፣ በሄሊክስ ያለ የኖዶችና የማህበረሰቦች መረብ የሚደገፍ።",
|
||||||
|
"p2": "ማቋረጥ፣ ፖለቲካ፣ ነጠላ ባለቤቶች እና ወደፊት ሊከሰት በሚችል እንኳን የሚቋቋም መረብ።",
|
||||||
|
"p3": "ኦፕሬተሮች፣ አበልጣጫዎች እና ተጠቃሚዎች ሁሉም ተዋጊ የሚሆኑበት አዲስ የኢኮኖሚ ሞዴል።",
|
||||||
|
"p4": "ፈጠራ ከጠርዝ የሚያድግ፣ ከማዕከል ሳይሆን፣ የሥራ አካባቢ ኢኮሲስተም።",
|
||||||
|
"card": {
|
||||||
|
"eyebrow": "የራዕይ አጭር እትም",
|
||||||
|
"title": "ወደ የተጋራ የአእምሮ መረብ",
|
||||||
|
"body": "Helexa ገና በቀዳሚ ደረጃ ላይ ይ beታል። ሀሳቦቹ ከአሁን እና ከተፈጠረው ተቀባይነት የበለጠ ትልቅ ናቸው — ይህም በተወሰነ ዓላማ ነው። መረቡ በእያንዳንዱ ደረጃ ይበቃል፣ ኦፕሬተሮችና አበልጣጫዎችም እድገቷን በጋራ ይፀናሉ።"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"joinMesh": {
|
||||||
|
"badge": "የመጀመሪያ ደረጃ",
|
||||||
|
"title": "መረቡ እየተፈጠረ ነው።",
|
||||||
|
"titleHighlight": "እርስዎም ክፍል ሆነው ሊሳተፉበት ይችላሉ።",
|
||||||
|
"lead": "ሀርድዌር ብትከናወኑ፣ ሞዴሎችን ብታበርኩ፣ ወይም በአጠቃላይ AI እንዴት እንደሚመራ ብታስቡ ሆነው፣ በዚህ መረብ ውስጥ ለእርስዎ የተዘጋበ ቦታ አለ።",
|
||||||
|
"ctaRunNode": "ኖድ ያስኬዱ (በቅርቡ)",
|
||||||
|
"ctaJoinAnnouncements": "የቀድሞ ማስታወቂያዎችን ይቀበሉ",
|
||||||
|
"ctaExploreCode": "ኮድን ያስሱ",
|
||||||
|
"footer": "የተጣሉ ፍራንቻዎች የሉም። አንድ ብቻ ባለቤት የለም። የሚመጣውን የአእምሮ ወደፊት በሚያቀና መልኩ በሰዎች፣ በሀርድዌር እና በሀሳቦች የተሠራ መረብ ብቻ ነው።"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
helexa.ai/src/i18n/resources/ar/chat.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"title": "مساحة محادثة",
|
||||||
|
"badge": "محادثة",
|
||||||
|
"lead": "هذه هي واجهة المحادثة. قم بتوصيل منطق الحوار الخاص بك ومكوّنات واجهة المستخدم بهذه الصفحة.",
|
||||||
|
"transcriptPlaceholder": "سجل المحادثة سيظهر هنا. اعرض رسائل النموذج والمستخدم في حاوية قابلة للتمرير، ويمكنك تجميعها حسب أدوار الحوار إذا رغبت.",
|
||||||
|
"inputPlaceholder": "اكتب رسالة لبدء المحادثة…",
|
||||||
|
"send": "إرسال",
|
||||||
|
"clear": "مسح"
|
||||||
|
}
|
||||||
63
helexa.ai/src/i18n/resources/ar/common.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "helexa.ai"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "الصفحة الرئيسية",
|
||||||
|
"docs": "التوثيق",
|
||||||
|
"chat": "المحادثة",
|
||||||
|
"mission": "Mission",
|
||||||
|
"login": "Sign in",
|
||||||
|
"register": "Sign up",
|
||||||
|
"account": "Account",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"toggle": {
|
||||||
|
"toLight": "التبديل إلى الوضع الفاتح",
|
||||||
|
"toDark": "التبديل إلى الوضع الداكن"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"bg": "البلغارية",
|
||||||
|
"de": "الألمانية",
|
||||||
|
"el": "اليونانية",
|
||||||
|
"en": "الإنجليزية",
|
||||||
|
"es": "الإسبانية",
|
||||||
|
"et": "الإستونية",
|
||||||
|
"fr": "الفرنسية",
|
||||||
|
"he": "العبرية",
|
||||||
|
"it": "الإيطالية",
|
||||||
|
"nl": "الهولندية",
|
||||||
|
"da": "الدنماركية",
|
||||||
|
"fi": "الفنلندية",
|
||||||
|
"ar": "العربية",
|
||||||
|
"fa": "الفارسية",
|
||||||
|
"sw": "السواحيلية",
|
||||||
|
"ha": "الهوسا",
|
||||||
|
"am": "الأمهرية",
|
||||||
|
"yo": "اليوربا",
|
||||||
|
"zu": "الزولو",
|
||||||
|
"ma": "الدارجة المغربية",
|
||||||
|
"ig": "الإيجبو",
|
||||||
|
"ka": "الجورجية",
|
||||||
|
"kk": "الكازاخية",
|
||||||
|
"no": "النرويجية",
|
||||||
|
"om": "الأورومو",
|
||||||
|
"so": "الصومالية",
|
||||||
|
"sv": "السويدية",
|
||||||
|
"ti": "التيغرينية",
|
||||||
|
"uz": "الأوزبكية",
|
||||||
|
"wo": "الولوف",
|
||||||
|
"pl": "البولندية",
|
||||||
|
"pt": "البرتغالية",
|
||||||
|
"ro": "الرومانية",
|
||||||
|
"ru": "الروسية",
|
||||||
|
"sr": "الصربية",
|
||||||
|
"tr": "التركية",
|
||||||
|
"uk": "الأوكرانية"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {{year}} helexa.ai"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
helexa.ai/src/i18n/resources/ar/home.json
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"badge": "شكل جديد من الذكاء",
|
||||||
|
"title": "شكل جديد من الذكاء",
|
||||||
|
"lead": "هيليكسـا هي شبكة ذكاء اصطناعي ذاتية التنظيم، يشغّلها مشغّلون مستقلون. مفتوحة. موزعة. تتطور باستمرار.",
|
||||||
|
"ctaJoinMesh": "انضم إلى الشبكة",
|
||||||
|
"ctaFollowProject": "تابع المشروع",
|
||||||
|
"subcopy": "صُممت للمشغّلين والبنّائين والمجتمعات التي تؤمن بأن الذكاء الاصطناعي يجب أن يكون مفتوحًا، مرنًا، ومشتركًا.",
|
||||||
|
"imageAlt": "تصور حلزوني لشكل Helexa"
|
||||||
|
},
|
||||||
|
|
||||||
|
"intent": {
|
||||||
|
"title": "لماذا وُجدت Helexa",
|
||||||
|
"p1": "الذكاء الاصطناعي يتحول إلى أقوى بنية تحتية على كوكب الأرض. لكن اليوم، هذه القوة مركّزة في أيدي عدد قليل من الشركات، تشكلها أولويات خاصة، وحدود جغرافية، واقتصادات هشة.",
|
||||||
|
"p2Intro": "هيليكسـا تتخيل شيئًا مختلفًا:",
|
||||||
|
"bullet1": "ذكاء ينمو من كل مكان، لا من نقطة واحدة.",
|
||||||
|
"bullet2": "شبكة يمكن للجميع فيها أن يساهموا ويستفيدوا.",
|
||||||
|
"bullet3": "نظام يتكيّف مع الطلب، لا مع الأوامر الفوقية.",
|
||||||
|
"bullet4": "تكنولوجيا تقوّي المجتمعات بدلًا من أن تستبدلها.",
|
||||||
|
"closing": "هيليكسـا ليست منصة. وليست سحابة.\nإنها شبكة — نسيج حيّ ومتطوّر من مشغّلين مستقلين يشكّلون نوعًا جديدًا من الذكاء."
|
||||||
|
},
|
||||||
|
|
||||||
|
"whyNow": {
|
||||||
|
"title": "نقطة تحوّل للذكاء الاصطناعي",
|
||||||
|
"problemTitle": "المشكلة",
|
||||||
|
"problemBullet1": "الذكاء الاصطناعي يتمركز أسرع من أي تكنولوجيا سبقته.",
|
||||||
|
"problemBullet2": "الوصول إلى القدرة الحاسوبية يحدد الإمكانات، وهذا الوصول يضيق باستمرار.",
|
||||||
|
"problemBullet3": "حواجز التكلفة تستبعد الباحثين والشركات الناشئة والمجتمعات.",
|
||||||
|
"problemBullet4": "الضغوط الجيوسياسية والتنظيمية تهدّد التوافر العالمي.",
|
||||||
|
"problemBullet5": "مُنشئو النماذج ومشغّلو العتاد نادرًا ما يشاركون في القيمة التي ينتجونها.",
|
||||||
|
"opportunityTitle": "الفرصة",
|
||||||
|
"opportunityIntro": "لكن عالمًا موزعًا ممكن.",
|
||||||
|
"opportunityBullet1": "آلاف وحدات الـGPU حول العالم تعمل بأقل من طاقتها.",
|
||||||
|
"opportunityBullet2": "المشغّلون يريدون تعويضًا عادلًا عن الحوسبة التي يقدّمونها.",
|
||||||
|
"opportunityBullet3": "المطورون يريدون بنية تحتية مفتوحة ومقاوِمة للرقابة.",
|
||||||
|
"opportunityBullet4": "المجتمعات تريد سيادة ومرونة في أنظمتها الرقمية.",
|
||||||
|
"opportunityBullet5": "نمو الذكاء الاصطناعي تجاوز البنى السحابية التقليدية — نحن بحاجة إلى أشكال جديدة.",
|
||||||
|
"opportunityClosing": "هيليكسـا هي لحظة تلاقي هذه القوى."
|
||||||
|
},
|
||||||
|
|
||||||
|
"howItWorks": {
|
||||||
|
"title": "كيف تتشكّل الشبكة",
|
||||||
|
"operators": {
|
||||||
|
"eyebrow": "المشغّلون يديرون العقد",
|
||||||
|
"title": "أي شخص يمكنه المساهمة بقوة حاسوبية.",
|
||||||
|
"body": "المشغّلون يشغّلون عُقد Helexa. يقررون أي النماذج يستضيفون. يبقون متحكمين في عتادهم واقتصادهم. لا موافقات، ولا حراس بوابة."
|
||||||
|
},
|
||||||
|
"routing": {
|
||||||
|
"eyebrow": "الشبكة توجّه الذكاء",
|
||||||
|
"title": "الطلب يتدفق عبر الشبكة.",
|
||||||
|
"body": "هيليكسـا تتعلم أين توجد السعة، وأين يرتفع الطلب، وأي العقد أنسب لخدمة الطلبات. الشبكة تتكيّف بشكل عضوي — مثل حلزون ينمو."
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"eyebrow": "القيمة تعود إلى المصدر",
|
||||||
|
"title": "العمل مُثبت. والدفع عادل.",
|
||||||
|
"body": "كل مهمة تحمل إيصالًا تشفيريًا. يكسب المشغّلون مقابل الذكاء الذي يساعدون في توفيره. لا ضرائب منصة. لا فواتير غامضة."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"principles": {
|
||||||
|
"title": "مبنية على مبادئ، لا على منصات",
|
||||||
|
"distributed": {
|
||||||
|
"title": "موزعة بالتصميم",
|
||||||
|
"body": "لا توجد نقطة فشل واحدة. لا توجد سلطة مركزية. شبكة تقوى مع كل مشغّل جديد."
|
||||||
|
},
|
||||||
|
"participation": {
|
||||||
|
"title": "مشاركة مفتوحة",
|
||||||
|
"body": "إذا كان لديك قدرة حاسوبية، يمكنك المساهمة. الشبكة ترحّب بالجميع — من الحافة، إلى الخوادم المنزلية، إلى مراكز البيانات."
|
||||||
|
},
|
||||||
|
"fairness": {
|
||||||
|
"title": "العدالة والشفافية",
|
||||||
|
"body": "الأرباح مبنية على عمل حقيقي، مُثبت تشفيريًا. لا صناديق سوداء. لا رسوم خفية."
|
||||||
|
},
|
||||||
|
"evolving": {
|
||||||
|
"title": "ذكاء يتطور باستمرار",
|
||||||
|
"body": "الشبكة تتعلم من الطلب. تُحمَّل النماذج حيث تكون مطلوبة. الذكاء ينتشر من خلال التعاون."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"roadAhead": {
|
||||||
|
"title": "إلى ماذا تطمح Helexa أن تصبح",
|
||||||
|
"p1": "طبقة ذكاء عالمية تنتمي للجميع، مدفوعة بحلزونة من العقد والمجتمعات.",
|
||||||
|
"p2": "شبكة مقاومة للانقطاعات، والسياسة، والاحتكارات، والإخفاق.",
|
||||||
|
"p3": "نموذج اقتصادي جديد يستفيد منه المشغّلون والبنّاؤون والمستخدمون جميعًا.",
|
||||||
|
"p4": "نظام بيئي تنمو فيه الابتكارات من الأطراف — لا من المركز.",
|
||||||
|
"card": {
|
||||||
|
"eyebrow": "لمحة عن الرؤية",
|
||||||
|
"title": "نحو شبكة ذكاء مشتركة",
|
||||||
|
"body": "هيليكسـا في مرحلة مبكرة. الأفكار أكبر من التنفيذ الحالي — وهذا مقصود. الشبكة ستنمو تدريجيًا، بينما يشكّل المشغّلون والبنّاؤون مسار تطورها."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"joinMesh": {
|
||||||
|
"badge": "مرحلة مبكرة",
|
||||||
|
"title": "الشبكة قيد التشكل.",
|
||||||
|
"titleHighlight": "ويمكنك أن تكون جزءًا منها.",
|
||||||
|
"lead": "سواءً كنت تدير عتادًا، تبني نماذج، أو يهمّك ببساطة كيف يُدار الذكاء الاصطناعي — هناك مكان لك داخل الشبكة.",
|
||||||
|
"ctaRunNode": "تشغيل عقدة (قريبًا)",
|
||||||
|
"ctaJoinAnnouncements": "انضم إلى الإعلانات المبكرة",
|
||||||
|
"ctaExploreCode": "استكشف الشفرة",
|
||||||
|
"footer": "لا حدائق مسوّرة. لا مالك واحد. مجرد شبكة من أشخاص وعتاد وأفكار — تصوغ مستقبلًا مختلفًا للذكاء."
|
||||||
|
}
|
||||||
|
}
|
||||||
9
helexa.ai/src/i18n/resources/bg/chat.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"title": "Работно пространство за разговори",
|
||||||
|
"badge": "Чат",
|
||||||
|
"lead": "Това е изгледът за чат. Тук можеш да свържеш своята логика за разговори и потребителски интерфейс.",
|
||||||
|
"transcriptPlaceholder": "Тук ще се показва историята на чата. Визуализирай съобщенията от модела и потребителя в превъртащ се контейнер, по желание групирани по ход.",
|
||||||
|
"inputPlaceholder": "Напиши съобщение, за да започнеш разговора…",
|
||||||
|
"send": "Изпрати",
|
||||||
|
"clear": "Изчисти"
|
||||||
|
}
|
||||||
63
helexa.ai/src/i18n/resources/bg/common.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "helexa.ai"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Начало",
|
||||||
|
"docs": "Документация",
|
||||||
|
"chat": "Чат",
|
||||||
|
"mission": "Mission",
|
||||||
|
"login": "Sign in",
|
||||||
|
"register": "Sign up",
|
||||||
|
"account": "Account",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"toggle": {
|
||||||
|
"toLight": "Превключване към светла тема",
|
||||||
|
"toDark": "Превключване към тъмна тема"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"bg": "български",
|
||||||
|
"de": "немски",
|
||||||
|
"el": "гръцки",
|
||||||
|
"en": "английски",
|
||||||
|
"es": "испански",
|
||||||
|
"et": "естонски",
|
||||||
|
"fr": "френски",
|
||||||
|
"he": "иврит",
|
||||||
|
"it": "италиански",
|
||||||
|
"nl": "Нидерландски",
|
||||||
|
"da": "Датски",
|
||||||
|
"fi": "Фински",
|
||||||
|
"no": "Норвежки",
|
||||||
|
"sv": "Шведски",
|
||||||
|
"ar": "Арабски",
|
||||||
|
"fa": "персийски",
|
||||||
|
"sw": "суахили",
|
||||||
|
"ha": "хауза",
|
||||||
|
"am": "амхарски",
|
||||||
|
"yo": "йоруба",
|
||||||
|
"zu": "зулу",
|
||||||
|
"ma": "дариджа",
|
||||||
|
"ig": "игбо",
|
||||||
|
"ka": "грузински",
|
||||||
|
"kk": "казахски",
|
||||||
|
"om": "оромо",
|
||||||
|
"so": "сомалийски",
|
||||||
|
"ti": "тигриња",
|
||||||
|
"uz": "узбекски",
|
||||||
|
"wo": "волоф",
|
||||||
|
"pl": "полски",
|
||||||
|
"pt": "португалски",
|
||||||
|
"ro": "румънски",
|
||||||
|
"ru": "руски",
|
||||||
|
"sr": "сръбски",
|
||||||
|
"tr": "турски",
|
||||||
|
"uk": "украински"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {{year}} helexa.ai"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
helexa.ai/src/i18n/resources/bg/home.json
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"badge": "Нова форма на интелигентност",
|
||||||
|
"title": "Нова форма на интелигентност",
|
||||||
|
"lead": "Helexa е самоорганизираща се AI мрежа, задвижвана от независими оператори. Отворена. Разпределена. Еволюираща.",
|
||||||
|
"ctaJoinMesh": "Присъедини се към мрежата",
|
||||||
|
"ctaFollowProject": "Следвай проекта",
|
||||||
|
"subcopy": "Създадена за оператори, разработчици и общности, които вярват, че AI трябва да бъде отворен, устойчив и споделен.",
|
||||||
|
"imageAlt": "Визуализация на хеликса на Helexa"
|
||||||
|
},
|
||||||
|
|
||||||
|
"intent": {
|
||||||
|
"title": "Защо съществува Helexa",
|
||||||
|
"p1": "AI се превръща в най-мощната инфраструктура на Земята. Но днес тази сила е концентрирана в шепа корпорации, оформяна от частни приоритети, географски ограничения и крехка икономика.",
|
||||||
|
"p2Intro": "Helexa си представя нещо различно:",
|
||||||
|
"bullet1": "Интелигентност, която расте от всякъде, не от едно място.",
|
||||||
|
"bullet2": "Мрежа, в която всеки може да допринася и да се възползва.",
|
||||||
|
"bullet3": "Система, която се адаптира към търсенето, не към заповеди.",
|
||||||
|
"bullet4": "Технология, която подсилва общностите вместо да ги заменя.",
|
||||||
|
"closing": "Helexa не е платформа. Не е и облак.\nТя е мрежа — жива, еволюираща решетка от независими оператори, която оформя нов вид интелигентност."
|
||||||
|
},
|
||||||
|
|
||||||
|
"whyNow": {
|
||||||
|
"title": "Преломен момент за AI",
|
||||||
|
"problemTitle": "Проблемът",
|
||||||
|
"problemBullet1": "AI се централизира по-бързо от всяка предишна технология.",
|
||||||
|
"problemBullet2": "Достъпът до изчислителни ресурси определя възможностите, а достъпът се стеснява.",
|
||||||
|
"problemBullet3": "Ценовите бариери изключват изследователи, стартъпи и общности.",
|
||||||
|
"problemBullet4": "Геополитически и регулаторни натиск застрашават глобалната достъпност.",
|
||||||
|
"problemBullet5": "Създателите на модели и операторите на хардуер рядко споделят стойността, която създават.",
|
||||||
|
"opportunityTitle": "Възможността",
|
||||||
|
"opportunityIntro": "Но един разпределен свят е възможен.",
|
||||||
|
"opportunityBullet1": "Хиляди GPU машини вече са недоизползвани по целия свят.",
|
||||||
|
"opportunityBullet2": "Операторите искат честно заплащане за изчислителната си мощ.",
|
||||||
|
"opportunityBullet3": "Разработчиците искат отворена, устойчива на цензура инфраструктура.",
|
||||||
|
"opportunityBullet4": "Общностите искат суверенитет и устойчивост в дигиталните системи.",
|
||||||
|
"opportunityBullet5": "Ръстът на AI изпревари традиционните облаци — нужни са нови форми.",
|
||||||
|
"opportunityClosing": "Helexa е моментът, в който тези сили се подравняват."
|
||||||
|
},
|
||||||
|
|
||||||
|
"howItWorks": {
|
||||||
|
"title": "Как се формира мрежата",
|
||||||
|
"operators": {
|
||||||
|
"eyebrow": "Операторите стартират възли",
|
||||||
|
"title": "Всеки може да допринесе с изчислителна мощ.",
|
||||||
|
"body": "Операторите стартират възли на Helexa. Те решават кои модели да хостват. Остават в контрол над своя хардуер и своята икономика. Без одобрения, без посредници."
|
||||||
|
},
|
||||||
|
"routing": {
|
||||||
|
"eyebrow": "Мрежата маршрутизира интелигентността",
|
||||||
|
"title": "Търсенето тече през мрежата.",
|
||||||
|
"body": "Helexa научава къде има капацитет, къде търсенето нараства и кои възли са най-подходящи да обслужват заявките. Мрежата се адаптира органично — като растяща спирала."
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"eyebrow": "Стойността се връща обратно",
|
||||||
|
"title": "Работата е доказуема. Заплащането е честно.",
|
||||||
|
"body": "Всяка задача носи криптографско потвърждение. Операторите печелят за интелигентността, която помагат да се предостави. Без платформи данъкоплатци. Без непрозрачни сметки."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"principles": {
|
||||||
|
"title": "Изградена върху принципи, не върху платформи",
|
||||||
|
"distributed": {
|
||||||
|
"title": "Разпределена по замисъл",
|
||||||
|
"body": "Без една-единствена точка на отказ. Без централен орган. Мрежа, която става по-силна с всеки нов оператор."
|
||||||
|
},
|
||||||
|
"participation": {
|
||||||
|
"title": "Отворено участие",
|
||||||
|
"body": "Ако имаш изчислителни ресурси, можеш да допринесеш. Мрежата посреща всички — edge устройства, домашни сървъри, центрове за данни."
|
||||||
|
},
|
||||||
|
"fairness": {
|
||||||
|
"title": "Справедливост и прозрачност",
|
||||||
|
"body": "Приходите се базират на реална свършена работа, криптографски потвърдена. Без черни кутии. Без скрити такси."
|
||||||
|
},
|
||||||
|
"evolving": {
|
||||||
|
"title": "Еволюираща интелигентност",
|
||||||
|
"body": "Мрежата учи от търсенето. Моделите се зареждат там, където са нужни. Интелигентността се разпространява чрез сътрудничество."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"roadAhead": {
|
||||||
|
"title": "Какво цели да стане Helexa",
|
||||||
|
"p1": "Глобален слой от интелигентност, който принадлежи на всички, задвижван от спирала от възли и общности.",
|
||||||
|
"p2": "Мрежа, устойчива на сривове, политика, монополи и провали.",
|
||||||
|
"p3": "Нов икономически модел, в който оператори, създатели и потребители печелят заедно.",
|
||||||
|
"p4": "Екосистема, в която иновациите растат от периферията — не от центъра.",
|
||||||
|
"card": {
|
||||||
|
"eyebrow": "Миг от визията",
|
||||||
|
"title": "Към споделена мрежа от интелигентност",
|
||||||
|
"body": "Helexa е в ранен етап. Идеите са по-големи от реализацията — и това е умишлено. Мрежата ще расте итеративно, оформяна от операторите и разработчиците."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"joinMesh": {
|
||||||
|
"badge": "Ранен етап",
|
||||||
|
"title": "Мрежата се формира.",
|
||||||
|
"titleHighlight": "Ти можеш да бъдеш част от нея.",
|
||||||
|
"lead": "Независимо дали управляваш хардуер, изграждаш модели или просто ти пука как се управлява AI, има място за теб в мрежата.",
|
||||||
|
"ctaRunNode": "Пусни възел (скоро)",
|
||||||
|
"ctaJoinAnnouncements": "Включи се в ранните известия",
|
||||||
|
"ctaExploreCode": "Разгледай кода",
|
||||||
|
"footer": "Без оградени градини. Без един собственик. Само мрежа от хора, хардуер и идеи — които заедно създават различно бъдеще за интелигентността."
|
||||||
|
}
|
||||||
|
}
|
||||||
9
helexa.ai/src/i18n/resources/da/chat.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"title": "Samtalearbejdsområde",
|
||||||
|
"badge": "Chat",
|
||||||
|
"lead": "Dette er chatvisningen. Tilføj din samtalelogik og dine UI‑komponenter til denne side.",
|
||||||
|
"transcriptPlaceholder": "Chattransskriptionen vises her. Gengiv beskeder fra modellen og brugeren i en rullende container, eventuelt grupperet efter tur.",
|
||||||
|
"inputPlaceholder": "Skriv en besked for at starte en chat…",
|
||||||
|
"send": "Send",
|
||||||
|
"clear": "Ryd"
|
||||||
|
}
|
||||||
63
helexa.ai/src/i18n/resources/da/common.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "helexa.ai"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Hjem",
|
||||||
|
"docs": "Dokumentation",
|
||||||
|
"chat": "Chat",
|
||||||
|
"mission": "Mission",
|
||||||
|
"login": "Sign in",
|
||||||
|
"register": "Sign up",
|
||||||
|
"account": "Account",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"toggle": {
|
||||||
|
"toLight": "Skift til lyst tema",
|
||||||
|
"toDark": "Skift til mørkt tema"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"am": "Amharisk",
|
||||||
|
"ar": "Arabisk",
|
||||||
|
"bg": "Bulgarsk",
|
||||||
|
"da": "Dansk",
|
||||||
|
"de": "Tysk",
|
||||||
|
"el": "Græsk",
|
||||||
|
"en": "Engelsk",
|
||||||
|
"es": "Spansk",
|
||||||
|
"et": "Estisk",
|
||||||
|
"fa": "Persisk",
|
||||||
|
"fi": "Finsk",
|
||||||
|
"fr": "Fransk",
|
||||||
|
"ha": "Hausa",
|
||||||
|
"he": "Hebraisk",
|
||||||
|
"ig": "Igbo",
|
||||||
|
"it": "Italiensk",
|
||||||
|
"ka": "Georgisk",
|
||||||
|
"kk": "Kasakhisk",
|
||||||
|
"ma": "Darija",
|
||||||
|
"nl": "Hollandsk",
|
||||||
|
"no": "Norsk",
|
||||||
|
"om": "Oromo",
|
||||||
|
"pl": "Polsk",
|
||||||
|
"pt": "Portugisisk",
|
||||||
|
"ro": "Rumænsk",
|
||||||
|
"ru": "Russisk",
|
||||||
|
"so": "Somalisk",
|
||||||
|
"sr": "Serbisk",
|
||||||
|
"sv": "Svensk",
|
||||||
|
"sw": "Swahili",
|
||||||
|
"ti": "Tigrinya",
|
||||||
|
"tr": "Tyrkisk",
|
||||||
|
"uk": "Ukrainsk",
|
||||||
|
"uz": "Usbekisk",
|
||||||
|
"wo": "Wolof",
|
||||||
|
"yo": "Yoruba",
|
||||||
|
"zu": "Zulu"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {{year}} helexa.ai"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
helexa.ai/src/i18n/resources/da/home.json
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"badge": "En ny form for intelligens",
|
||||||
|
"title": "En ny form for intelligens",
|
||||||
|
"lead": "Helexa er et selvorganiserende AI‑mesh drevet af uafhængige operatører. Åbent. Distribueret. Under udvikling.",
|
||||||
|
"ctaJoinMesh": "Deltag i mesh'en",
|
||||||
|
"ctaFollowProject": "Følg projektet",
|
||||||
|
"subcopy": "Bygget til operatører, udviklere og fællesskaber, der mener, at AI skal være åbent, robust og delt.",
|
||||||
|
"imageAlt": "Helexa helix‑visualisering"
|
||||||
|
},
|
||||||
|
|
||||||
|
"intent": {
|
||||||
|
"title": "Hvorfor Helexa findes",
|
||||||
|
"p1": "AI er ved at blive den mest magtfulde infrastruktur på Jorden. Men i dag er den magt koncentreret hos en håndfuld virksomheder, formet af private prioriteter, geografiske begrænsninger og skrøbelig økonomi.",
|
||||||
|
"p2Intro": "Helexa forestiller sig noget andet:",
|
||||||
|
"bullet1": "Intelligens, der vokser frem overalt – ikke ét sted.",
|
||||||
|
"bullet2": "Et netværk, hvor alle kan bidrage og få gavn.",
|
||||||
|
"bullet3": "Et system, der tilpasser sig efter efterspørgsel, ikke direktiver.",
|
||||||
|
"bullet4": "Teknologi, der styrker fællesskaber i stedet for at erstatte dem.",
|
||||||
|
"closing": "Helexa er ikke en platform. Det er ikke en cloud.\nDet er et mesh – et levende, udviklende gitter af uafhængige operatører, der danner en ny form for intelligens."
|
||||||
|
},
|
||||||
|
|
||||||
|
"whyNow": {
|
||||||
|
"title": "Et vendepunkt for AI",
|
||||||
|
"problemTitle": "Problemet",
|
||||||
|
"problemBullet1": "AI centraliseres hurtigere end nogen tidligere teknologi.",
|
||||||
|
"problemBullet2": "Adgang til compute definerer kapacitet, og adgangen bliver snævrere.",
|
||||||
|
"problemBullet3": "Omkostninger udelukker forskere, startups og fællesskaber.",
|
||||||
|
"problemBullet4": "Geopolitiske og regulatoriske pres truer global tilgængelighed.",
|
||||||
|
"problemBullet5": "Skaberne af modeller og operatørerne af hardware deler sjældent i den værdi, de producerer.",
|
||||||
|
"opportunityTitle": "Muligheden",
|
||||||
|
"opportunityIntro": "Men en distribueret verden er mulig.",
|
||||||
|
"opportunityBullet1": "Tusindvis af GPU'er står allerede uudnyttede rundt om i verden.",
|
||||||
|
"opportunityBullet2": "Operatører ønsker retfærdig kompensation for compute.",
|
||||||
|
"opportunityBullet3": "Udviklere ønsker åben, censurresistent infrastruktur.",
|
||||||
|
"opportunityBullet4": "Fællesskaber ønsker suverænitet og robusthed i digitale systemer.",
|
||||||
|
"opportunityBullet5": "AI's vækst har overhalet traditionelle clouds – der er brug for nye former.",
|
||||||
|
"opportunityClosing": "Helexa er øjeblikket, hvor disse kræfter mødes."
|
||||||
|
},
|
||||||
|
|
||||||
|
"howItWorks": {
|
||||||
|
"title": "Sådan dannes mesh'en",
|
||||||
|
"operators": {
|
||||||
|
"eyebrow": "Operatører kører noder",
|
||||||
|
"title": "Alle kan bidrage med compute.",
|
||||||
|
"body": "Operatører kører Helexa‑noder. De beslutter, hvilke modeller der skal hostes. De bevarer kontrollen over deres hardware og økonomi. Ingen godkendelser, ingen gatekeepere."
|
||||||
|
},
|
||||||
|
"routing": {
|
||||||
|
"eyebrow": "Mesh'en dirigerer intelligens",
|
||||||
|
"title": "Efterspørgslen flyder gennem netværket.",
|
||||||
|
"body": "Helexa lærer, hvor kapacitet findes, hvor efterspørgslen stiger, og hvilke noder der er bedst egnet til at håndtere forespørgsler. Mesh'en tilpasser sig organisk – som en voksende helix."
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"eyebrow": "Værdien flyder tilbage",
|
||||||
|
"title": "Arbejdet bevises. Betalingen er retfærdig.",
|
||||||
|
"body": "Hver opgave bærer en kryptografisk kvittering. Operatører tjener på den intelligens, de hjælper med at levere. Ingen platformsskat. Ingen uigennemsigtig fakturering."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"principles": {
|
||||||
|
"title": "Bygget på principper, ikke platforme",
|
||||||
|
"distributed": {
|
||||||
|
"title": "Distribueret fra starten",
|
||||||
|
"body": "Ingen enkelt fejlkilde. Ingen central autoritet. Et netværk, der bliver stærkere for hver ny operatør."
|
||||||
|
},
|
||||||
|
"participation": {
|
||||||
|
"title": "Åben deltagelse",
|
||||||
|
"body": "Hvis du har compute, kan du bidrage. Mesh'en byder alle velkommen – edge, hjemmeserver, datacenter."
|
||||||
|
},
|
||||||
|
"fairness": {
|
||||||
|
"title": "Retfærdighed og gennemsigtighed",
|
||||||
|
"body": "Indtjening er baseret på reelt arbejde, kryptografisk verificeret. Ingen sorte bokse. Ingen skjulte gebyrer."
|
||||||
|
},
|
||||||
|
"evolving": {
|
||||||
|
"title": "Udviklende intelligens",
|
||||||
|
"body": "Mesh'en lærer af efterspørgslen. Modeller loades dér, hvor de behøves. Intelligens spredes gennem samarbejde."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"roadAhead": {
|
||||||
|
"title": "Hvad Helexa sigter mod at blive",
|
||||||
|
"p1": "Et globalt intelligenslag, der tilhører alle, drevet af en helix af noder og fællesskaber.",
|
||||||
|
"p2": "Et netværk, der er robust over for nedbrud, politik, monopoler og fejl.",
|
||||||
|
"p3": "En ny økonomisk model, hvor operatører, udviklere og brugere alle får udbytte.",
|
||||||
|
"p4": "Et økosystem, hvor innovation vokser ud fra kanterne – ikke centrum.",
|
||||||
|
"card": {
|
||||||
|
"eyebrow": "Visionsoversigt",
|
||||||
|
"title": "Mod et delt intelligens‑mesh",
|
||||||
|
"body": "Helexa er i en tidlig fase. Idéerne er større end implementationen – og det er med vilje. Netværket vil vokse gradvist, hvor operatører og udviklere sammen former dets udvikling."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"joinMesh": {
|
||||||
|
"badge": "Tidlig fase",
|
||||||
|
"title": "Mesh'en tager form.",
|
||||||
|
"titleHighlight": "Du kan være en del af den.",
|
||||||
|
"lead": "Uanset om du kører hardware, bygger modeller eller blot går op i, hvordan AI styres, er der en plads til dig i mesh'en.",
|
||||||
|
"ctaRunNode": "Kør en node (snart)",
|
||||||
|
"ctaJoinAnnouncements": "Deltag i tidlige annonceringer",
|
||||||
|
"ctaExploreCode": "Udforsk koden",
|
||||||
|
"footer": "Ingen lukkede haver. Ingen enkelt ejer. Bare et mesh af mennesker, hardware og idéer – der sammen former en anderledes fremtid for intelligens."
|
||||||
|
}
|
||||||
|
}
|
||||||
9
helexa.ai/src/i18n/resources/de/chat.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"title": "Konversationsbereich",
|
||||||
|
"badge": "Chat",
|
||||||
|
"lead": "Dies ist die Chat-Ansicht. Binde hier deine Konversationslogik und UI-Komponenten ein.",
|
||||||
|
"transcriptPlaceholder": "Das Chat-Protokoll erscheint hier. Rendern die Nachrichten des Modells und der Nutzerin bzw. des Nutzers in einem scrollbaren Container, optional nach Gesprächsrunden gruppiert.",
|
||||||
|
"inputPlaceholder": "Schreibe eine Nachricht, um mit dem Chat zu beginnen…",
|
||||||
|
"send": "Senden",
|
||||||
|
"clear": "Leeren"
|
||||||
|
}
|
||||||
63
helexa.ai/src/i18n/resources/de/common.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "helexa.ai"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"docs": "Dokumentation",
|
||||||
|
"chat": "Chat",
|
||||||
|
"mission": "Mission",
|
||||||
|
"login": "Sign in",
|
||||||
|
"register": "Sign up",
|
||||||
|
"account": "Account",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"toggle": {
|
||||||
|
"toLight": "In den hellen Modus wechseln",
|
||||||
|
"toDark": "In den dunklen Modus wechseln"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"bg": "Bulgarisch",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"el": "Griechisch",
|
||||||
|
"en": "Englisch",
|
||||||
|
"es": "Spanisch",
|
||||||
|
"et": "Estnisch",
|
||||||
|
"fr": "Französisch",
|
||||||
|
"he": "Hebräisch",
|
||||||
|
"it": "Italienisch",
|
||||||
|
"nl": "Niederländisch",
|
||||||
|
"da": "Dänisch",
|
||||||
|
"fi": "Finnisch",
|
||||||
|
"no": "Norwegisch",
|
||||||
|
"sv": "Schwedisch",
|
||||||
|
"ar": "Arabisch",
|
||||||
|
"fa": "Persisch",
|
||||||
|
"sw": "Suaheli",
|
||||||
|
"ha": "Hausa",
|
||||||
|
"am": "Amharisch",
|
||||||
|
"yo": "Yoruba",
|
||||||
|
"zu": "Zulu",
|
||||||
|
"ma": "Darija",
|
||||||
|
"ig": "Igbo",
|
||||||
|
"ka": "Georgisch",
|
||||||
|
"kk": "Kasachisch",
|
||||||
|
"om": "Oromo",
|
||||||
|
"so": "Somali",
|
||||||
|
"ti": "Tigrinya",
|
||||||
|
"uz": "Usbekisch",
|
||||||
|
"wo": "Wolof",
|
||||||
|
"pl": "Polnisch",
|
||||||
|
"pt": "Portugiesisch",
|
||||||
|
"ro": "Rumänisch",
|
||||||
|
"ru": "Russisch",
|
||||||
|
"sr": "Serbisch",
|
||||||
|
"tr": "Türkisch",
|
||||||
|
"uk": "Ukrainisch"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {{year}} helexa.ai"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
helexa.ai/src/i18n/resources/de/home.json
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"badge": "Eine neue Form von Intelligenz",
|
||||||
|
"title": "Eine neue Form von Intelligenz",
|
||||||
|
"lead": "Helexa ist ein selbstorganisiertes KI‑Mesh, betrieben von unabhängigen Operatoren. Offen. Dezentral. Im Wandel.",
|
||||||
|
"ctaJoinMesh": "Dem Mesh beitreten",
|
||||||
|
"ctaFollowProject": "Dem Projekt folgen",
|
||||||
|
"subcopy": "Geschaffen für Operatoren, Builder und Communities, die glauben, dass KI offen, widerstandsfähig und geteilt sein sollte.",
|
||||||
|
"imageAlt": "Helexa‑Helix‑Visualisierung"
|
||||||
|
},
|
||||||
|
|
||||||
|
"intent": {
|
||||||
|
"title": "Warum es Helexa gibt",
|
||||||
|
"p1": "KI wird zur mächtigsten Infrastruktur auf der Erde. Doch heute ist diese Macht in den Händen weniger Konzerne konzentriert – geprägt von privaten Prioritäten, geografischen Grenzen und fragilen Geschäftsmodellen.",
|
||||||
|
"p2Intro": "Helexa stellt sich etwas anderes vor:",
|
||||||
|
"bullet1": "Intelligenz, die überall wächst – nicht nur an einem Ort.",
|
||||||
|
"bullet2": "Ein Netzwerk, in dem jeder beitragen und profitieren kann.",
|
||||||
|
"bullet3": "Ein System, das sich an Nachfrage anpasst, nicht an Vorgaben.",
|
||||||
|
"bullet4": "Technologie, die Gemeinschaften stärkt, statt sie zu ersetzen.",
|
||||||
|
"closing": "Helexa ist keine Plattform. Es ist keine Cloud.\nEs ist ein Mesh – ein lebendiges, sich entwickelndes Geflecht unabhängiger Operatoren, das eine neue Form von Intelligenz bildet."
|
||||||
|
},
|
||||||
|
|
||||||
|
"whyNow": {
|
||||||
|
"title": "Ein Wendepunkt für KI",
|
||||||
|
"problemTitle": "Das Problem",
|
||||||
|
"problemBullet1": "KI zentralisiert sich schneller als jede Technologie zuvor.",
|
||||||
|
"problemBullet2": "Zugang zu Rechenressourcen bestimmt die Fähigkeiten – und dieser Zugang verengt sich.",
|
||||||
|
"problemBullet3": "Kostenbarrieren schließen Forschende, Startups und Communities aus.",
|
||||||
|
"problemBullet4": "Geopolitische und regulatorische Spannungen bedrohen die globale Verfügbarkeit.",
|
||||||
|
"problemBullet5": "Die Schöpfer von Modellen und die Betreiber von Hardware teilen selten den Wert, den sie erzeugen.",
|
||||||
|
"opportunityTitle": "Die Chance",
|
||||||
|
"opportunityIntro": "Aber eine verteilte Welt ist möglich.",
|
||||||
|
"opportunityBullet1": "Tausende GPUs weltweit sind heute bereits unterausgelastet.",
|
||||||
|
"opportunityBullet2": "Operatoren wollen eine faire Vergütung für ihre Rechenleistung.",
|
||||||
|
"opportunityBullet3": "Entwicklerinnen und Entwickler wünschen sich offene, zensurresistente Infrastruktur.",
|
||||||
|
"opportunityBullet4": "Communities wollen Souveränität und Resilienz in digitalen Systemen.",
|
||||||
|
"opportunityBullet5": "Das Wachstum der KI hat klassische Clouds überholt – es braucht neue Formen.",
|
||||||
|
"opportunityClosing": "Helexa ist der Moment, in dem sich diese Kräfte ausrichten."
|
||||||
|
},
|
||||||
|
|
||||||
|
"howItWorks": {
|
||||||
|
"title": "Wie sich das Mesh bildet",
|
||||||
|
"operators": {
|
||||||
|
"eyebrow": "Operatoren betreiben Nodes",
|
||||||
|
"title": "Jede und jeder kann Rechenleistung beisteuern.",
|
||||||
|
"body": "Operatoren betreiben Helexa‑Nodes. Sie entscheiden, welche Modelle sie hosten. Sie behalten die Kontrolle über ihre Hardware und ihre Wirtschaftlichkeit. Keine Genehmigungen, keine Gatekeeper."
|
||||||
|
},
|
||||||
|
"routing": {
|
||||||
|
"eyebrow": "Das Mesh leitet Intelligenz",
|
||||||
|
"title": "Nachfrage fließt durch das Netzwerk.",
|
||||||
|
"body": "Helexa lernt, wo Kapazität vorhanden ist, wo die Nachfrage steigt und welche Nodes sich am besten für Anfragen eignen. Das Mesh passt sich organisch an – wie eine wachsende Helix."
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"eyebrow": "Wert fließt zurück",
|
||||||
|
"title": "Arbeit wird bewiesen. Bezahlung ist fair.",
|
||||||
|
"body": "Jeder Job trägt einen kryptografischen Beleg. Operatoren verdienen an der Intelligenz mit, die sie bereitstellen. Keine Plattformsteuer. Keine intransparente Abrechnung."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"principles": {
|
||||||
|
"title": "Auf Prinzipien gebaut, nicht auf Plattformen",
|
||||||
|
"distributed": {
|
||||||
|
"title": "Von Grund auf verteilt",
|
||||||
|
"body": "Kein Single Point of Failure. Keine zentrale Instanz. Ein Netzwerk, das mit jedem neuen Operator stärker wird."
|
||||||
|
},
|
||||||
|
"participation": {
|
||||||
|
"title": "Offene Teilhabe",
|
||||||
|
"body": "Wenn du Rechenleistung hast, kannst du beitragen. Das Mesh heißt alle willkommen – Edge, Heimserver oder Rechenzentrum."
|
||||||
|
},
|
||||||
|
"fairness": {
|
||||||
|
"title": "Fairness & Transparenz",
|
||||||
|
"body": "Einnahmen basieren auf echter, kryptografisch verifizierter Arbeit. Keine Blackboxen. Keine versteckten Gebühren."
|
||||||
|
},
|
||||||
|
"evolving": {
|
||||||
|
"title": "Sich entwickelnde Intelligenz",
|
||||||
|
"body": "Das Mesh lernt aus der Nachfrage. Modelle landen dort, wo sie gebraucht werden. Intelligenz breitet sich durch Kooperation aus."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"roadAhead": {
|
||||||
|
"title": "Was Helexa werden will",
|
||||||
|
"p1": "Eine globale Intelligenzschicht, die allen gehört – angetrieben von einer Helix aus Nodes und Communities.",
|
||||||
|
"p2": "Ein Netzwerk, das Ausfällen, Politik, Monopolen und Störungen standhält.",
|
||||||
|
"p3": "Ein neues Wirtschaftsmodell, in dem Operatoren, Builder und Nutzerinnen gleichermaßen profitieren.",
|
||||||
|
"p4": "Ein Ökosystem, in dem Innovation von den Rändern ausgeht – nicht aus der Mitte.",
|
||||||
|
"card": {
|
||||||
|
"eyebrow": "Visions‑Snapshot",
|
||||||
|
"title": "Auf dem Weg zu einem geteilten Intelligenz‑Mesh",
|
||||||
|
"body": "Helexa steht am Anfang. Die Ideen sind größer als die aktuelle Implementierung – und das ist beabsichtigt. Das Netzwerk wird schrittweise wachsen, während Operatoren und Builder seine Entwicklung mitgestalten."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"joinMesh": {
|
||||||
|
"badge": "Frühe Phase",
|
||||||
|
"title": "Das Mesh formt sich.",
|
||||||
|
"titleHighlight": "Du kannst Teil davon sein.",
|
||||||
|
"lead": "Ob du Hardware betreibst, Modelle baust oder dir einfach wichtig ist, wie KI gesteuert wird – im Mesh gibt es einen Platz für dich.",
|
||||||
|
"ctaRunNode": "Node betreiben (bald)",
|
||||||
|
"ctaJoinAnnouncements": "Frühe Ankündigungen abonnieren",
|
||||||
|
"ctaExploreCode": "Code erkunden",
|
||||||
|
"footer": "Keine geschlossenen Gärten. Kein einzelner Besitzer. Nur ein Mesh aus Menschen, Hardware und Ideen – das eine andere Zukunft für Intelligenz komponiert."
|
||||||
|
}
|
||||||
|
}
|
||||||
9
helexa.ai/src/i18n/resources/el/chat.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"title": "Χώρος συνομιλίας",
|
||||||
|
"badge": "Συνομιλία",
|
||||||
|
"lead": "Αυτή είναι η προβολή συνομιλίας. Σύνδεσε εδώ τη λογική της συζήτησης και τα components του περιβάλλοντος χρήστη σου.",
|
||||||
|
"transcriptPlaceholder": "Το ιστορικό της συνομιλίας θα εμφανίζεται εδώ. Απόδωσε τα μηνύματα του μοντέλου και του χρήστη σε ένα κυλιόμενο container, προαιρετικά ομαδοποιημένα ανά γύρο.",
|
||||||
|
"inputPlaceholder": "Πληκτρολόγησε ένα μήνυμα για να ξεκινήσεις τη συνομιλία…",
|
||||||
|
"send": "Αποστολή",
|
||||||
|
"clear": "Καθαρισμός"
|
||||||
|
}
|
||||||
63
helexa.ai/src/i18n/resources/el/common.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "helexa.ai"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Αρχική",
|
||||||
|
"docs": "Τεκμηρίωση",
|
||||||
|
"chat": "Συνομιλία",
|
||||||
|
"mission": "Mission",
|
||||||
|
"login": "Sign in",
|
||||||
|
"register": "Sign up",
|
||||||
|
"account": "Account",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"toggle": {
|
||||||
|
"toLight": "Μετάβαση σε φωτεινή λειτουργία",
|
||||||
|
"toDark": "Μετάβαση σε σκοτεινή λειτουργία"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"bg": "Βουλγαρικά",
|
||||||
|
"de": "Γερμανικά",
|
||||||
|
"el": "Ελληνικά",
|
||||||
|
"en": "Αγγλικά",
|
||||||
|
"es": "Ισπανικά",
|
||||||
|
"et": "Εσθονικά",
|
||||||
|
"fr": "Γαλλικά",
|
||||||
|
"he": "Εβραϊκά",
|
||||||
|
"it": "Ιταλικά",
|
||||||
|
"nl": "Ολλανδικά",
|
||||||
|
"da": "Δανικά",
|
||||||
|
"fi": "Φινλανδικά",
|
||||||
|
"no": "Νορβηγικά",
|
||||||
|
"sv": "Σουηδικά",
|
||||||
|
"ar": "Αραβικά",
|
||||||
|
"fa": "Περσικά",
|
||||||
|
"sw": "Σουαχίλι",
|
||||||
|
"ha": "Χάουσα",
|
||||||
|
"am": "Αμχαρικά",
|
||||||
|
"yo": "Γιορούμπα",
|
||||||
|
"zu": "Ζουλού",
|
||||||
|
"ma": "Νταρίτζα",
|
||||||
|
"ig": "Ίγκμπο",
|
||||||
|
"ka": "Γεωργιανά",
|
||||||
|
"kk": "Καζακικά",
|
||||||
|
"om": "Ορόμο",
|
||||||
|
"so": "Σομαλικά",
|
||||||
|
"ti": "Τιγκρινια",
|
||||||
|
"uz": "Ουζμπεκικά",
|
||||||
|
"wo": "Γουόλοφ",
|
||||||
|
"pl": "Πολωνικά",
|
||||||
|
"pt": "Πορτογαλικά",
|
||||||
|
"ro": "Ρουμανικά",
|
||||||
|
"ru": "Ρωσικά",
|
||||||
|
"sr": "Σερβικά",
|
||||||
|
"tr": "Τουρκικά",
|
||||||
|
"uk": "Ουκρανικά"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© {{year}} helexa.ai"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
helexa.ai/src/i18n/resources/el/home.json
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"badge": "Μια νέα μορφή νοημοσύνης",
|
||||||
|
"title": "Μια νέα μορφή νοημοσύνης",
|
||||||
|
"lead": "Η Helexa είναι ένα αυτο-οργανωμένο πλέγμα ΤΝ που τροφοδοτείται από ανεξάρτητους operators. Ανοιχτό. Κατανεμημένο. Εξελισσόμενο.",
|
||||||
|
"ctaJoinMesh": "Γίνε μέρος του πλέγματος",
|
||||||
|
"ctaFollowProject": "Παρακολούθησε το έργο",
|
||||||
|
"subcopy": "Φτιαγμένο για operators, builders και κοινότητες που πιστεύουν ότι η ΤΝ πρέπει να είναι ανοιχτή, ανθεκτική και κοινόχρηστη.",
|
||||||
|
"imageAlt": "Οπτικοποίηση της έλικας Helexa"
|
||||||
|
},
|
||||||
|
|
||||||
|
"intent": {
|
||||||
|
"title": "Γιατί υπάρχει η Helexa",
|
||||||
|
"p1": "Η ΤΝ γίνεται η πιο ισχυρή υποδομή στον κόσμο. Σήμερα όμως, αυτή η ισχύς είναι συγκεντρωμένη σε λίγες εταιρείες, διαμορφωμένη από ιδιωτικές προτεραιότητες, γεωγραφικούς περιορισμούς και εύθραυστες οικονομίες.",
|
||||||
|
"p2Intro": "Η Helexa φαντάζεται κάτι διαφορετικό:",
|
||||||
|
"bullet1": "Μια νοημοσύνη που αναπτύσσεται από παντού, όχι από ένα μόνο σημείο.",
|
||||||
|
"bullet2": "Ένα δίκτυο στο οποίο ο καθένας μπορεί να συνεισφέρει και να ωφεληθεί.",
|
||||||
|
"bullet3": "Ένα σύστημα που προσαρμόζεται στη ζήτηση, όχι στις εντολές.",
|
||||||
|
"bullet4": "Τεχνολογία που ενδυναμώνει τις κοινότητες αντί να τις αντικαθιστά.",
|
||||||
|
"closing": "Η Helexa δεν είναι πλατφόρμα. Δεν είναι cloud.\nΕίναι ένα πλέγμα — ένα ζωντανό, εξελισσόμενο δίκτυο ανεξάρτητων operators που σχηματίζουν ένα νέο είδος νοημοσύνης."
|
||||||
|
},
|
||||||
|
|
||||||
|
"whyNow": {
|
||||||
|
"title": "Ένα σημείο καμπής για την ΤΝ",
|
||||||
|
"problemTitle": "Το πρόβλημα",
|
||||||
|
"problemBullet1": "Η ΤΝ συγκεντρώνεται πιο γρήγορα από κάθε προηγούμενη τεχνολογία.",
|
||||||
|
"problemBullet2": "Η πρόσβαση σε υπολογιστική ισχύ καθορίζει τις δυνατότητες και αυτή η πρόσβαση περιορίζεται.",
|
||||||
|
"problemBullet3": "Τα κόστη αποκλείουν ερευνητές, startups και κοινότητες.",
|
||||||
|
"problemBullet4": "Γεωπολιτικές και ρυθμιστικές πιέσεις απειλούν τη συνολική διαθεσιμότητα.",
|
||||||
|
"problemBullet5": "Οι δημιουργοί μοντέλων και οι operators υλικού σπάνια μοιράζονται την αξία που παράγουν.",
|
||||||
|
"opportunityTitle": "Η ευκαιρία",
|
||||||
|
"opportunityIntro": "Όμως ένας κατανεμημένος κόσμος είναι εφικτός.",
|
||||||
|
"opportunityBullet1": "Χιλιάδες GPUs σε όλο τον κόσμο παραμένουν υποαπασχολημένες.",
|
||||||
|
"opportunityBullet2": "Οι operators θέλουν δίκαιη αποζημίωση για την υπολογιστική ισχύ.",
|
||||||
|
"opportunityBullet3": "Οι developers θέλουν ανοιχτή, ανθεκτική στη λογοκρισία υποδομή.",
|
||||||
|
"opportunityBullet4": "Οι κοινότητες θέλουν κυριαρχία και ανθεκτικότητα στα ψηφιακά τους συστήματα.",
|
||||||
|
"opportunityBullet5": "Η ανάπτυξη της ΤΝ έχει ξεπεράσει τα παραδοσιακά clouds — χρειάζονται νέες μορφές.",
|
||||||
|
"opportunityClosing": "Η Helexa είναι η στιγμή όπου αυτές οι δυνάμεις ευθυγραμμίζονται."
|
||||||
|
},
|
||||||
|
|
||||||
|
"howItWorks": {
|
||||||
|
"title": "Πώς σχηματίζεται το πλέγμα",
|
||||||
|
"operators": {
|
||||||
|
"eyebrow": "Οι operators τρέχουν nodes",
|
||||||
|
"title": "Ο καθένας μπορεί να συνεισφέρει υπολογιστική ισχύ.",
|
||||||
|
"body": "Οι operators τρέχουν nodes της Helexa. Αποφασίζουν ποια μοντέλα θα φιλοξενήσουν. Παραμένουν σε έλεγχο του υλικού και της οικονομίας τους. Χωρίς εγκρίσεις, χωρίς μεσάζοντες gatekeepers."
|
||||||
|
},
|
||||||
|
"routing": {
|
||||||
|
"eyebrow": "Το πλέγμα δρομολογεί νοημοσύνη",
|
||||||
|
"title": "Η ζήτηση ρέει μέσα από το δίκτυο.",
|
||||||
|
"body": "Η Helexa μαθαίνει πού υπάρχει διαθέσιμη ισχύς, πού αυξάνεται η ζήτηση και ποια nodes είναι πιο κατάλληλα για να εξυπηρετήσουν τα αιτήματα. Το πλέγμα προσαρμόζεται οργανικά — σαν μια αναπτυσσόμενη έλικα."
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"eyebrow": "Η αξία επιστρέφει πίσω",
|
||||||
|
"title": "Η εργασία αποδεικνύεται. Η πληρωμή είναι δίκαιη.",
|
||||||
|
"body": "Κάθε εργασία φέρει μια κρυπτογραφική απόδειξη. Οι operators αμείβονται για τη νοημοσύνη που βοηθούν να παραχθεί. Χωρίς φόρο πλατφόρμας. Χωρίς αδιαφανή τιμολόγηση."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"principles": {
|
||||||
|
"title": "Χτισμένη σε αρχές, όχι σε πλατφόρμες",
|
||||||
|
"distributed": {
|
||||||
|
"title": "Κατανεμημένη από σχεδιασμό",
|
||||||
|
"body": "Χωρίς μοναδικό σημείο αποτυχίας. Χωρίς κεντρική αρχή. Ένα δίκτυο που γίνεται ισχυρότερο με κάθε νέο operator."
|
||||||
|
},
|
||||||
|
"participation": {
|
||||||
|
"title": "Ανοιχτή συμμετοχή",
|
||||||
|
"body": "Αν έχεις υπολογιστική ισχύ, μπορείς να συνεισφέρεις. Το πλέγμα καλωσορίζει τους πάντες — edge, οικιακό server, datacenter."
|
||||||
|
},
|
||||||
|
"fairness": {
|
||||||
|
"title": "Δικαιοσύνη & διαφάνεια",
|
||||||
|
"body": "Τα έσοδα βασίζονται σε πραγματική, κρυπτογραφικά επαληθευμένη εργασία. Χωρίς «μαύρα κουτιά». Χωρίς κρυφές χρεώσεις."
|
||||||
|
},
|
||||||
|
"evolving": {
|
||||||
|
"title": "Εξελισσόμενη νοημοσύνη",
|
||||||
|
"body": "Το πλέγμα μαθαίνει από τη ζήτηση. Τα μοντέλα φορτώνονται εκεί όπου χρειάζονται. Η νοημοσύνη εξαπλώνεται μέσω συνεργασίας."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"roadAhead": {
|
||||||
|
"title": "Τι στοχεύει να γίνει η Helexa",
|
||||||
|
"p1": "Ένα παγκόσμιο επίπεδο νοημοσύνης που ανήκει σε όλους, τροφοδοτούμενο από μια έλικα από nodes και κοινότητες.",
|
||||||
|
"p2": "Ένα δίκτυο ανθεκτικό σε διακοπές, πολιτική, μονοπώλια και αστοχίες.",
|
||||||
|
"p3": "Ένα νέο οικονομικό μοντέλο όπου operators, builders και χρήστες ωφελούνται όλοι.",
|
||||||
|
"p4": "Ένα οικοσύστημα όπου η καινοτομία αναπτύσσεται από τις άκρες — όχι από το κέντρο.",
|
||||||
|
"card": {
|
||||||
|
"eyebrow": "Στιγμιότυπο του οράματος",
|
||||||
|
"title": "Προς ένα κοινό πλέγμα νοημοσύνης",
|
||||||
|
"body": "Η Helexa βρίσκεται σε πρώιμο στάδιο. Οι ιδέες είναι μεγαλύτερες από την υλοποίηση — και αυτό είναι σκόπιμο. Το δίκτυο θα μεγαλώνει βήμα‑βήμα, με τους operators και τους builders να διαμορφώνουν την εξέλιξή του."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"joinMesh": {
|
||||||
|
"badge": "Πρώιμη φάση",
|
||||||
|
"title": "Το πλέγμα σχηματίζεται.",
|
||||||
|
"titleHighlight": "Μπορείς να είσαι μέρος του.",
|
||||||
|
"lead": "Είτε τρέχεις hardware, είτε χτίζεις μοντέλα, είτε απλώς σε νοιάζει πώς κυβερνάται η ΤΝ, υπάρχει μια θέση για σένα μέσα στο πλέγμα.",
|
||||||
|
"ctaRunNode": "Τρέξε ένα node (σύντομα)",
|
||||||
|
"ctaJoinAnnouncements": "Μπες στις πρώιμες ανακοινώσεις",
|
||||||
|
"ctaExploreCode": "Εξερεύνησε τον κώδικα",
|
||||||
|
"footer": "Χωρίς περιφραγμένους κήπους. Χωρίς έναν μόνο ιδιοκτήτη. Μόνο ένα πλέγμα από ανθρώπους, hardware και ιδέες — που συνθέτουν ένα διαφορετικό μέλλον για τη νοημοσύνη."
|
||||||
|
}
|
||||||
|
}
|
||||||