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
parent
5abbf9b9ad
commit
439e547367
@ -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)
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue