Phase 4: LLM provider abstraction with Gemini, user API key encryption

Backend:
- LlmProvider async trait with generate_search_pass/generate_rewrite_pass
- GeminiProvider: googleSearch grounding (pass 1), structured JSON output (pass 2)
- AES-256-GCM encryption for user API keys at rest (per-key random nonces)
- MasterKey with zeroize-on-drop (no Clone to prevent unzeroized copies)
- User API key endpoints: list (prefix only), create/update, delete, test
- Dynamic category schema builder for structured LLM output
- Provider factory (Gemini implemented, OpenAI/Anthropic stubbed for Phase 6)
- 37 new unit tests (encryption, schema, Gemini serialization, factory)
- 17 integration tests (CRUD, encryption verification, ownership isolation)

Frontend:
- ApiKeyManager component: per-provider key management in Settings
- Password input with show/hide toggle, key prefix display (monospace)
- Test button validates key with minimal LLM call
- Status badges (configured/not configured)
- 11 new tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent 5abbf9b9ad
commit 439e547367

130
backend/Cargo.lock generated

@ -2,6 +2,41 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -15,7 +50,9 @@ dependencies = [
name = "ai-synth-backend"
version = "0.1.0"
dependencies = [
"aes-gcm",
"anyhow",
"async-trait",
"axum",
"base64",
"chrono",
@ -41,6 +78,7 @@ dependencies = [
"tracing-subscriber",
"url",
"uuid",
"zeroize",
]
[[package]]
@ -114,6 +152,17 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atoi"
version = "2.0.0"
@ -278,6 +327,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.0"
@ -411,6 +470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
@ -437,6 +497,15 @@ dependencies = [
"syn",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@ -780,6 +849,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "h2"
version = "0.4.13"
@ -1157,6 +1236,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -1467,6 +1555,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.76"
@ -1652,6 +1746,18 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@ -2810,6 +2916,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@ -3427,6 +3543,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerotrie"

@ -37,6 +37,11 @@ sha2 = "0.10"
rand = "0.8"
base64 = "0.22"
hex = "0.4"
aes-gcm = "0.10"
zeroize = { version = "1", features = ["derive"] }
# Async trait (needed for trait objects)
async-trait = "0.1"
# Logging
tracing = "0.1"

@ -0,0 +1,17 @@
-- User API keys table: stores encrypted LLM provider API keys per user.
-- Each user can have at most one key per provider.
CREATE TABLE user_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider_name VARCHAR(50) NOT NULL,
encrypted_key BYTEA NOT NULL,
nonce BYTEA NOT NULL,
key_prefix VARCHAR(10) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, provider_name)
);
CREATE INDEX idx_user_api_keys_user_id ON user_api_keys(user_id);

@ -0,0 +1,112 @@
//! Database queries for the `user_api_keys` table.
//!
//! All queries enforce ownership isolation via `user_id`.
//! Encrypted key material is stored/retrieved as raw bytes (BYTEA).
use sqlx::PgPool;
use uuid::Uuid;
use crate::errors::AppError;
use crate::models::api_key::UserApiKey;
/// List all API keys for a given user.
///
/// Returns the encrypted keys (callers must decrypt as needed).
/// Ordered by provider name for consistent display.
pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserApiKey>, AppError> {
let keys = sqlx::query_as::<_, UserApiKey>(
r#"
SELECT id, user_id, provider_name, encrypted_key, nonce, key_prefix, created_at, updated_at
FROM user_api_keys
WHERE user_id = $1
ORDER BY provider_name ASC
"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(keys)
}
/// Get a specific API key for a user and provider.
///
/// Returns `None` if the user has no key for the given provider.
pub async fn get_for_user_and_provider(
pool: &PgPool,
user_id: Uuid,
provider_name: &str,
) -> Result<Option<UserApiKey>, AppError> {
let key = sqlx::query_as::<_, UserApiKey>(
r#"
SELECT id, user_id, provider_name, encrypted_key, nonce, key_prefix, created_at, updated_at
FROM user_api_keys
WHERE user_id = $1 AND provider_name = $2
"#,
)
.bind(user_id)
.bind(provider_name)
.fetch_optional(pool)
.await?;
Ok(key)
}
/// Insert or update an API key for a user and provider.
///
/// Uses `ON CONFLICT` to upsert: if the user already has a key for
/// the given provider, the encrypted key, nonce, prefix, and timestamp
/// are updated.
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
provider_name: &str,
encrypted_key: &[u8],
nonce: &[u8],
key_prefix: &str,
) -> Result<UserApiKey, AppError> {
let key = sqlx::query_as::<_, UserApiKey>(
r#"
INSERT INTO user_api_keys (user_id, provider_name, encrypted_key, nonce, key_prefix)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, provider_name)
DO UPDATE SET
encrypted_key = EXCLUDED.encrypted_key,
nonce = EXCLUDED.nonce,
key_prefix = EXCLUDED.key_prefix,
updated_at = NOW()
RETURNING id, user_id, provider_name, encrypted_key, nonce, key_prefix, created_at, updated_at
"#,
)
.bind(user_id)
.bind(provider_name)
.bind(encrypted_key)
.bind(nonce)
.bind(key_prefix)
.fetch_one(pool)
.await?;
Ok(key)
}
/// Delete an API key for a user and provider.
///
/// Returns `true` if a row was deleted, `false` if no matching key was found.
pub async fn delete(
pool: &PgPool,
user_id: Uuid,
provider_name: &str,
) -> Result<bool, AppError> {
let result = sqlx::query(
r#"
DELETE FROM user_api_keys
WHERE user_id = $1 AND provider_name = $2
"#,
)
.bind(user_id)
.bind(provider_name)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}

@ -1,3 +1,4 @@
pub mod api_keys;
pub mod audit;
pub mod magic_links;
pub mod providers;

@ -0,0 +1,218 @@
//! User API key handlers.
//!
//! - `GET /api/v1/user/api-keys` — list user's keys (prefix only, never full key)
//! - `POST /api/v1/user/api-keys` — add/update a key (encrypted before storage)
//! - `DELETE /api/v1/user/api-keys/:provider` — remove a key
//! - `POST /api/v1/user/api-keys/:provider/test` — test a key with a minimal LLM call
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::Serialize;
use crate::app_state::AppState;
use crate::db;
use crate::errors::AppError;
use crate::middleware::auth::AuthUser;
use crate::models::api_key::{extract_key_prefix, ApiKeyResponse, CreateApiKeyRequest};
use crate::services::encryption::{self, MasterKey};
use crate::services::llm::factory;
/// `GET /api/v1/user/api-keys`
///
/// Returns all API keys belonging to the authenticated user.
/// Only the key prefix is included — the full key is NEVER returned.
pub async fn list(
auth_user: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, AppError> {
let keys = db::api_keys::list_for_user(&state.pool, auth_user.id).await?;
let response: Vec<ApiKeyResponse> = keys.into_iter().map(ApiKeyResponse::from).collect();
Ok(Json(response))
}
/// `POST /api/v1/user/api-keys`
///
/// Adds or updates an API key for the authenticated user.
/// The key is encrypted with AES-256-GCM before storage.
/// A prefix of the first 8 characters is stored for display.
pub async fn create(
auth_user: AuthUser,
State(state): State<AppState>,
Json(body): Json<CreateApiKeyRequest>,
) -> Result<impl IntoResponse, AppError> {
body.validate().map_err(AppError::Validation)?;
let master_key = MasterKey::from_hex(&state.config.master_encryption_key)?;
// Extract prefix before encryption (first 8 chars + "...")
let key_prefix = extract_key_prefix(&body.api_key);
// Encrypt the raw API key
let (encrypted_key, nonce) = encryption::encrypt(&master_key, &body.api_key)?;
// Upsert: insert or update the key for this user+provider
let saved_key = db::api_keys::upsert(
&state.pool,
auth_user.id,
&body.provider_name,
&encrypted_key,
&nonce,
&key_prefix,
)
.await?;
let response = ApiKeyResponse::from(saved_key);
Ok((StatusCode::OK, Json(response)))
}
/// `DELETE /api/v1/user/api-keys/:provider`
///
/// Removes an API key for the authenticated user and given provider.
/// Returns 404 if no key exists for that provider.
pub async fn delete(
auth_user: AuthUser,
State(state): State<AppState>,
Path(provider): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let deleted = db::api_keys::delete(&state.pool, auth_user.id, &provider).await?;
if !deleted {
return Err(AppError::NotFound(format!(
"No API key found for provider '{}'",
provider
)));
}
Ok(StatusCode::NO_CONTENT)
}
/// Response body for the test endpoint.
#[derive(Serialize)]
struct TestResult {
success: bool,
message: String,
}
/// `POST /api/v1/user/api-keys/:provider/test`
///
/// Tests the user's API key by making a minimal LLM call.
/// Decrypts the stored key, creates a provider instance, and sends
/// a simple request to verify the key works.
pub async fn test_key(
auth_user: AuthUser,
State(state): State<AppState>,
Path(provider): Path<String>,
) -> Result<impl IntoResponse, AppError> {
// Look up the stored key for this user+provider
let stored_key = db::api_keys::get_for_user_and_provider(
&state.pool,
auth_user.id,
&provider,
)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("No API key found for provider '{}'", provider))
})?;
// Decrypt the key
let master_key = MasterKey::from_hex(&state.config.master_encryption_key)?;
let decrypted_key =
encryption::decrypt(&master_key, &stored_key.encrypted_key, &stored_key.nonce)?;
// Create a provider instance
let llm_provider = factory::create_provider(&provider, decrypted_key, &state.http_client)?;
// Make a minimal test call using the rewrite pass (no web search needed)
let test_schema = serde_json::json!({
"type": "object",
"properties": {
"greeting": {
"type": "string"
}
},
"required": ["greeting"]
});
// Use a simple, deterministic prompt for testing
// We need to pick a default model for the test. Use the first available
// model from the admin config, or a sensible default.
let test_model = get_default_model_for_provider(&state, &provider).await?;
let result = llm_provider
.generate_rewrite_pass(
&test_model,
"You are a test assistant. Respond in JSON as instructed.",
"Say hello in one word.",
&test_schema,
)
.await;
match result {
Ok(_) => Ok(Json(TestResult {
success: true,
message: "API key is valid and working".into(),
})),
Err(e) => {
// Return the error as a test failure, not a server error
let message = match &e {
AppError::BadRequest(msg) => msg.clone(),
AppError::RateLimited(msg) => msg.clone(),
_ => "API key test failed. The key may be invalid or the provider may be unavailable.".into(),
};
Ok(Json(TestResult {
success: false,
message,
}))
}
}
}
/// Get a default model identifier for a provider from the admin config.
///
/// Looks up the admin provider configuration for the given provider name
/// and returns the default model, or the first available model.
async fn get_default_model_for_provider(
state: &AppState,
provider_name: &str,
) -> Result<String, AppError> {
let providers = db::providers::list_all(&state.pool).await?;
let provider = providers
.iter()
.find(|p| p.provider_name == provider_name && p.is_enabled);
match provider {
Some(p) => {
// Find the default model, or use the first one
let model = p
.models
.iter()
.find(|m| m.is_default)
.or_else(|| p.models.first())
.ok_or_else(|| {
AppError::BadRequest(format!(
"No models configured for provider '{}'",
provider_name
))
})?;
Ok(model.model_id.clone())
}
None => {
// Fallback defaults if provider isn't configured yet
let default_model = match provider_name {
"gemini" => "gemini-2.5-flash",
"openai" => "gpt-4o-mini",
"anthropic" => "claude-sonnet-4-20250514",
_ => {
return Err(AppError::BadRequest(format!(
"Unknown provider: '{}'",
provider_name
)));
}
};
Ok(default_model.to_string())
}
}
}

