Backend: AI generation endpoints (Gemini nano-banana + Veo)

Server-side proxy so the Google API key (from Infisical) never reaches the
client. Behind an AiClient trait with a fake for headless tests.

- ai.rs: GeminiClient — refine prompt (gemini-2.5-flash), generate image
  (gemini-2.5-flash-image / "nano-banana"), and async video (veo-2.0:
  predictLongRunning → poll → proxy the redirecting download). DisabledAiClient
  when no key is configured.
- POST /v1/ai/refine, /v1/ai/image (rate-limited 60/h, 20/h)
- POST /v1/ai/video (submit, 5/h) + /v1/ai/video/status (poll → base64 mp4)
- config: GEMINI_API_KEY via SecretSource
- 5 endpoint/parse tests; full workspace gate green (114 tests)

Live-verified: refine + nano-banana return through the proxy; Veo submits,
polls to done in ~60s, and streams back a valid mp4.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 12:56:19 -05:00
co-authored by Claude Opus 4.8
parent eddca62f23
commit 4be1cf5fa3
10 changed files with 597 additions and 1 deletions
@@ -0,0 +1,302 @@
//! AI image generation via Google Gemini (PRD §21 Phase 5 "Card AI Generator").
//!
//! Two steps: refine the user's rough fields into a single vivid prompt
//! (gemini-2.5-flash), then generate the image (gemini-2.5-flash-image, aka
//! "nano-banana"). The API key lives only here on the server — loaded from
//! Infisical via config — and never reaches the client.
//!
//! Behind an [`AiClient`] trait so handlers/tests don't depend on the network.
use async_trait::async_trait;
use serde::Serialize;
const GEMINI_BASE: &str = "https://generativelanguage.googleapis.com/v1beta";
const TEXT_MODEL: &str = "gemini-2.5-flash";
const IMAGE_MODEL: &str = "gemini-2.5-flash-image"; // "nano-banana"
const VIDEO_MODEL: &str = "veo-2.0-generate-001"; // Google Veo (async)
#[derive(Debug, thiserror::Error)]
pub enum AiError {
#[error("AI is not configured")]
NotConfigured,
#[error("gemini request failed: {0}")]
Request(String),
#[error("gemini returned no {0}")]
Empty(&'static str),
}
/// Rough fields collected by the "Describe your scene" wizard.
#[derive(Debug, Clone, Serialize)]
pub struct SceneBrief {
pub scene: String,
pub style: Option<String>,
pub mood: Option<String>,
}
/// A generated image as base64 plus its MIME type.
pub struct GeneratedImage {
pub mime_type: String,
pub base64: String,
}
/// State of an async Veo video job.
pub enum VideoStatus {
Pending,
Done(Vec<u8>),
Failed(String),
}
#[async_trait]
pub trait AiClient: Send + Sync {
/// Refine the brief into a single image-generation prompt.
async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError>;
/// Generate an image from a prompt (nano-banana).
async fn generate_image(&self, prompt: &str) -> Result<GeneratedImage, AiError>;
/// Submit a Veo video job; returns the long-running operation name.
async fn start_video(&self, prompt: &str) -> Result<String, AiError>;
/// Poll a Veo operation; downloads + returns the MP4 bytes when done.
async fn poll_video(&self, operation: &str) -> Result<VideoStatus, AiError>;
}
// ---- Gemini (production) --------------------------------------------------
pub struct GeminiClient {
api_key: String,
http: reqwest::Client,
}
impl GeminiClient {
/// Returns `None` when no key is configured (AI stays disabled).
pub fn new(api_key: String) -> Option<Self> {
if api_key.trim().is_empty() {
return None;
}
Some(Self {
api_key,
http: reqwest::Client::new(),
})
}
async fn generate_content(
&self,
model: &str,
text: &str,
) -> Result<serde_json::Value, AiError> {
let url = format!("{GEMINI_BASE}/models/{model}:generateContent");
let body = serde_json::json!({
"contents": [{ "parts": [{ "text": text }] }]
});
let resp = self
.http
.post(&url)
.query(&[("key", &self.api_key)])
.json(&body)
.send()
.await
.map_err(|e| AiError::Request(e.to_string()))?;
if !resp.status().is_success() {
return Err(AiError::Request(format!("status {}", resp.status())));
}
resp.json::<serde_json::Value>()
.await
.map_err(|e| AiError::Request(e.to_string()))
}
}
#[async_trait]
impl AiClient for GeminiClient {
async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError> {
let instruction = format!(
"You are helping design a digital business card image. Turn the \
following into ONE vivid, concrete image-generation prompt (2-3 \
sentences, no preamble, no quotes).\nScene: {}\nStyle: {}\nMood: {}",
brief.scene,
brief.style.as_deref().unwrap_or("(any)"),
brief.mood.as_deref().unwrap_or("(any)"),
);
let json = self.generate_content(TEXT_MODEL, &instruction).await?;
first_text(&json).ok_or(AiError::Empty("text"))
}
async fn generate_image(&self, prompt: &str) -> Result<GeneratedImage, AiError> {
let json = self.generate_content(IMAGE_MODEL, prompt).await?;
first_inline_image(&json).ok_or(AiError::Empty("image"))
}
async fn start_video(&self, prompt: &str) -> Result<String, AiError> {
let url = format!("{GEMINI_BASE}/models/{VIDEO_MODEL}:predictLongRunning");
let body = serde_json::json!({
"instances": [{ "prompt": prompt }],
"parameters": { "aspectRatio": "9:16", "durationSeconds": 5 }
});
let resp = self
.http
.post(&url)
.query(&[("key", &self.api_key)])
.json(&body)
.send()
.await
.map_err(|e| AiError::Request(e.to_string()))?;
if !resp.status().is_success() {
return Err(AiError::Request(format!("status {}", resp.status())));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| AiError::Request(e.to_string()))?;
json["name"]
.as_str()
.map(str::to_string)
.ok_or(AiError::Empty("operation"))
}
async fn poll_video(&self, operation: &str) -> Result<VideoStatus, AiError> {
let url = format!("{GEMINI_BASE}/{operation}");
let json: serde_json::Value = self
.http
.get(&url)
.query(&[("key", &self.api_key)])
.send()
.await
.map_err(|e| AiError::Request(e.to_string()))?
.json()
.await
.map_err(|e| AiError::Request(e.to_string()))?;
if let Some(err) = json.get("error") {
let msg = err["message"].as_str().unwrap_or("video generation failed");
return Ok(VideoStatus::Failed(msg.to_string()));
}
if !json["done"].as_bool().unwrap_or(false) {
return Ok(VideoStatus::Pending);
}
// Done: the sample carries a download URI (302-redirects to a signed
// URL; reqwest follows it). The key authorizes the first hop.
let uri = json
.pointer("/response/generateVideoResponse/generatedSamples/0/video/uri")
.and_then(|v| v.as_str())
.ok_or(AiError::Empty("video"))?;
let bytes = self
.http
.get(uri)
.query(&[("key", &self.api_key)])
.send()
.await
.map_err(|e| AiError::Request(e.to_string()))?
.error_for_status()
.map_err(|e| AiError::Request(e.to_string()))?
.bytes()
.await
.map_err(|e| AiError::Request(e.to_string()))?;
Ok(VideoStatus::Done(bytes.to_vec()))
}
}
/// Extract the first text part from a generateContent response.
fn first_text(json: &serde_json::Value) -> Option<String> {
json["candidates"][0]["content"]["parts"]
.as_array()?
.iter()
.find_map(|p| p["text"].as_str())
.map(|s| s.trim().to_string())
}
/// Extract the first inline image part (base64 + mime) from a response.
fn first_inline_image(json: &serde_json::Value) -> Option<GeneratedImage> {
json["candidates"][0]["content"]["parts"]
.as_array()?
.iter()
.find_map(|p| {
let data = p["inlineData"]["data"].as_str()?;
let mime = p["inlineData"]["mimeType"].as_str().unwrap_or("image/png");
Some(GeneratedImage {
mime_type: mime.to_string(),
base64: data.to_string(),
})
})
}
/// Used when no API key is configured — every call errors `NotConfigured`.
pub struct DisabledAiClient;
#[async_trait]
impl AiClient for DisabledAiClient {
async fn refine_prompt(&self, _brief: &SceneBrief) -> Result<String, AiError> {
Err(AiError::NotConfigured)
}
async fn generate_image(&self, _prompt: &str) -> Result<GeneratedImage, AiError> {
Err(AiError::NotConfigured)
}
async fn start_video(&self, _prompt: &str) -> Result<String, AiError> {
Err(AiError::NotConfigured)
}
async fn poll_video(&self, _operation: &str) -> Result<VideoStatus, AiError> {
Err(AiError::NotConfigured)
}
}
// ---- Fake (tests) ---------------------------------------------------------
/// Deterministic fake: echoes a refined prompt and returns a 1x1 PNG.
pub struct FakeAiClient;
const PIXEL_PNG_B64: &str =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
#[async_trait]
impl AiClient for FakeAiClient {
async fn refine_prompt(&self, brief: &SceneBrief) -> Result<String, AiError> {
Ok(format!(
"A {} {} scene: {}",
brief.mood.as_deref().unwrap_or("striking"),
brief.style.as_deref().unwrap_or("cinematic"),
brief.scene,
))
}
async fn generate_image(&self, _prompt: &str) -> Result<GeneratedImage, AiError> {
Ok(GeneratedImage {
mime_type: "image/png".to_string(),
base64: PIXEL_PNG_B64.to_string(),
})
}
async fn start_video(&self, _prompt: &str) -> Result<String, AiError> {
Ok("models/veo-2.0-generate-001/operations/fake".to_string())
}
async fn poll_video(&self, _operation: &str) -> Result<VideoStatus, AiError> {
// Minimal valid MP4 ftyp header — enough for the endpoint contract.
Ok(VideoStatus::Done(vec![
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_text_and_image_from_gemini_shape() {
let text_resp = serde_json::json!({
"candidates": [{ "content": { "parts": [{ "text": " a vivid prompt " }] } }]
});
assert_eq!(first_text(&text_resp).as_deref(), Some("a vivid prompt"));
let img_resp = serde_json::json!({
"candidates": [{ "content": { "parts": [
{ "text": "here is your image" },
{ "inlineData": { "mimeType": "image/png", "data": "QUJD" } }
] } }]
});
let img = first_inline_image(&img_resp).unwrap();
assert_eq!(img.mime_type, "image/png");
assert_eq!(img.base64, "QUJD");
}
#[test]
fn disabled_without_key() {
assert!(GeminiClient::new("".into()).is_none());
assert!(GeminiClient::new(" ".into()).is_none());
assert!(GeminiClient::new("real-key".into()).is_some());
}
}
@@ -0,0 +1,179 @@
//! AI card-image generation handlers (PRD §21 Phase 5). Two steps the mobile
//! wizard calls: refine the brief, then generate the image (nano-banana). Heavily
//! rate-limited because each call costs money; production also gates these behind
//! auth + the Pro tier.
use axum::extract::State;
use axum::http::HeaderMap;
use axum::Json;
use base64::Engine;
use cardclaws_types::AppError;
use serde::{Deserialize, Serialize};
use crate::ai::{AiError, SceneBrief, VideoStatus};
use crate::error::ApiResult;
use crate::handlers::analytics::client_ip;
use crate::middleware::rate_limit;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct RefineRequest {
pub scene: String,
#[serde(default)]
pub style: Option<String>,
#[serde(default)]
pub mood: Option<String>,
}
#[derive(Serialize)]
pub struct RefineResponse {
pub prompt: String,
}
#[derive(Deserialize)]
pub struct ImageRequest {
pub prompt: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageResponse {
pub mime_type: String,
/// Base64-encoded image; the client renders it as a `data:` URI.
pub image_base64: String,
}
fn map_ai_err(e: AiError) -> AppError {
match e {
AiError::NotConfigured => AppError::Internal("AI is not configured".into()),
other => AppError::Internal(other.to_string()),
}
}
pub async fn refine(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<RefineRequest>,
) -> ApiResult<Json<RefineResponse>> {
rate_limit::check(state.cache.as_ref(), &rl_key("refine", &headers), 60, 3600).await?;
if req.scene.trim().is_empty() {
return Err(AppError::Validation("scene is required".into()).into());
}
let brief = SceneBrief {
scene: req.scene,
style: req.style,
mood: req.mood,
};
let prompt = state.ai.refine_prompt(&brief).await.map_err(map_ai_err)?;
Ok(Json(RefineResponse { prompt }))
}
pub async fn image(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<ImageRequest>,
) -> ApiResult<Json<ImageResponse>> {
// Image generation is the expensive call — tighter limit.
rate_limit::check(state.cache.as_ref(), &rl_key("image", &headers), 20, 3600).await?;
if req.prompt.trim().is_empty() {
return Err(AppError::Validation("prompt is required".into()).into());
}
let img = state
.ai
.generate_image(&req.prompt)
.await
.map_err(map_ai_err)?;
Ok(Json(ImageResponse {
mime_type: img.mime_type,
image_base64: img.base64,
}))
}
// ---- Video (Veo, async) ---------------------------------------------------
#[derive(Deserialize)]
pub struct VideoRequest {
pub prompt: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StartVideoResponse {
pub operation_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoStatusRequest {
pub operation_id: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoStatusResponse {
/// "pending" | "done" | "failed".
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video_base64: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Submit a Veo job. Very tightly rate-limited — each clip is costly.
pub async fn start_video(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<VideoRequest>,
) -> ApiResult<Json<StartVideoResponse>> {
rate_limit::check(state.cache.as_ref(), &rl_key("video", &headers), 5, 3600).await?;
if req.prompt.trim().is_empty() {
return Err(AppError::Validation("prompt is required".into()).into());
}
let operation_id = state
.ai
.start_video(&req.prompt)
.await
.map_err(map_ai_err)?;
Ok(Json(StartVideoResponse { operation_id }))
}
/// Poll a Veo operation. Not rate-limited (the client polls repeatedly); the
/// backend proxies the download so the API key never reaches the client.
pub async fn video_status(
State(state): State<AppState>,
Json(req): Json<VideoStatusRequest>,
) -> ApiResult<Json<VideoStatusResponse>> {
let resp = match state
.ai
.poll_video(&req.operation_id)
.await
.map_err(map_ai_err)?
{
VideoStatus::Pending => VideoStatusResponse {
status: "pending".into(),
mime_type: None,
video_base64: None,
error: None,
},
VideoStatus::Failed(e) => VideoStatusResponse {
status: "failed".into(),
mime_type: None,
video_base64: None,
error: Some(e),
},
VideoStatus::Done(bytes) => VideoStatusResponse {
status: "done".into(),
mime_type: Some("video/mp4".into()),
video_base64: Some(base64::engine::general_purpose::STANDARD.encode(bytes)),
error: None,
},
};
Ok(Json(resp))
}
fn rl_key(op: &str, headers: &HeaderMap) -> String {
let ip = client_ip(headers).unwrap_or_else(|| "unknown".into());
format!("ai:{op}:{ip}")
}
@@ -1,4 +1,5 @@
pub mod account; pub mod account;
pub mod ai;
pub mod analytics; pub mod analytics;
pub mod assets; pub mod assets;
pub mod auth; pub mod auth;
@@ -4,6 +4,7 @@
//! test doubles (in-memory cache, capturing email, fake Apple keys). The binary //! test doubles (in-memory cache, capturing email, fake Apple keys). The binary
//! (`main.rs`) wires the production implementations. //! (`main.rs`) wires the production implementations.
pub mod ai;
pub mod assets; pub mod assets;
pub mod cache; pub mod cache;
pub mod email; pub mod email;
@@ -3,6 +3,7 @@
use std::sync::Arc; use std::sync::Arc;
use cardclaws_api::ai::{AiClient, DisabledAiClient, GeminiClient};
use cardclaws_api::assets::R2Store; use cardclaws_api::assets::R2Store;
use cardclaws_api::cache::RedisCache; use cardclaws_api::cache::RedisCache;
use cardclaws_api::email::ResendEmailSender; use cardclaws_api::email::ResendEmailSender;
@@ -50,6 +51,13 @@ async fn main() -> Result<(), BoxError> {
let pass_signer = build_pass_signer(&secrets).await?; let pass_signer = build_pass_signer(&secrets).await?;
let google_signer = build_google_signer(&secrets).await; let google_signer = build_google_signer(&secrets).await;
let geo = build_geo_resolver(&secrets).await; let geo = build_geo_resolver(&secrets).await;
let ai: Arc<dyn AiClient> = match GeminiClient::new(config.gemini_api_key.clone()) {
Some(c) => {
tracing::info!("AI card generator enabled (Gemini)");
Arc::new(c)
}
None => Arc::new(DisabledAiClient),
};
// Brand glyphs bundled into every pass. Solid-fill placeholders for now; // Brand glyphs bundled into every pass. Solid-fill placeholders for now;
// replaced by real CardClaws artwork when design assets land. // replaced by real CardClaws artwork when design assets land.
@@ -64,6 +72,7 @@ async fn main() -> Result<(), BoxError> {
email: Arc::new(ResendEmailSender::new(resend_key, email_from)), email: Arc::new(ResendEmailSender::new(resend_key, email_from)),
assets: Arc::new(assets), assets: Arc::new(assets),
geo, geo,
ai,
jwt: JwtKeys::new(&config.jwt_secret), jwt: JwtKeys::new(&config.jwt_secret),
apple: Arc::new(HttpJwkProvider::new()), apple: Arc::new(HttpJwkProvider::new()),
apple_audience, apple_audience,
@@ -5,7 +5,7 @@ use axum::routing::{get, post};
use axum::Router; use axum::Router;
use crate::handlers::{ use crate::handlers::{
account, analytics, assets, auth, cards, health, profile, share, teams, wallet, webhooks, account, ai, analytics, assets, auth, cards, health, profile, share, teams, wallet, webhooks,
}; };
use crate::middleware::cors; use crate::middleware::cors;
use crate::state::AppState; use crate::state::AppState;
@@ -65,6 +65,10 @@ pub fn build_router(state: AppState) -> Router {
axum::routing::delete(teams::delete_template), axum::routing::delete(teams::delete_template),
) )
.route("/analytics/event", post(analytics::ingest_event)) .route("/analytics/event", post(analytics::ingest_event))
.route("/ai/refine", post(ai::refine))
.route("/ai/image", post(ai::image))
.route("/ai/video", post(ai::start_video))
.route("/ai/video/status", post(ai::video_status))
.route("/account/export", get(account::export_data)) .route("/account/export", get(account::export_data))
.route("/account", axum::routing::delete(account::delete_account)) .route("/account", axum::routing::delete(account::delete_account))
.route("/assets/upload", post(assets::presign_upload)) .route("/assets/upload", post(assets::presign_upload))
@@ -10,6 +10,7 @@ use cardclaws_wallet::apple::signer::PassSigner;
use cardclaws_wallet::apple::BrandAssets; use cardclaws_wallet::apple::BrandAssets;
use cardclaws_wallet::google::jwt_signer::GoogleWalletSigner; use cardclaws_wallet::google::jwt_signer::GoogleWalletSigner;
use crate::ai::AiClient;
use crate::assets::ObjectStore; use crate::assets::ObjectStore;
use crate::cache::Cache; use crate::cache::Cache;
use crate::email::EmailSender; use crate::email::EmailSender;
@@ -24,6 +25,7 @@ pub struct AppState {
pub email: Arc<dyn EmailSender>, pub email: Arc<dyn EmailSender>,
pub assets: Arc<dyn ObjectStore>, pub assets: Arc<dyn ObjectStore>,
pub geo: Arc<dyn GeoResolver>, pub geo: Arc<dyn GeoResolver>,
pub ai: Arc<dyn AiClient>,
pub jwt: JwtKeys, pub jwt: JwtKeys,
pub apple: Arc<dyn JwkProvider>, pub apple: Arc<dyn JwkProvider>,
/// Apple Services ID / bundle id the identity token must be addressed to. /// Apple Services ID / bundle id the identity token must be addressed to.
@@ -0,0 +1,93 @@
//! AI generator endpoints (PRD §21 Phase 5). Uses the fake AI client; a live
//! Gemini/nano-banana run is exercised manually with a real key.
mod common;
use axum::http::StatusCode;
use serde_json::json;
#[tokio::test]
async fn refine_returns_a_prompt() {
let app = require_app!();
let (status, body) = app
.request(
"POST",
"/v1/ai/refine",
None,
Some(json!({ "scene": "neon Tokyo street", "style": "Cinematic", "mood": "Bold" })),
)
.await;
assert_eq!(status, StatusCode::OK);
let prompt = body["prompt"].as_str().unwrap();
assert!(prompt.contains("neon Tokyo street"));
}
#[tokio::test]
async fn refine_requires_a_scene() {
let app = require_app!();
let (status, body) = app
.request(
"POST",
"/v1/ai/refine",
None,
Some(json!({ "scene": " " })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "validation");
}
#[tokio::test]
async fn image_returns_base64_png() {
let app = require_app!();
let (status, body) = app
.request(
"POST",
"/v1/ai/image",
None,
Some(json!({ "prompt": "a cinematic neon street" })),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["mimeType"], "image/png");
assert!(!body["imageBase64"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn video_submit_then_poll_returns_clip() {
let app = require_app!();
let (status, body) = app
.request(
"POST",
"/v1/ai/video",
None,
Some(json!({ "prompt": "a calm forest at dawn" })),
)
.await;
assert_eq!(status, StatusCode::OK);
let op = body["operationId"].as_str().unwrap().to_string();
assert!(!op.is_empty());
let (status, body) = app
.request(
"POST",
"/v1/ai/video/status",
None,
Some(json!({ "operationId": op })),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "done");
assert_eq!(body["mimeType"], "video/mp4");
assert!(!body["videoBase64"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn video_requires_a_prompt() {
let app = require_app!();
let (status, body) = app
.request("POST", "/v1/ai/video", None, Some(json!({ "prompt": "" })))
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "validation");
}
@@ -18,6 +18,7 @@ use http_body_util::BodyExt;
use serde_json::Value; use serde_json::Value;
use tower::ServiceExt; use tower::ServiceExt;
use cardclaws_api::ai::FakeAiClient;
use cardclaws_api::assets::InMemoryStore; use cardclaws_api::assets::InMemoryStore;
use cardclaws_api::cache::InMemoryCache; use cardclaws_api::cache::InMemoryCache;
use cardclaws_api::email::CapturingEmailSender; use cardclaws_api::email::CapturingEmailSender;
@@ -84,6 +85,7 @@ pub async fn try_setup() -> Option<TestApp> {
email: email.clone(), email: email.clone(),
assets: assets.clone(), assets: assets.clone(),
geo: Arc::new(FakeGeo), geo: Arc::new(FakeGeo),
ai: Arc::new(FakeAiClient),
jwt: JwtKeys::new("test-jwt-secret"), jwt: JwtKeys::new("test-jwt-secret"),
apple: Arc::new(NoopApple), apple: Arc::new(NoopApple),
apple_audience: "com.cardclaws.test".into(), apple_audience: "com.cardclaws.test".into(),
@@ -54,6 +54,8 @@ pub struct Config {
pub ip_hash_secret: String, pub ip_hash_secret: String,
/// Shared secret RevenueCat sends in the webhook `Authorization` header. /// Shared secret RevenueCat sends in the webhook `Authorization` header.
pub billing_webhook_secret: String, pub billing_webhook_secret: String,
/// Google Gemini API key (for the AI card generator). Empty = AI disabled.
pub gemini_api_key: String,
pub bind_addr: String, pub bind_addr: String,
pub r2: R2Config, pub r2: R2Config,
pub wallet: WalletConfig, pub wallet: WalletConfig,
@@ -102,6 +104,7 @@ impl Config {
profile_base_url: optional(src, "PROFILE_BASE_URL", "https://cardclaws.com").await, profile_base_url: optional(src, "PROFILE_BASE_URL", "https://cardclaws.com").await,
ip_hash_secret: require(src, "IP_HASH_SECRET").await?, ip_hash_secret: require(src, "IP_HASH_SECRET").await?,
billing_webhook_secret: optional(src, "BILLING_WEBHOOK_SECRET", "").await, billing_webhook_secret: optional(src, "BILLING_WEBHOOK_SECRET", "").await,
gemini_api_key: optional(src, "GEMINI_API_KEY", "").await,
bind_addr: optional(src, "BIND_ADDR", "0.0.0.0:8080").await, bind_addr: optional(src, "BIND_ADDR", "0.0.0.0:8080").await,
r2: R2Config { r2: R2Config {
endpoint: optional( endpoint: optional(