Compare commits

...

7 Commits

Author SHA1 Message Date
d94c62c143 feat(B4): /web/v1 account API + silent fingerprint multi-account abuse
All checks were successful
CI / Format (push) Successful in 44s
CI / CUDA type-check (push) Successful in 1m40s
CI / Clippy (push) Successful in 3m14s
CI / Test (push) Successful in 6m29s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The human-facing account surface the helexa.ai frontend (F4) consumes,
on top of B2's authz surface. Email+password auth with JWT sessions
(distinct from inference API keys); plain-JSON errors (the #63 envelope
stays on the authz surface).

- Auth lifecycle: register → email-verify → login → password-reset
  (request/confirm). argon2id passwords; verify/reset via single-use
  sha256-hashed email tokens; register and reset-request always return
  202 (no account enumeration). Email via a pluggable EmailSender (lettre
  Smtp + dev Log transport).
- API keys: create (sk-helexa-<base62(32 OsRng)>, raw shown once, stored
  as sha256 + non-secret prefix), list (prefix never the secret), archive,
  PATCH per-key limit (percent|hardcap). Protected by a JWT session
  middleware.
- Account balance endpoint (allocation total/spent/reserved).
- Silent fingerprint abuse: register captures the browser fingerprint;
  >= threshold (default 5) accounts sharing one fingerprint are silently
  deactivated + flagged — registration still returns a normal 202, and a
  deactivated account's key resolves as an ordinary 401 at the authz
  surface (no "banned" signal anywhere).
- crypto: argon2 hash/verify + CSPRNG token/key minting (base62). config
  gains [auth] + [email]. CORS on the app for the browser SPA.