@ -1,4 +1,5 @@
pub mod admin;
pub mod api_keys;
pub mod auth;
pub mod config;
pub mod health;

@ -0,0 +1,179 @@
//! User API key model and request/response types.
//!
//! Represents encrypted LLM provider API keys stored per user.
//! The full key is NEVER returned via the API — only a prefix for display.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user API key record from the database.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct UserApiKey {
pub id: Uuid,
pub user_id: Uuid,
pub provider_name: String,
pub encrypted_key: Vec<u8>,
pub nonce: Vec<u8>,
pub key_prefix: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Known provider names for validation.
const VALID_PROVIDERS: &[&str] = &["gemini", "openai", "anthropic"];
/// Request body for `POST /api/v1/user/api-keys`.
///
/// Contains the raw (unencrypted) API key from the user.
/// The key is encrypted before storage and never stored in plaintext.
#[derive(Debug, Deserialize)]
pub struct CreateApiKeyRequest {
pub provider_name: String,
pub api_key: String,
}
impl CreateApiKeyRequest {
/// Validate the API key creation request.
pub fn validate(&self) -> Result<(), String> {
let provider = self.provider_name.trim();
if provider.is_empty() {
return Err("Provider name cannot be empty".into());
}
if !VALID_PROVIDERS.contains(&provider) {
return Err(format!(
"Invalid provider '{}'. Must be one of: {}",
provider,
VALID_PROVIDERS.join(", ")
));
}
let key = self.api_key.trim();
if key.is_empty() {
return Err("API key cannot be empty".into());
}
if key.len() < 8 {
return Err("API key must be at least 8 characters".into());
}
if key.len() > 500 {
return Err("API key must be at most 500 characters".into());
}
Ok(())
}
}
/// Request body for `POST /api/v1/user/api-keys/:provider/test`.
#[derive(Debug, Deserialize)]
pub struct TestApiKeyRequest {
pub provider_name: String,
}
/// Response for API key endpoints.
///
/// NEVER includes the full key — only a prefix for display purposes.
#[derive(Debug, Serialize)]
pub struct ApiKeyResponse {
pub id: Uuid,
pub provider_name: String,
pub key_prefix: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<UserApiKey> for ApiKeyResponse {
fn from(k: UserApiKey) -> Self {
Self {
id: k.id,
provider_name: k.provider_name,
key_prefix: k.key_prefix,
created_at: k.created_at,
updated_at: k.updated_at,
}
}
}
/// Extract a display prefix from the raw API key.
///
/// Takes the first 8 characters followed by "..." for display.
/// If the key is shorter than 8 characters, uses whatever is available.
pub fn extract_key_prefix(api_key: &str) -> String {
let prefix_len = api_key.len().min(8);
format!("{}...", &api_key[..prefix_len])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_create_request() {
let req = CreateApiKeyRequest {
provider_name: "gemini".into(),
api_key: "AIzaSyB-test-key-12345".into(),
};
assert!(req.validate().is_ok());
}
#[test]
fn test_invalid_provider() {
let req = CreateApiKeyRequest {
provider_name: "mistral".into(),
api_key: "some-key-value-1234".into(),
};
let err = req.validate().unwrap_err();
assert!(err.contains("Invalid provider"));
}
#[test]
fn test_empty_provider() {
let req = CreateApiKeyRequest {
provider_name: " ".into(),
api_key: "some-key-value-1234".into(),
};
let err = req.validate().unwrap_err();
assert!(err.contains("cannot be empty"));
}
#[test]
fn test_empty_api_key() {
let req = CreateApiKeyRequest {
provider_name: "gemini".into(),
api_key: "".into(),
};
let err = req.validate().unwrap_err();
assert!(err.contains("cannot be empty"));
}
#[test]
fn test_short_api_key() {
let req = CreateApiKeyRequest {
provider_name: "gemini".into(),
api_key: "short".into(),
};
let err = req.validate().unwrap_err();
assert!(err.contains("at least 8"));
}
#[test]
fn test_key_prefix_extraction() {
assert_eq!(extract_key_prefix("AIzaSyB-test-key"), "AIzaSyB-...");
assert_eq!(extract_key_prefix("sk-proj-abc123def456"), "sk-proj-...");
}
#[test]
fn test_key_prefix_exactly_8_chars() {
assert_eq!(extract_key_prefix("12345678"), "12345678...");
}
#[test]
fn test_all_valid_providers() {
for provider in &["gemini", "openai", "anthropic"] {
let req = CreateApiKeyRequest {
provider_name: provider.to_string(),
api_key: "valid-key-12345678".into(),
};
assert!(req.validate().is_ok(), "Provider '{}' should be valid", provider);
}
}
}

@ -1,3 +1,4 @@
pub mod api_key;
pub mod audit;
pub mod magic_link;
pub mod provider;

@ -44,6 +44,11 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router {
.route("/sources/bulk", post(handlers::sources::bulk_import))
.route("/sources/import-csv", post(handlers::sources::import_csv))
.route("/sources/export-csv", get(handlers::sources::export_csv))
// User API key management
.route("/user/api-keys", get(handlers::api_keys::list))
.route("/user/api-keys", post(handlers::api_keys::create))
.route("/user/api-keys/{provider}", delete(handlers::api_keys::delete))
.route("/user/api-keys/{provider}/test", post(handlers::api_keys::test_key))
// Public config (authenticated, non-admin)
.route("/config/providers", get(handlers::config::list_enabled_providers))
// Admin routes

@ -0,0 +1,186 @@
//! AES-256-GCM encryption service for user API keys.
//!
//! Provides authenticated encryption with unique random nonces.
//! The master key is loaded from the `MASTER_ENCRYPTION_KEY` env var
//! (64 hex characters = 32 bytes).
use aes_gcm::aead::{Aead, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit, Nonce};
use zeroize::Zeroize;
use crate::errors::AppError;
/// Master encryption key for AES-256-GCM.
///
/// Holds the raw 32-byte key in memory. The key bytes are zeroized on drop.
/// Intentionally does NOT derive `Clone` — clones would not be zeroized,
/// leaving key material in memory. Use references (`&MasterKey`) instead.
pub struct MasterKey {
key_bytes: Vec<u8>,
}
impl MasterKey {
/// Create a `MasterKey` from a 64-character hex string (32 bytes).
///
/// Returns an error if the hex string is malformed or the wrong length.
pub fn from_hex(hex_str: &str) -> Result<Self, AppError> {
let key_bytes = hex::decode(hex_str).map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"Failed to decode master encryption key: {}",
e
))
})?;
if key_bytes.len() != 32 {
return Err(AppError::Internal(anyhow::anyhow!(
"Master encryption key must be exactly 32 bytes (64 hex chars), got {} bytes",
key_bytes.len()
)));
}
Ok(Self { key_bytes })
}
/// Returns the raw key bytes (for use with AES-256-GCM).
fn as_bytes(&self) -> &[u8] {
&self.key_bytes
}
}
impl Drop for MasterKey {
fn drop(&mut self) {
self.key_bytes.zeroize();
}
}
/// Encrypt plaintext using AES-256-GCM with a random 12-byte nonce.
///
/// Returns `(ciphertext, nonce)`. The nonce must be stored alongside
/// the ciphertext for decryption.
pub fn encrypt(master_key: &MasterKey, plaintext: &str) -> Result<(Vec<u8>, Vec<u8>), AppError> {
let key = Key::<Aes256Gcm>::from_slice(master_key.as_bytes());
let cipher = Aes256Gcm::new(key);
// Generate a unique 12-byte nonce using OS randomness
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| AppError::Internal(anyhow::anyhow!("Encryption failed: {}", e)))?;
Ok((ciphertext, nonce.to_vec()))
}
/// Decrypt ciphertext using AES-256-GCM with the provided nonce.
///
/// Returns the decrypted plaintext string. Returns an error if the key
/// is wrong, the data is corrupted, or the nonce doesn't match.
pub fn decrypt(
master_key: &MasterKey,
ciphertext: &[u8],
nonce_bytes: &[u8],
) -> Result<String, AppError> {
let key = Key::<Aes256Gcm>::from_slice(master_key.as_bytes());
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| AppError::Internal(anyhow::anyhow!("Decryption failed: invalid key or corrupted data")))?;
String::from_utf8(plaintext)
.map_err(|_| AppError::Internal(anyhow::anyhow!("Decrypted data is not valid UTF-8")))
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper: create a valid test master key (64 hex chars = 32 bytes).
fn test_master_key() -> MasterKey {
MasterKey::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
.unwrap()
}
#[test]
fn encrypt_decrypt_roundtrip() {
let key = test_master_key();
let plaintext = "AIzaSyB-test-key-12345";
let (ciphertext, nonce) = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &ciphertext, &nonce).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn different_nonces_each_time() {
let key = test_master_key();
let plaintext = "same-key-value";
let (_, nonce1) = encrypt(&key, plaintext).unwrap();
let (_, nonce2) = encrypt(&key, plaintext).unwrap();
assert_ne!(nonce1, nonce2, "Each encryption must produce a unique nonce");
}
#[test]
fn wrong_key_fails() {
let key1 = test_master_key();
let key2 = MasterKey::from_hex(
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
)
.unwrap();
let plaintext = "secret-api-key";
let (ciphertext, nonce) = encrypt(&key1, plaintext).unwrap();
let result = decrypt(&key2, &ciphertext, &nonce);
assert!(result.is_err(), "Decryption with wrong key must fail");
}
#[test]
fn corrupted_data_fails() {
let key = test_master_key();
let plaintext = "secret-api-key";
let (mut ciphertext, nonce) = encrypt(&key, plaintext).unwrap();
// Flip a byte in the ciphertext
if let Some(byte) = ciphertext.first_mut() {
*byte ^= 0xFF;
}
let result = decrypt(&key, &ciphertext, &nonce);
assert!(result.is_err(), "Decryption of corrupted data must fail");
}
#[test]
fn invalid_hex_key_rejected() {
let result = MasterKey::from_hex("not-valid-hex");
assert!(result.is_err());
}
#[test]
fn wrong_length_key_rejected() {
// 32 hex chars = 16 bytes, too short
let result = MasterKey::from_hex("0123456789abcdef0123456789abcdef");
assert!(result.is_err());
}
#[test]
fn nonce_is_12_bytes() {
let key = test_master_key();
let (_, nonce) = encrypt(&key, "test").unwrap();
assert_eq!(nonce.len(), 12);
}
#[test]
fn empty_plaintext_roundtrip() {
let key = test_master_key();
let (ciphertext, nonce) = encrypt(&key, "").unwrap();
let decrypted = decrypt(&key, &ciphertext, &nonce).unwrap();
assert_eq!(decrypted, "");
}
}

@ -0,0 +1,81 @@
//! Provider factory: creates the correct `LlmProvider` implementation
//! based on the provider name and the user's decrypted API key.
use super::gemini::GeminiProvider;
use super::LlmProvider;
use crate::errors::AppError;
/// Create an LLM provider instance for the given provider name.
///
/// # Arguments
/// * `provider_name` — One of "gemini", "openai", "anthropic"
/// * `api_key` — The decrypted API key for the provider
/// * `http_client` — Shared HTTP client for making API calls
///
/// # Errors
/// Returns `AppError::BadRequest` if the provider is not yet supported.
pub fn create_provider(
provider_name: &str,
api_key: String,
http_client: &reqwest::Client,
) -> Result<Box<dyn LlmProvider>, AppError> {
match provider_name {
"gemini" => Ok(Box::new(GeminiProvider::new(api_key, http_client.clone()))),
"openai" => Err(AppError::BadRequest(
"OpenAI provider is not yet implemented (planned for Phase 6)".into(),
)),
"anthropic" => Err(AppError::BadRequest(
"Anthropic provider is not yet implemented (planned for Phase 6)".into(),
)),
_ => Err(AppError::BadRequest(format!(
"Unknown provider: '{}'",
provider_name
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn factory_creates_gemini_provider() {
let client = reqwest::Client::new();
let provider = create_provider("gemini", "test-key".into(), &client).unwrap();
assert_eq!(provider.provider_id(), "gemini");
assert!(provider.supports_web_search());
}
#[test]
fn factory_rejects_openai() {
let client = reqwest::Client::new();
let result = create_provider("openai", "test-key".into(), &client);
match result {
Err(AppError::BadRequest(msg)) => assert!(msg.contains("not yet implemented")),
Err(_) => panic!("Expected BadRequest variant"),
Ok(_) => panic!("Expected error for openai"),
}
}
#[test]
fn factory_rejects_anthropic() {
let client = reqwest::Client::new();
let result = create_provider("anthropic", "test-key".into(), &client);
match result {
Err(AppError::BadRequest(msg)) => assert!(msg.contains("not yet implemented")),
Err(_) => panic!("Expected BadRequest variant"),
Ok(_) => panic!("Expected error for anthropic"),
}
}
#[test]
fn factory_rejects_unknown_provider() {
let client = reqwest::Client::new();
let result = create_provider("mistral", "test-key".into(), &client);
match result {
Err(AppError::BadRequest(msg)) => assert!(msg.contains("Unknown provider")),
Err(_) => panic!("Expected BadRequest variant"),
Ok(_) => panic!("Expected error for unknown provider"),
}
}
}

@ -0,0 +1,393 @@
//! Google Gemini LLM provider implementation.
//!
//! Implements the `LlmProvider` trait using the Gemini REST API.
//! Supports both web search grounding (Pass 1) and plain structured
//! output (Pass 2) via the `generateContent` endpoint.
use async_trait::async_trait;
use serde_json::Value;
use super::LlmProvider;
use crate::errors::AppError;
/// Google Gemini provider.
///
/// Holds the API key and an HTTP client for making requests
/// to the Gemini `generateContent` API.
pub struct GeminiProvider {
api_key: String,
http_client: reqwest::Client,
}
impl GeminiProvider {
/// Create a new Gemini provider with the given API key and HTTP client.
pub fn new(api_key: String, http_client: reqwest::Client) -> Self {
Self {
api_key,
http_client,
}
}
/// Build the Gemini API URL for a given model.
fn api_url(&self, model: &str) -> String {
format!(
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
model, self.api_key
)
}
/// Execute a generateContent request and parse the response.
async fn generate_content(&self, model: &str, body: &Value) -> Result<Value, AppError> {
let url = self.api_url(model);
let response = self
.http_client
.post(&url)
.json(body)
.send()
.await
.map_err(|e| {
tracing::error!("Gemini API request failed: {}", e);
AppError::Internal(anyhow::anyhow!("Failed to connect to Gemini API"))
})?;
let status = response.status();
let response_body: Value = response.json().await.map_err(|e| {
tracing::error!("Failed to parse Gemini response: {}", e);
AppError::Internal(anyhow::anyhow!("Failed to parse Gemini API response"))
})?;
// Handle error responses
if !status.is_success() {
return Err(map_gemini_error(status.as_u16(), &response_body));
}
// Extract the text content from the Gemini response structure
extract_content(&response_body)
}
}
#[async_trait]
impl LlmProvider for GeminiProvider {
fn provider_id(&self) -> &str {
"gemini"
}
async fn generate_search_pass(
&self,
model: &str,
system_prompt: &str,
user_prompt: &str,
response_schema: &Value,
) -> Result<Value, AppError> {
let body = build_request_body(
system_prompt,
user_prompt,
response_schema,
true, // include googleSearch tool
);
self.generate_content(model, &body).await
}
async fn generate_rewrite_pass(
&self,
model: &str,
system_prompt: &str,
user_prompt: &str,
response_schema: &Value,
) -> Result<Value, AppError> {
let body = build_request_body(
system_prompt,
user_prompt,
response_schema,
false, // no tools for rewrite
);
self.generate_content(model, &body).await
}
fn supports_web_search(&self) -> bool {
true
}
}
/// Build the JSON request body for the Gemini `generateContent` endpoint.
///
/// When `include_search` is true, the `googleSearch` tool is included
/// to enable web search grounding (Pass 1).
fn build_request_body(
system_prompt: &str,
user_prompt: &str,
response_schema: &Value,
include_search: bool,
) -> Value {
let mut body = serde_json::json!({
"contents": [{
"role": "user",
"parts": [{
"text": user_prompt
}]
}],
"systemInstruction": {
"parts": [{
"text": system_prompt
}]
},
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": response_schema
}
});
if include_search {
body["tools"] = serde_json::json!([{
"googleSearch": {}
}]);
}
body
}
/// Extract the text content from a Gemini API response.
///
/// The response structure is:
/// ```json
/// { "candidates": [{ "content": { "parts": [{ "text": "..." }] } }] }
/// ```
///
/// The text field contains a JSON string that we parse into a `Value`.
fn extract_content(response: &Value) -> Result<Value, AppError> {
let text = response
.get("candidates")
.and_then(|c| c.get(0))
.and_then(|c| c.get("content"))
.and_then(|c| c.get("parts"))
.and_then(|p| p.get(0))
.and_then(|p| p.get("text"))
.and_then(|t| t.as_str())
.ok_or_else(|| {
tracing::error!("Unexpected Gemini response structure: {:?}", response);
AppError::Internal(anyhow::anyhow!(
"Gemini API returned an unexpected response structure"
))
})?;
// The text content is a JSON string — parse it
serde_json::from_str(text).map_err(|e| {
tracing::error!("Failed to parse Gemini JSON output: {}", e);
AppError::Internal(anyhow::anyhow!(
"Gemini returned invalid JSON in structured output"
))
})
}
/// Map Gemini API error responses to appropriate `AppError` variants.
///
/// Handles common error codes without exposing internal details.
fn map_gemini_error(status: u16, body: &Value) -> AppError {
let error_message = body
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
let error_status = body
.get("error")
.and_then(|e| e.get("status"))
.and_then(|s| s.as_str())
.unwrap_or("");
tracing::error!(
"Gemini API error (HTTP {}): {} (status: {})",
status,
error_message,
error_status
);
match status {
400 => AppError::BadRequest("Invalid request to LLM provider".into()),
401 | 403 => AppError::BadRequest("Invalid or unauthorized API key".into()),
404 => AppError::BadRequest("Model not found or not available".into()),
429 => AppError::RateLimited("LLM provider rate limit exceeded. Please try again later.".into()),
_ => AppError::Internal(anyhow::anyhow!("LLM provider returned an error")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_request_body_with_search() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"category_0": {
"type": "array",
"items": { "type": "object" }
}
}
});
let body = build_request_body("system prompt", "user prompt", &schema, true);
// Verify contents
assert_eq!(
body["contents"][0]["role"].as_str().unwrap(),
"user"
);
assert_eq!(
body["contents"][0]["parts"][0]["text"].as_str().unwrap(),
"user prompt"
);
// Verify system instruction
assert_eq!(
body["systemInstruction"]["parts"][0]["text"].as_str().unwrap(),
"system prompt"
);
// Verify tools (googleSearch present)
assert!(body["tools"].is_array());
assert!(body["tools"][0].get("googleSearch").is_some());
// Verify generation config
assert_eq!(
body["generationConfig"]["responseMimeType"].as_str().unwrap(),
"application/json"
);
assert!(body["generationConfig"]["responseSchema"].is_object());
}
#[test]
fn build_request_body_without_search() {
let schema = serde_json::json!({"type": "object"});
let body = build_request_body("sys", "user", &schema, false);
// No tools key when search is disabled
assert!(body.get("tools").is_none());
}
#[test]
fn extract_content_valid_response() {
let response = serde_json::json!({
"candidates": [{
"content": {
"parts": [{
"text": "{\"category_0\": [{\"title\": \"Test\", \"url\": \"https://example.com\", \"summary\": \"A test article\"}]}"
}]
}
}]
});
let result = extract_content(&response).unwrap();
assert!(result["category_0"].is_array());
assert_eq!(result["category_0"][0]["title"].as_str().unwrap(), "Test");
}
#[test]
fn extract_content_missing_candidates() {
let response = serde_json::json!({});
assert!(extract_content(&response).is_err());
}
#[test]
fn extract_content_empty_candidates() {
let response = serde_json::json!({"candidates": []});
assert!(extract_content(&response).is_err());
}
#[test]
fn extract_content_invalid_json_text() {
let response = serde_json::json!({
"candidates": [{
"content": {
"parts": [{
"text": "this is not valid json"
}]
}
}]
});
assert!(extract_content(&response).is_err());
}
#[test]
fn map_gemini_error_invalid_key() {
let body = serde_json::json!({
"error": {
"code": 403,
"message": "API key not valid",
"status": "PERMISSION_DENIED"
}
});
let err = map_gemini_error(403, &body);
match err {
AppError::BadRequest(msg) => assert!(msg.contains("unauthorized")),
_ => panic!("Expected BadRequest for 403"),
}
}
#[test]
fn map_gemini_error_rate_limited() {
let body = serde_json::json!({
"error": {
"code": 429,
"message": "Resource exhausted",
"status": "RESOURCE_EXHAUSTED"
}
});
let err = map_gemini_error(429, &body);
match err {
AppError::RateLimited(msg) => assert!(msg.contains("rate limit")),
_ => panic!("Expected RateLimited for 429"),
}
}
#[test]
fn map_gemini_error_model_not_found() {
let body = serde_json::json!({
"error": {
"code": 404,
"message": "Model not found",
"status": "NOT_FOUND"
}
});
let err = map_gemini_error(404, &body);
match err {
AppError::BadRequest(msg) => assert!(msg.contains("not found")),
_ => panic!("Expected BadRequest for 404"),
}
}
#[test]
fn map_gemini_error_server_error() {
let body = serde_json::json!({
"error": {
"code": 500,
"message": "Internal error",
"status": "INTERNAL"
}
});
let err = map_gemini_error(500, &body);
match err {
AppError::Internal(_) => {} // expected
_ => panic!("Expected Internal for 500"),
}
}
#[test]
fn gemini_provider_supports_web_search() {
let provider = GeminiProvider::new(
"test-key".into(),
reqwest::Client::new(),
);
assert!(provider.supports_web_search());
assert_eq!(provider.provider_id(), "gemini");
}
}

@ -0,0 +1,75 @@
//! LLM provider abstraction layer.
//!
//! Defines the `LlmProvider` trait that all LLM providers implement,
//! along with shared types and the provider factory function.
pub mod factory;
pub mod gemini;
pub mod schema;
use async_trait::async_trait;
use serde_json::Value;
use crate::errors::AppError;
/// Capabilities advertised by an LLM provider.
#[derive(Debug, Clone)]
pub struct ProviderCapabilities {
/// Whether the provider supports native web search grounding.
pub supports_web_search: bool,
/// Whether the provider supports structured output via JSON schema.
pub supports_structured_output: bool,
}
/// Trait defining the contract for LLM provider implementations.
///
/// Each provider (Gemini, OpenAI, Anthropic) implements this trait
/// to provide a unified interface for the synthesis generation pipeline.
///
/// The pipeline uses two passes:
/// - **Search pass**: Generates content with web search grounding (if supported)
/// - **Rewrite pass**: Rewrites/consolidates content with structured output
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// Returns the provider identifier (e.g., "gemini", "openai", "anthropic").
fn provider_id(&self) -> &str;
/// Generate content with web search grounding (Pass 1).
///
/// For providers that support native web search (e.g., Gemini with googleSearch),
/// this pass retrieves and structures information from the web.
///
/// # Arguments
/// * `model` — The model identifier (e.g., "gemini-2.5-pro")
/// * `system_prompt` — System-level instructions for the model
/// * `user_prompt` — The user's prompt with search criteria
/// * `response_schema` — JSON Schema defining the expected response structure
async fn generate_search_pass(
&self,
model: &str,
system_prompt: &str,
user_prompt: &str,
response_schema: &Value,
) -> Result<Value, AppError>;
/// Generate content without web search (Pass 2).
///
/// Used for rewriting, consolidating, or reformatting content
/// with structured output but no web search tools.
///
/// # Arguments
/// * `model` — The model identifier
/// * `system_prompt` — System-level instructions for the model
/// * `user_prompt` — The user's prompt (typically includes content from Pass 1)
/// * `response_schema` — JSON Schema defining the expected response structure
async fn generate_rewrite_pass(
&self,
model: &str,
system_prompt: &str,
user_prompt: &str,
response_schema: &Value,
) -> Result<Value, AppError>;
/// Whether this provider supports native web search grounding.
fn supports_web_search(&self) -> bool;
}