Validated against a throwaway Postgres 16: verify-once, full lifecycle
(register→verify→login→create key→account→list→authz resolve→archive→401),
and 5-same-fingerprint → all accounts silently deactivated + no-clue 401.
8 unit + 11 gated integration tests; all skip cleanly without
UPSTREAM_TEST_DATABASE_URL so CI stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 10:28:20 +03:00
cb9e7c7c2e chore: untrack helexa.ai/node_modules + dist (B2 .gitignore slip)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 36s
build-prerelease / Build neuron-blackwell (push) Successful in 1m41s
build-prerelease / Build neuron-ada (push) Successful in 2m20s
build-prerelease / Build neuron-ampere (push) Successful in 2m21s
build-prerelease / Build cortex binary (push) Successful in 3m27s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m43s
build-prerelease / Test (push) Successful in 6m11s
build-prerelease / Package cortex RPM (push) Successful in 1m35s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m46s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m52s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m54s
build-prerelease / Build helexa-bench binary (push) Successful in 2m0s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m20s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 55s
The B2/B4 branches were cut before F0 added the helexa.ai .gitignore
entries, so a 'git add -A' on those branches swept node_modules into the
commit; it rode into main via the B2 merge. Untrack it (it stays on disk,
now correctly ignored). History still carries the blobs — acceptable for
an internal repo; can gc/filter later if size matters.
2026-06-23 10:27:37 +03:00
2604b9f134 Merge feat/B2-authz-api: /authz/v1 authority surface + client-auth + sweeper (B2)
Some checks failed
build-prerelease / Resolve version stamps + change detection (push) Successful in 45s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Has been skipped
build-prerelease / Test (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 10:25:27 +03:00
178e3092d5 Merge feat/F0-helexa-ai-scaffold: helexa.ai frontend scaffold (F0)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 53s
build-prerelease / Build neuron-blackwell (push) Successful in 1m27s
build-prerelease / Build neuron-ada (push) Successful in 2m4s
build-prerelease / Build neuron-ampere (push) Successful in 2m12s
build-prerelease / Build cortex binary (push) Successful in 2m32s
build-prerelease / Build helexa-bench binary (push) Successful in 2m41s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m49s
build-prerelease / Package cortex RPM (push) Successful in 1m16s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m35s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m36s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m29s
build-prerelease / Test (push) Successful in 6m40s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 2m2s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 55s
2026-06-23 10:12:38 +03:00
46befde4cd feat(B2): /authz/v1 authority surface + client-auth + reservation sweeper
All checks were successful
CI / Format (push) Successful in 49s
CI / CUDA type-check (push) Successful in 1m42s
CI / Clippy (push) Successful in 3m11s
CI / Test (push) Successful in 7m16s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The machine surface cortex's UpstreamEntitlementProvider (#57) consumes,
mirroring the EntitlementProvider trait 1:1 over the B1 ledger.

- `authz.rs`: POST /authz/v1/{resolve,reserve,settle,release,snapshot}.
  resolve → {principal, snapshot} | 401 invalid_api_key (a deactivated
  account resolves as the SAME 401 — the silent-abuse no-clue property).
  reserve returns 200 whether granted ({reservation_id}) or budget-refused
  ({rejected:{kind,...}}) — a refusal is an authoritative answer, not a
  transport failure; non-2xx means "fail closed" to the client. settle/
  release → 204 (idempotent). snapshot → {hard_cap,spent,reserved} | 404.
  Rejections use the shared #63 OpenAiError envelope (cortex-core dep).
- Client auth: shared-bearer middleware (constant-time compare via subtle)
  maps a token → operator_id (stamped into request extensions for #58
  served-usage); empty config = open dev surface (logged). mTLS deferred.
- ledger gains resolve_key (sha256 lookup, account-active-gated), snapshot,
  and sweep_stale (one data-modifying-CTE statement releasing aged-out open
  reservations and folding their reserved tokens back into accounts+keys).
- Sweeper task spawned in run(); [authz] ttl/interval + [client_auth]
  config; crypto::sha256 helper.

Validated against a throwaway Postgres 16 (fresh schema): resolve→reserve→
settle→snapshot round-trip, over-cap → 200 insufficient_quota rejection
(not retried away), deactivated account → 401 (no clue), missing/wrong
client bearer → 401 before any DB hit. 5 unit + 8 gated integration tests;
all skip cleanly without UPSTREAM_TEST_DATABASE_URL so CI stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 10:12:19 +03:00
cf87e156c5 Merge feat/B1-helexa-upstream-skeleton: helexa-upstream skeleton + schema + ledger (B1, #59)
Some checks are pending
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 32s
build-prerelease / Build neuron-blackwell (push) Successful in 1m27s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m19s
build-prerelease / Test (push) Successful in 6m30s
build-prerelease / Build neuron-ada (push) Successful in 2m5s
build-prerelease / Build neuron-ampere (push) Successful in 2m14s
build-prerelease / Build helexa-bench binary (push) Successful in 2m15s
build-prerelease / Build cortex binary (push) Successful in 2m24s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m43s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m18s
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m39s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m42s
2026-06-23 09:58:58 +03:00
79073170ec feat(F0): helexa.ai frontend scaffold + monorepo coexistence
All checks were successful
CI / Format (push) Successful in 47s
CI / CUDA type-check (push) Successful in 1m39s
CI / Clippy (push) Successful in 2m38s
CI / Test (push) Successful in 5m24s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
New top-level `helexa.ai/` app for the public beta — Vite + React (SWC) +
TypeScript + react-bootstrap + react-router + react-i18next-ready. Not a
Cargo crate; lives beside the workspace.

- Vite with @vitejs/plugin-react-swc (standard Vite + npm, not the
  reference's rolldown/pnpm pin). `vite.config.ts` dev-proxies the mesh
  data-plane (/v1, /health → helexa-router) and account control-plane
  (/api → helexa-upstream /web/v1) same-origin, targets overridable via
  VITE_ROUTER_BASE_URL / VITE_ACCOUNT_BASE_URL.
- tsconfig (app/node, ported from the reference), eslint flat config,
  minimal index.css reset + bootstrap CSS, a placeholder App shell.
- Deps pre-declared for later phases: dexie + dexie-react-hooks (IndexedDB
  chat history), @fingerprintjs/fingerprintjs (anon throttle + register
  fingerprint), i18next/react-i18next, react-icons.
- Monorepo: root .gitignore ignores helexa.ai/{node_modules,dist} +
  .env.local (mirrors the existing /bench entries); committed
  package-lock.json for reproducible installs.

Validated: npm install resolves (vite 7 + plugin-react-swc 4 + react 19),
`npm run lint`/`typecheck`/`build` all green (344 modules via SWC →
dist/). The frontend isn't in the Cargo workspace, so the Rust CI is
unaffected. A path-filtered web CI job is deferred (needs a Node-capable
runner confirmed) and folded into a later phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 09:58:35 +03:00
29 changed files with 6429 additions and 8 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
/target
/bench/node_modules
/bench/dist
/helexa.ai/node_modules
/helexa.ai/dist
helexa.ai/.env.local
*.swp
*.swo
.idea/

129
Cargo.lock generated
View File

@@ -153,6 +153,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-channel"
version = "2.5.0"
@@ -427,6 +439,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1202,6 +1223,22 @@ dependencies = [
"serde",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64 0.22.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encode_unicode"
version = "1.0.0"
@@ -1296,7 +1333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
dependencies = [
"futures-core",
"nom",
"nom 7.1.3",
"pin-project-lite",
]
@@ -2079,13 +2116,21 @@ name = "helexa-upstream"
version = "0.1.16"
dependencies = [
"anyhow",
"argon2",
"axum",
"chrono",
"clap",
"cortex-core",
"figment",
"jsonwebtoken",
"lettre",
"rand 0.8.6",
"reqwest",
"serde",
"serde_json",
"sha2",
"sqlx",
"subtle",
"thiserror 2.0.18",
"tokio",
"tower-http",
@@ -2560,6 +2605,21 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2575,6 +2635,33 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lettre"
version = "0.11.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
dependencies = [
"async-trait",
"base64 0.22.1",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"httpdate",
"idna",
"mime",
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-rustls",
"url",
"webpki-roots 1.0.7",
]
[[package]]
name = "libc"
version = "0.2.185"
@@ -2923,6 +3010,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3158,6 +3254,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -3505,6 +3612,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -4266,6 +4379,18 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "sketches-ddsketch"
version = "0.3.1"
@@ -4334,7 +4459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
dependencies = [
"base64 0.13.1",
"nom",
"nom 7.1.3",
"serde",
"unicode-segmentation",
]

View File

@@ -42,6 +42,22 @@ sqlx = { version = "0.8", default-features = false, features = [
"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 }

View 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,
))
}

View File

@@ -18,6 +18,108 @@ pub struct UpstreamConfig {
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)]
@@ -92,6 +194,30 @@ fn default_free_grant() -> i64 {
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

View 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);
}
}

View 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(())
}
}
}
}