@ -0,0 +1,197 @@
//! JSON Schema builder for structured LLM output.
//!
//! Constructs the JSON Schema that is passed to the LLM provider
//! to enforce structured output matching the user's categories.
use serde_json::Value;
/// Build a JSON Schema for structured output based on user categories.
///
/// Each category is mapped to a property named `category_0`, `category_1`, etc.
/// Each property is an array of news items with `title`, `url`, and `summary` fields.
///
/// # Example
///
/// For categories `["Major Announcements", "Research"]`, produces:
/// ```json
/// {
/// "type": "object",
/// "properties": {
/// "category_0": {
/// "type": "array",
/// "description": "Major Announcements",
/// "items": {
/// "type": "object",
/// "properties": {
/// "title": { "type": "string" },
/// "url": { "type": "string" },
/// "summary": { "type": "string" }
/// },
/// "required": ["title", "url", "summary"]
/// }
/// },
/// "category_1": { ... }
/// },
/// "required": ["category_0", "category_1"]
/// }
/// ```
pub fn build_category_schema(categories: &[String]) -> Value {
let news_item_schema = serde_json::json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the news article"
},
"url": {
"type": "string",
"description": "The URL of the source article"
},
"summary": {
"type": "string",
"description": "A concise summary of the article"
}
},
"required": ["title", "url", "summary"]
});
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for (i, category_name) in categories.iter().enumerate() {
let key = format!("category_{}", i);
properties.insert(
key.clone(),
serde_json::json!({
"type": "array",
"description": category_name,
"items": news_item_schema
}),
);
required.push(Value::String(key));
}
serde_json::json!({
"type": "object",
"properties": properties,
"required": required
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_with_one_category() {
let categories = vec!["AI News".to_string()];
let schema = build_category_schema(&categories);
assert_eq!(schema["type"], "object");
// One property
let props = schema["properties"].as_object().unwrap();
assert_eq!(props.len(), 1);
assert!(props.contains_key("category_0"));
// Category description
assert_eq!(props["category_0"]["description"], "AI News");
// Array type with items
assert_eq!(props["category_0"]["type"], "array");
let items = &props["category_0"]["items"];
assert_eq!(items["type"], "object");
assert!(items["properties"].get("title").is_some());
assert!(items["properties"].get("url").is_some());
assert!(items["properties"].get("summary").is_some());
// Required fields
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "category_0");
}
#[test]
fn schema_with_three_categories() {
let categories = vec![
"Annonces majeures".to_string(),
"Recherche".to_string(),
"Secteur public".to_string(),
];
let schema = build_category_schema(&categories);
let props = schema["properties"].as_object().unwrap();
assert_eq!(props.len(), 3);
assert_eq!(props["category_0"]["description"], "Annonces majeures");
assert_eq!(props["category_1"]["description"], "Recherche");
assert_eq!(props["category_2"]["description"], "Secteur public");
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 3);
}
#[test]
fn schema_with_five_categories() {
let categories: Vec<String> = (0..5)
.map(|i| format!("Category {}", i))
.collect();
let schema = build_category_schema(&categories);
let props = schema["properties"].as_object().unwrap();
assert_eq!(props.len(), 5);
for i in 0..5 {
let key = format!("category_{}", i);
assert!(props.contains_key(&key));
assert_eq!(
props[&key]["description"].as_str().unwrap(),
format!("Category {}", i)
);
}
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 5);
}
#[test]
fn schema_with_empty_categories() {
let categories: Vec<String> = vec![];
let schema = build_category_schema(&categories);
let props = schema["properties"].as_object().unwrap();
assert_eq!(props.len(), 0);
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 0);
}
#[test]
fn schema_news_item_has_required_fields() {
let categories = vec!["Test".to_string()];
let schema = build_category_schema(&categories);
let items = &schema["properties"]["category_0"]["items"];
let item_required = items["required"].as_array().unwrap();
let item_required_strs: Vec<&str> = item_required
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(item_required_strs.contains(&"title"));
assert!(item_required_strs.contains(&"url"));
assert!(item_required_strs.contains(&"summary"));
}
#[test]
fn schema_with_special_characters_in_category_name() {
let categories = vec![
"AI & Machine Learning".to_string(),
"R&D / Innovation".to_string(),
];
let schema = build_category_schema(&categories);
let props = schema["properties"].as_object().unwrap();
assert_eq!(props["category_0"]["description"], "AI & Machine Learning");
assert_eq!(props["category_1"]["description"], "R&D / Innovation");
}
}

@ -1,6 +1,8 @@
pub mod auth;
pub mod csv;
pub mod email;
pub mod encryption;
pub mod llm;
pub mod rate_limiter;
pub mod scraper;
pub mod turnstile;

@ -0,0 +1,645 @@
//! Integration tests for user API key management endpoints (Phase 4).
//!
//! Tests:
//! - GET /api/v1/user/api-keys — list user's API keys
//! - POST /api/v1/user/api-keys — add/update an API key (encrypted)
//! - DELETE /api/v1/user/api-keys/:provider — remove an API key
//! - POST /api/v1/user/api-keys/:provider/test — test an API key
//!
//! Covers authentication, CRUD, encryption verification, ownership
//! isolation, and key testing.
//!
//! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run.
mod common;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
fn require_test_db() -> bool {
std::env::var("TEST_DATABASE_URL").is_ok()
}
// ═══════════════════════════════════════════════════════════════════════════
// Authentication
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn get_api_keys_without_auth_returns_401() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (status, body) = app.get("/api/v1/user/api-keys").await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"GET /user/api-keys without auth should return 401"
);
assert_eq!(body["error"], "unauthorized");
}
#[tokio::test]
async fn post_api_keys_without_auth_returns_401() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": "AIzaSyB-test-key-12345"
});
let (status, resp) = app.post("/api/v1/user/api-keys", &body).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"POST /user/api-keys without auth should return 401"
);
assert_eq!(resp["error"], "unauthorized");
}
#[tokio::test]
async fn delete_api_key_without_auth_returns_401() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let req = Request::builder()
.method(Method::DELETE)
.uri("/api/v1/user/api-keys/gemini")
.header("X-Requested-With", "XMLHttpRequest")
.body(Body::empty())
.unwrap();
let response = app.raw_request(req).await;
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"DELETE /user/api-keys/:provider without auth should return 401"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// CRUD — Basic operations
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn get_api_keys_returns_empty_list_initially() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-empty@example.com")
.await;
let (status, body) = app
.get_with_session("/api/v1/user/api-keys", &session)
.await;
assert_eq!(status, StatusCode::OK, "GET /user/api-keys should return 200");
let keys = body.as_array().expect("Response should be an array");
assert!(keys.is_empty(), "New user should have no API keys");
}
#[tokio::test]
async fn post_api_key_with_valid_gemini_key_succeeds() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-create@example.com")
.await;
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": "AIzaSyB-test-key-12345"
});
let (status, resp) = app
.post_with_session("/api/v1/user/api-keys", &body, &session)
.await;
assert_eq!(
status,
StatusCode::OK,
"POST /user/api-keys with valid gemini key should return 200"
);
assert_eq!(resp["provider_name"], "gemini");
assert!(resp["id"].as_str().is_some(), "Response should contain an id");
assert!(
resp["key_prefix"].as_str().is_some(),
"Response should contain a key_prefix"
);
assert!(
resp["created_at"].as_str().is_some(),
"Response should contain created_at"
);
assert!(
resp["updated_at"].as_str().is_some(),
"Response should contain updated_at"
);
}
#[tokio::test]
async fn post_then_get_shows_key_with_prefix_only() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-prefix@example.com")
.await;
let raw_key = "AIzaSyB-test-key-12345";
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": raw_key
});
let (create_status, _) = app
.post_with_session("/api/v1/user/api-keys", &body, &session)
.await;
assert_eq!(create_status, StatusCode::OK);
// List should show the key with prefix only
let (list_status, list_body) = app
.get_with_session("/api/v1/user/api-keys", &session)
.await;
assert_eq!(list_status, StatusCode::OK);
let keys = list_body.as_array().expect("Response should be an array");
assert_eq!(keys.len(), 1, "Should have exactly one API key");
assert_eq!(keys[0]["provider_name"], "gemini");
let prefix = keys[0]["key_prefix"].as_str().unwrap();
assert_eq!(prefix, "AIzaSyB-...", "Prefix should be first 8 chars + '...'");
// The full key must NOT appear anywhere in the response
let response_str = serde_json::to_string(&list_body).unwrap();
assert!(
!response_str.contains(raw_key),
"Full API key must NEVER be returned in the response"
);
}
#[tokio::test]
async fn post_api_key_with_invalid_provider_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-invalid-provider@example.com")
.await;
let body = serde_json::json!({
"provider_name": "mistral",
"api_key": "some-valid-length-key-1234"
});
let (status, resp) = app
.post_with_session("/api/v1/user/api-keys", &body, &session)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"Invalid provider should return 422"
);
assert_eq!(resp["error"], "validation_error");
}
#[tokio::test]
async fn post_api_key_with_empty_api_key_returns_422() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-empty-key@example.com")
.await;
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": ""
});
let (status, resp) = app
.post_with_session("/api/v1/user/api-keys", &body, &session)
.await;
assert_eq!(
status,
StatusCode::UNPROCESSABLE_ENTITY,
"Empty api_key should return 422"
);
assert_eq!(resp["error"], "validation_error");
}
#[tokio::test]
async fn post_api_key_upserts_existing_key() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-upsert@example.com")
.await;
// Create the initial key
let body1 = serde_json::json!({
"provider_name": "gemini",
"api_key": "AIzaSyB-first-key-12345"
});
let (status1, resp1) = app
.post_with_session("/api/v1/user/api-keys", &body1, &session)
.await;
assert_eq!(status1, StatusCode::OK);
let first_prefix = resp1["key_prefix"].as_str().unwrap().to_string();
// Upsert with a different key for the same provider
let body2 = serde_json::json!({
"provider_name": "gemini",
"api_key": "XYZnewky-second-key-67890"
});
let (status2, resp2) = app
.post_with_session("/api/v1/user/api-keys", &body2, &session)
.await;
assert_eq!(status2, StatusCode::OK);
let second_prefix = resp2["key_prefix"].as_str().unwrap().to_string();
// Prefix should have changed
assert_ne!(
first_prefix, second_prefix,
"After upsert, key_prefix should change"
);
assert_eq!(second_prefix, "XYZnewky...");
// List should still have exactly one key (upsert, not duplicate)
let (list_status, list_body) = app
.get_with_session("/api/v1/user/api-keys", &session)
.await;
assert_eq!(list_status, StatusCode::OK);
let keys = list_body.as_array().unwrap();
assert_eq!(
keys.len(),
1,
"Upsert should not create a duplicate; still one key"
);
assert_eq!(keys[0]["key_prefix"], "XYZnewky...");
}
#[tokio::test]
async fn delete_api_key_removes_key_returns_204() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-delete@example.com")
.await;
// Create a key
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": "AIzaSyB-delete-key-12345"
});
let (create_status, _) = app
.post_with_session("/api/v1/user/api-keys", &body, &session)
.await;
assert_eq!(create_status, StatusCode::OK);
// Delete it
let (del_status, _) = app
.delete_with_session("/api/v1/user/api-keys/gemini", &session)
.await;
assert_eq!(
del_status,
StatusCode::NO_CONTENT,
"DELETE /user/api-keys/:provider should return 204"
);
}
#[tokio::test]
async fn delete_then_get_shows_empty_list() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-delete-list@example.com")
.await;
// Create a key
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": "AIzaSyB-dellist-key-12345"
});
let (create_status, _) = app
.post_with_session("/api/v1/user/api-keys", &body, &session)
.await;
assert_eq!(create_status, StatusCode::OK);
// Delete it
let (del_status, _) = app
.delete_with_session("/api/v1/user/api-keys/gemini", &session)
.await;
assert_eq!(del_status, StatusCode::NO_CONTENT);
// List should be empty again
let (list_status, list_body) = app
.get_with_session("/api/v1/user/api-keys", &session)
.await;
assert_eq!(list_status, StatusCode::OK);
let keys = list_body.as_array().unwrap();
assert!(keys.is_empty(), "After deleting, API key list should be empty");
}
// ═══════════════════════════════════════════════════════════════════════════
// Encryption Verification
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn stored_encrypted_key_is_not_plaintext() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (user_id, session) = app
.create_authenticated_user("apikeys-encryption@example.com")
.await;
let raw_key = "AIzaSyB-plaintext-check-12345";
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": raw_key
});
let (create_status, _) = app
.post_with_session("/api/v1/user/api-keys", &body, &session)
.await;
assert_eq!(create_status, StatusCode::OK);
// Query the database directly to inspect the stored encrypted_key
let row: (Vec<u8>,) = sqlx::query_as(
"SELECT encrypted_key FROM user_api_keys WHERE user_id = $1 AND provider_name = 'gemini'",
)
.bind(user_id)
.fetch_one(&app.pool)
.await
.expect("Should find the stored key in the database");
let stored_bytes = row.0;
// The stored bytes must NOT equal the raw plaintext bytes
assert_ne!(
stored_bytes,
raw_key.as_bytes(),
"The encrypted_key stored in DB must NOT be the plaintext API key"
);
// Extra check: the stored ciphertext should not contain the plaintext as a substring
let stored_str = String::from_utf8_lossy(&stored_bytes);
assert!(
!stored_str.contains(raw_key),
"The encrypted_key must not contain the plaintext key as a substring"
);
}
#[tokio::test]
async fn key_prefix_matches_first_8_chars_of_original_key() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-prefix-check@example.com")
.await;
let raw_key = "sk-proj-abc123def456ghi789";
let body = serde_json::json!({
"provider_name": "openai",
"api_key": raw_key
});
let (create_status, resp) = app
.post_with_session("/api/v1/user/api-keys", &body, &session)
.await;
assert_eq!(create_status, StatusCode::OK);
let prefix = resp["key_prefix"].as_str().unwrap();
let expected_prefix = format!("{}...", &raw_key[..8]);
assert_eq!(
prefix, expected_prefix,
"key_prefix should be the first 8 chars of the original key followed by '...'"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Ownership Isolation
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn user_a_keys_not_visible_to_user_b() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_a_id, session_a) = app
.create_authenticated_user("user-a-keys@example.com")
.await;
let (_user_b_id, session_b) = app
.create_authenticated_user("user-b-keys@example.com")
.await;
// User A creates an API key
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": "AIzaSyB-user-a-key-12345"
});
let (status, _) = app
.post_with_session("/api/v1/user/api-keys", &body, &session_a)
.await;
assert_eq!(status, StatusCode::OK);
// User B lists API keys -> should be empty
let (list_status, list_body) = app
.get_with_session("/api/v1/user/api-keys", &session_b)
.await;
assert_eq!(list_status, StatusCode::OK);
let keys = list_body.as_array().unwrap();
assert!(
keys.is_empty(),
"User B should NOT see User A's API keys"
);
// User A lists API keys -> should see their key
let (_, list_body_a) = app
.get_with_session("/api/v1/user/api-keys", &session_a)
.await;
let keys_a = list_body_a.as_array().unwrap();
assert_eq!(keys_a.len(), 1, "User A should see their own API key");
}
#[tokio::test]
async fn user_b_cannot_delete_user_a_key() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_a_id, session_a) = app
.create_authenticated_user("owner-a-keys@example.com")
.await;
let (_user_b_id, session_b) = app
.create_authenticated_user("attacker-b-keys@example.com")
.await;
// User A creates a gemini API key
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": "AIzaSyB-owner-a-key-12345"
});
let (status, _) = app
.post_with_session("/api/v1/user/api-keys", &body, &session_a)
.await;
assert_eq!(status, StatusCode::OK);
// User B tries to delete User A's gemini key -> should get 404
let (del_status, del_body) = app
.delete_with_session("/api/v1/user/api-keys/gemini", &session_b)
.await;
assert_eq!(
del_status,
StatusCode::NOT_FOUND,
"Deleting another user's API key should return 404, not 403"
);
assert_eq!(del_body["error"], "not_found");
// Verify User A's key is still there
let (_, list_body) = app
.get_with_session("/api/v1/user/api-keys", &session_a)
.await;
let keys = list_body.as_array().unwrap();
assert_eq!(
keys.len(),
1,
"User A's API key should NOT have been deleted"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Test Endpoint
// ═══════════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn test_key_without_configured_key_returns_error() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-test-nokey@example.com")
.await;
// Try to test a key that hasn't been configured
let empty_body = serde_json::json!({});
let (status, resp) = app
.post_with_session("/api/v1/user/api-keys/gemini/test", &empty_body, &session)
.await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"Testing a non-existent key should return 404"
);
assert_eq!(resp["error"], "not_found");
}
#[tokio::test]
async fn test_key_with_configured_key_returns_test_result() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let (_user_id, session) = app
.create_authenticated_user("apikeys-test-withkey@example.com")
.await;
// First, store an API key (it won't be a real key, so the test will fail
// but the endpoint should NOT crash -- it should return {success, message})
let body = serde_json::json!({
"provider_name": "gemini",
"api_key": "AIzaSyB-fake-test-key-12345"
});
let (create_status, _) = app
.post_with_session("/api/v1/user/api-keys", &body, &session)
.await;
assert_eq!(create_status, StatusCode::OK);
// Now test the key -- the call will likely fail (invalid key) but the
// endpoint should return 200 with a JSON body containing success + message
let empty_body = serde_json::json!({});
let (status, resp) = app
.post_with_session("/api/v1/user/api-keys/gemini/test", &empty_body, &session)
.await;
assert_eq!(
status,
StatusCode::OK,
"Test endpoint should return 200 even if the key is invalid (failure is in the JSON body)"
);
// The response must have "success" and "message" fields
assert!(
resp.get("success").is_some(),
"Test response should contain a 'success' field"
);
assert!(
resp.get("message").is_some(),
"Test response should contain a 'message' field"
);
assert!(
resp["success"].is_boolean(),
"'success' field should be a boolean"
);
assert!(
resp["message"].is_string(),
"'message' field should be a string"
);
}