View 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
}

View File

@@ -20,6 +20,103 @@
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

View File

@@ -7,34 +7,61 @@
//! of the `/authz/v1` surface; the helexa.ai frontend is a client of the
//! `/web/v1` surface.
//!
//! B1 (this milestone) lands the crate skeleton, the full Postgres schema
//! (`migrations/`), the reserve→settle ledger ([`ledger`]) with its
//! no-overshoot guarantee, and `/health`.
//! 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, bind the listener.
/// 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);
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}");
@@ -43,3 +70,19 @@ pub async fn run(config: UpstreamConfig) -> Result<()> {
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"),
}
}
});
}

View File

@@ -1,6 +1,7 @@
//! Shared application state.
use crate::config::UpstreamConfig;
use crate::email::EmailSender;
use sqlx::postgres::PgPool;
use std::sync::Arc;
@@ -8,13 +9,15 @@ use std::sync::Arc;
pub struct AppState {
pub pool: PgPool,
pub config: Arc<UpstreamConfig>,
pub email: EmailSender,
}
impl AppState {
pub fn new(pool: PgPool, config: UpstreamConfig) -> Self {
pub fn new(pool: PgPool, config: UpstreamConfig, email: EmailSender) -> Self {
Self {
pool,
config: Arc::new(config),
email,
}
}
}

View 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())
}

View 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);
}

View 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"
);
}

View File

@@ -21,3 +21,32 @@ url = "postgres://helexa:helexa@localhost/helexa_upstream"
# 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
View 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
View File

@@ -0,0 +1,6 @@
node_modules
dist
*.local
.env.local
.env.*.local
*.tsbuildinfo

34
helexa.ai/README.md Normal file
View 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`.

View 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
View 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

File diff suppressed because it is too large Load Diff

40
helexa.ai/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"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"
},
"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"
}
}

12
helexa.ai/src/App.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Container } from "react-bootstrap";
// F0 scaffold shell. Theming, i18n, routing, the chat workspace, mission
// page and account dashboard land in the F1+ phases.
export default function App() {
return (
<Container className="py-5">
<h1 className="mb-2">helexa.ai</h1>
<p className="text-muted">Public beta coming online.</p>
</Container>
);
}

16
helexa.ai/src/index.css Normal file
View File

@@ -0,0 +1,16 @@
/* Minimal reset; the full theme (CSS custom properties, light/dark, accent
* palette) is ported from the reference site in F1. */
:root {
color-scheme: light dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100vh;
}

11
helexa.ai/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "bootstrap/dist/css/bootstrap.min.css";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
helexa.ai/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

29
helexa.ai/vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react-swc";
// During `vite dev`, proxy the mesh data-plane (helexa-router, OpenAI-
// compatible) and the account control-plane (helexa-upstream) so the SPA
// talks to them same-origin without CORS. Targets are overridable via env.
// VITE_ROUTER_BASE_URL — helexa-router (default http://localhost:8088)
// VITE_ACCOUNT_BASE_URL — helexa-upstream (default http://localhost:8090)
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "VITE_");
const router = env.VITE_ROUTER_BASE_URL || "http://localhost:8088";
const account = env.VITE_ACCOUNT_BASE_URL || "http://localhost:8090";
return {
plugins: [react()],
server: {
proxy: {
"/v1": { target: router, changeOrigin: true },
"/health": { target: router, changeOrigin: true },
// The frontend calls /api/*; helexa-upstream serves /web/v1/*.
"/api": {
target: account,
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, "/web/v1"),
},
},
},
build: { outDir: "dist" },
};
});