@ -0,0 +1,259 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('API Keys API', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.resetModules();
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
});
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('list', () => {
it('should call GET /api/v1/user/api-keys and parse response', async () => {
const mockKeys = [
{
id: 'key-1',
provider_name: 'gemini',
key_prefix: 'AIza...xQ',
created_at: '2026-03-01T00:00:00Z',
updated_at: '2026-03-01T00:00:00Z',
},
{
id: 'key-2',
provider_name: 'openai',
key_prefix: 'sk-ab...cd',
created_at: '2026-03-02T00:00:00Z',
updated_at: '2026-03-02T00:00:00Z',
},
];
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockKeys),
});
const { apiKeysApi } = await import('~/api/apiKeys');
const result = await apiKeysApi.list();
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/v1/user/api-keys',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'X-Requested-With': 'XMLHttpRequest',
}),
credentials: 'same-origin',
}),
);
expect(result).toEqual(mockKeys);
expect(result).toHaveLength(2);
expect(result[0].provider_name).toBe('gemini');
expect(result[0].key_prefix).toBe('AIza...xQ');
expect(result[1].provider_name).toBe('openai');
expect(result[1].key_prefix).toBe('sk-ab...cd');
});
it('should return empty array when no keys configured', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([]),
});
const { apiKeysApi } = await import('~/api/apiKeys');
const result = await apiKeysApi.list();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should never include full API key in list response', async () => {
const mockKeys = [
{
id: 'key-1',
provider_name: 'gemini',
key_prefix: 'AIza...xQ',
created_at: '2026-03-01T00:00:00Z',
updated_at: '2026-03-01T00:00:00Z',
},
];
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockKeys),
});
const { apiKeysApi } = await import('~/api/apiKeys');
const result = await apiKeysApi.list();
// Verify no key field with full API key exists in the response
for (const key of result) {
expect(key).not.toHaveProperty('api_key');
expect(key).not.toHaveProperty('encrypted_key');
expect(key).toHaveProperty('key_prefix');
}
});
});
describe('create', () => {
it('should call POST /api/v1/user/api-keys with body', async () => {
const mockResponse = {
id: 'key-3',
provider_name: 'anthropic',
key_prefix: 'sk-an...ef',
created_at: '2026-03-10T00:00:00Z',
updated_at: '2026-03-10T00:00:00Z',
};
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockResponse),
});
const { apiKeysApi } = await import('~/api/apiKeys');
const result = await apiKeysApi.create({
provider_name: 'anthropic',
api_key: 'sk-ant-full-key-here',
});
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/v1/user/api-keys',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
provider_name: 'anthropic',
api_key: 'sk-ant-full-key-here',
}),
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
}),
);
expect(result).toEqual(mockResponse);
expect(result.key_prefix).toBe('sk-an...ef');
});
});
describe('remove', () => {
it('should call DELETE /api/v1/user/api-keys/:provider', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
});
const { apiKeysApi } = await import('~/api/apiKeys');
await apiKeysApi.remove('gemini');
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/v1/user/api-keys/gemini',
expect.objectContaining({ method: 'DELETE' }),
);
});
it('should encode provider name in URL', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
});
const { apiKeysApi } = await import('~/api/apiKeys');
await apiKeysApi.remove('provider with spaces');
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/v1/user/api-keys/provider%20with%20spaces',
expect.objectContaining({ method: 'DELETE' }),
);
});
});
describe('test', () => {
it('should call POST /api/v1/user/api-keys/:provider/test', async () => {
const mockResponse = {
success: true,
message: 'Connection successful',
};
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockResponse),
});
const { apiKeysApi } = await import('~/api/apiKeys');
const result = await apiKeysApi.test('gemini');
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/v1/user/api-keys/gemini/test',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(result.success).toBe(true);
expect(result.message).toBe('Connection successful');
});
it('should return failure result for invalid key', async () => {
const mockResponse = {
success: false,
message: 'Invalid API key',
};
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockResponse),
});
const { apiKeysApi } = await import('~/api/apiKeys');
const result = await apiKeysApi.test('openai');
expect(result.success).toBe(false);
expect(result.message).toBe('Invalid API key');
});
});
});
describe('Key prefix display formatting', () => {
it('should display prefix as-is from the API response', () => {
// The backend is responsible for generating the prefix (e.g., "sk-ab...cd")
// The frontend displays it verbatim; this test documents that contract.
const keyPrefix = 'sk-ab...cd';
expect(keyPrefix).toMatch(/^.{2,6}\.\.\..{2,4}$/);
});
it('should handle various prefix formats', () => {
const prefixes = [
'AIza...xQ', // Gemini-style
'sk-ab...cd', // OpenAI-style
'sk-an...ef', // Anthropic-style
];
for (const prefix of prefixes) {
// All prefixes should contain the ellipsis marker
expect(prefix).toContain('...');
// No prefix should be longer than 12 characters (prefix + ... + suffix)
expect(prefix.length).toBeLessThanOrEqual(12);
}
});
it('should never contain a full key value', () => {
// A full API key is typically 30+ characters
const prefix = 'sk-ab...cd';
expect(prefix.length).toBeLessThan(20);
expect(prefix).toContain('...');
});
});

@ -0,0 +1,16 @@
import { api } from './client';
import type { UserApiKey, CreateApiKeyRequest, TestApiKeyResponse } from '~/types';
export const apiKeysApi = {
list: (): Promise<UserApiKey[]> =>
api.get<UserApiKey[]>('/user/api-keys'),
create: (data: CreateApiKeyRequest): Promise<UserApiKey> =>
api.post<UserApiKey>('/user/api-keys', data),
remove: (provider: string): Promise<void> =>
api.delete<void>(`/user/api-keys/${encodeURIComponent(provider)}`),
test: (provider: string): Promise<TestApiKeyResponse> =>
api.post<TestApiKeyResponse>(`/user/api-keys/${encodeURIComponent(provider)}/test`),
};

@ -0,0 +1,304 @@
import {
type Component,
createSignal,
createResource,
Show,
For,
} from 'solid-js';
import { Key, Eye, EyeOff, CheckCircle, XCircle, Trash2, RefreshCw } from 'lucide-solid';
import { apiKeysApi } from '~/api/apiKeys';
import { useI18n } from '~/i18n';
import { useToast } from '~/components/ui/Toast';
import { isApiError } from '~/types';
import type { ProviderConfig, UserApiKey } from '~/types';
interface ApiKeyManagerProps {
providers: ProviderConfig[];
}
const ApiKeyManager: Component<ApiKeyManagerProps> = (props) => {
const { t } = useI18n();
const { addToast } = useToast();
const [apiKeys, { refetch }] = createResource(() => apiKeysApi.list());
const getKeyForProvider = (providerName: string): UserApiKey | undefined => {
return apiKeys()?.find((k) => k.provider_name === providerName);
};
return (
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200 mt-8">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
<div class="flex items-center">
<Key class="h-5 w-5 text-indigo-600 mr-2" />
<h2 class="text-lg font-medium text-gray-900">
{t('settings.apiKeys.title')}
</h2>
</div>
<p class="mt-1 text-sm text-gray-500">
{t('settings.apiKeys.description')}
</p>
</div>
<div class="divide-y divide-gray-200">
<For each={props.providers}>
{(provider) => (
<ProviderKeyCard
provider={provider}
apiKey={getKeyForProvider(provider.provider_name)}
onKeyChanged={refetch}
/>
)}
</For>
</div>
</div>
);
};
interface ProviderKeyCardProps {
provider: ProviderConfig;
apiKey: UserApiKey | undefined;
onKeyChanged: () => void;
}
const ProviderKeyCard: Component<ProviderKeyCardProps> = (props) => {
const { t } = useI18n();
const { addToast } = useToast();
const [keyInput, setKeyInput] = createSignal('');
const [showKey, setShowKey] = createSignal(false);
const [editing, setEditing] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [testing, setTesting] = createSignal(false);
const [confirmDelete, setConfirmDelete] = createSignal(false);
const isConfigured = () => !!props.apiKey;
const showInput = () => !isConfigured() || editing();
const handleSave = async () => {
const key = keyInput().trim();
if (!key) return;
setSaving(true);
try {
await apiKeysApi.create({
provider_name: props.provider.provider_name,
api_key: key,
});
addToast({
type: 'success',
message: t('settings.apiKeys.saved'),
duration: 4000,
});
setKeyInput('');
setShowKey(false);
setEditing(false);
props.onKeyChanged();
} catch (err) {
const message = isApiError(err) ? err.message : t('settings.apiKeys.saveError');
addToast({ type: 'error', message, duration: 5000 });
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
try {
const result = await apiKeysApi.test(props.provider.provider_name);
if (result.success) {
addToast({
type: 'success',
message: t('settings.apiKeys.testSuccess'),
duration: 4000,
});
} else {
addToast({
type: 'error',
message: t('settings.apiKeys.testFailure', { message: result.message }),
duration: 6000,
});
}
} catch (err) {
const message = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: 'Erreur inconnue' });
addToast({ type: 'error', message, duration: 5000 });
} finally {
setTesting(false);
}
};
const handleDelete = async () => {
try {
await apiKeysApi.remove(props.provider.provider_name);
addToast({
type: 'success',
message: t('settings.apiKeys.deleted'),
duration: 4000,
});
setConfirmDelete(false);
setEditing(false);
props.onKeyChanged();
} catch (err) {
const message = isApiError(err) ? err.message : t('settings.apiKeys.deleteError');
addToast({ type: 'error', message, duration: 5000 });
}
};
const handleCancel = () => {
setEditing(false);
setKeyInput('');
setShowKey(false);
setConfirmDelete(false);
};
return (
<div class="px-4 py-4 sm:px-6">
{/* Header: Provider name + status badge */}
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-900">
{props.provider.display_name}
</span>
<Show
when={isConfigured()}
fallback={
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{t('settings.apiKeys.notConfigured')}
</span>
}
>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{t('settings.apiKeys.configured')}
</span>
</Show>
</div>
{/* Action buttons for configured keys */}
<Show when={isConfigured() && !editing()}>
<div class="flex items-center gap-2">
<button
type="button"
onClick={handleTest}
disabled={testing()}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-indigo-700 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
>
<Show
when={!testing()}
fallback={
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-indigo-700 mr-1.5" />
}
>
<RefreshCw class="h-3 w-3 mr-1.5" />
</Show>
{testing() ? t('settings.apiKeys.testing') : t('settings.apiKeys.test')}
</button>
<button
type="button"
onClick={() => setEditing(true)}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
>
{t('settings.apiKeys.update')}
</button>
<Show
when={!confirmDelete()}
fallback={
<div class="flex items-center gap-1">
<button
type="button"
onClick={handleDelete}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500"
>
{t('common.confirm')}
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
>
{t('common.cancel')}
</button>
</div>
}
>
<button
type="button"
onClick={() => setConfirmDelete(true)}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500"
>
<Trash2 class="h-3 w-3 mr-1.5" />
{t('settings.apiKeys.delete')}
</button>
</Show>
</div>
</Show>
</div>
{/* Key prefix display */}
<Show when={isConfigured() && !editing()}>
<p class="text-sm text-gray-500">
<span class="font-mono text-gray-400">
{t('settings.apiKeys.keyPrefix', { prefix: props.apiKey!.key_prefix })}
</span>
</p>
</Show>
{/* Input form: shown when not configured or editing */}
<Show when={showInput()}>
<div class="mt-2">
<label
for={`api-key-${props.provider.provider_name}`}
class="block text-sm font-medium text-gray-700 mb-1"
>
{t('settings.apiKeys.inputLabel')}
</label>
<div class="flex items-center gap-2">
<div class="relative flex-1">
<input
type={showKey() ? 'text' : 'password'}
id={`api-key-${props.provider.provider_name}`}
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 pr-10 border font-mono"
value={keyInput()}
onInput={(e) => setKeyInput(e.currentTarget.value)}
placeholder={t('settings.apiKeys.inputPlaceholder', {
provider: props.provider.display_name,
})}
/>
<button
type="button"
onClick={() => setShowKey(!showKey())}
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
aria-label={showKey() ? t('settings.apiKeys.hideKey') : t('settings.apiKeys.showKey')}
>
<Show when={showKey()} fallback={<Eye class="h-4 w-4" />}>
<EyeOff class="h-4 w-4" />
</Show>
</button>
</div>
<button
type="button"
onClick={handleSave}
disabled={saving() || !keyInput().trim()}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
<Show when={saving()}>
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
</Show>
{saving() ? t('settings.apiKeys.saving') : t('settings.apiKeys.save')}
</button>
<Show when={editing()}>
<button
type="button"
onClick={handleCancel}
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{t('common.cancel')}
</button>
</Show>
</div>
</div>
</Show>
</div>
);
};
export default ApiKeyManager;

@ -117,6 +117,35 @@ const fr = {
'sources.urlRequired': "L'URL est requise.",
'sources.urlInvalid': "L'URL n'est pas valide.",
// Settings - API Keys
'settings.apiKeys.title': 'Vos cles API',
'settings.apiKeys.description':
'Configurez vos cles API pour chaque fournisseur. Votre cle est chiffree et stockee de maniere securisee.',
'settings.apiKeys.configured': 'Configuree',
'settings.apiKeys.notConfigured': 'Non configuree',
'settings.apiKeys.keyPrefix': 'Cle : {prefix}',
'settings.apiKeys.inputLabel': 'Cle API',
'settings.apiKeys.inputPlaceholder': 'Entrez votre cle API pour {provider}',
'settings.apiKeys.showKey': 'Afficher la cle',
'settings.apiKeys.hideKey': 'Masquer la cle',
'settings.apiKeys.save': 'Enregistrer',
'settings.apiKeys.saving': 'Enregistrement...',
'settings.apiKeys.saved': 'Cle API enregistree avec succes.',
'settings.apiKeys.saveError': "Erreur lors de l'enregistrement de la cle API.",
'settings.apiKeys.update': 'Modifier',
'settings.apiKeys.test': 'Tester',
'settings.apiKeys.testing': 'Test en cours...',
'settings.apiKeys.testSuccess': 'Cle API valide. Connexion reussie.',
'settings.apiKeys.testFailure': 'Cle API invalide : {message}',
'settings.apiKeys.delete': 'Supprimer',
'settings.apiKeys.deleteConfirm':
'Etes-vous sur de vouloir supprimer cette cle API ? Vous ne pourrez plus generer de syntheses avec ce fournisseur.',
'settings.apiKeys.deleted': 'Cle API supprimee avec succes.',
'settings.apiKeys.deleteError': 'Erreur lors de la suppression de la cle API.',
'settings.apiKeys.loadError': 'Erreur lors du chargement des cles API.',
'settings.apiKeys.noWebSearch':
'Ce fournisseur ne supporte pas la recherche web native. Le scraping backend sera utilise.',
// Settings - Provider
'settings.provider': "Fournisseur d'IA",
'settings.providerHelp':

@ -14,6 +14,7 @@ import { useI18n } from '~/i18n';
import { DEFAULT_SETTINGS, isApiError } from '~/types';
import type { UserSettings, ProviderConfig } from '~/types';
import LoadingSpinner from '~/components/ui/LoadingSpinner';
import ApiKeyManager from '~/components/ApiKeyManager';
const Settings: Component = () => {
const { t } = useI18n();
@ -447,6 +448,11 @@ const Settings: Component = () => {
</button>
</div>
</div>
{/* API Key Management */}
<Show when={providers() && providers()!.length > 0}>
<ApiKeyManager providers={providers()!} />
</Show>
</div>
</Show>
);

@ -168,6 +168,26 @@ export interface UpdateUserRoleRequest {
role: 'user' | 'admin';
}
// ---- User API Keys ----
export interface UserApiKey {
id: string;
provider_name: string;
key_prefix: string;
created_at: string;
updated_at: string;
}
export interface CreateApiKeyRequest {
provider_name: string;
api_key: string;
}
export interface TestApiKeyResponse {
success: boolean;
message: string;
}
// ---- Public Config ----
export interface ProviderConfigModel {

Loading…
Cancel
Save