From 7cfb15eea9742ff9ad7a42c59ad19aaa06b0a274 Mon Sep 17 00:00:00 2001 From: Nigel Tatschner Date: Sun, 31 May 2026 12:46:02 +0100 Subject: [PATCH] feat(config): _FILE support for Revolut API key + webhook secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unblocks the donate routes on the homelab compose stack, which mounts secrets as files via $SECRETSDIR rather than passing values inline. RevolutConfig::from_env now uses the same read_env_or_file helper as SpicedbConfig / MinioConfig / RoadmapPipelineConfig — REVOLUT_API_KEY and REVOLUT_WEBHOOK_SECRET each accept either the inline value or a _FILE variant pointing at a Docker-secrets-style mount. Inline env wins over _FILE when both are set (backward-compatible for any existing deploy that passes values inline). Signature widened Option -> Result> so file-read errors propagate instead of silently degrading to "not configured" — matches the rest of the config types. Updates docs/REVOLUT-INTEGRATION-PLAN.md to replace the dead "Dashboard -> Webhooks -> Add webhook" steps (Revolut removed that UI in the 2024-09-01 API version) with the equivalent POST /api/1.0/webhooks curl call. --- crates/starstats-server/src/config.rs | 135 +++++++++++++++++++++++--- docs/REVOLUT-INTEGRATION-PLAN.md | 45 +++++++-- 2 files changed, 160 insertions(+), 20 deletions(-) diff --git a/crates/starstats-server/src/config.rs b/crates/starstats-server/src/config.rs index 99b32b90..8284812a 100644 --- a/crates/starstats-server/src/config.rs +++ b/crates/starstats-server/src/config.rs @@ -146,9 +146,12 @@ impl RoadmapPipelineConfig { /// Revolut Business Merchant API client configuration. /// -/// Required env vars: -/// - `REVOLUT_API_KEY` Bearer key from the merchant dashboard. -/// - `REVOLUT_WEBHOOK_SECRET` HMAC signing secret for webhook events. +/// Required env vars (each accepts either the value inline OR a `_FILE` +/// variant pointing at a Docker-secrets-style mount; matches the +/// convention used by `MINIO_SECRET_KEY_FILE`, `SPICEDB_PRESHARED_KEY_FILE`, +/// `ROADMAP_GH_*_FILE` etc.): +/// - `REVOLUT_API_KEY` (or `_FILE`) Bearer key from the merchant dashboard. +/// - `REVOLUT_WEBHOOK_SECRET` (or `_FILE`) HMAC signing secret for webhook events. /// /// Optional env vars (sensible defaults shipped): /// - `REVOLUT_API_BASE` Defaults to the sandbox host. Switch to @@ -168,12 +171,16 @@ pub struct RevolutConfig { } impl RevolutConfig { - pub fn from_env() -> Option { - let api_key = std::env::var("REVOLUT_API_KEY").ok()?; - let webhook_secret = std::env::var("REVOLUT_WEBHOOK_SECRET").ok()?; - if api_key.is_empty() || webhook_secret.is_empty() { - return None; - } + pub fn from_env() -> Result> { + let api_key = match read_env_or_file("REVOLUT_API_KEY", "REVOLUT_API_KEY_FILE")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let webhook_secret = + match read_env_or_file("REVOLUT_WEBHOOK_SECRET", "REVOLUT_WEBHOOK_SECRET_FILE")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; let api_base = std::env::var("REVOLUT_API_BASE") .unwrap_or_else(|_| "https://sandbox-merchant.revolut.com".to_string()) .trim_end_matches('/') @@ -183,13 +190,13 @@ impl RevolutConfig { let return_url = std::env::var("REVOLUT_RETURN_URL") .ok() .filter(|s| !s.is_empty()); - Some(Self { + Ok(Some(Self { api_key, webhook_secret, api_base, api_version, return_url, - }) + })) } } @@ -445,7 +452,7 @@ impl Config { let smtp = SmtpConfig::from_env()?; let updater = UpdaterConfig::from_env(); let kek = KekConfig::from_env(); - let revolut = RevolutConfig::from_env(); + let revolut = RevolutConfig::from_env()?; if revolut.is_some() { tracing::info!("Revolut Business merchant API configured"); } else { @@ -754,4 +761,108 @@ mod tests { clear_minio_env(); } + + fn clear_revolut_env() { + std::env::remove_var("REVOLUT_API_KEY"); + std::env::remove_var("REVOLUT_API_KEY_FILE"); + std::env::remove_var("REVOLUT_WEBHOOK_SECRET"); + std::env::remove_var("REVOLUT_WEBHOOK_SECRET_FILE"); + std::env::remove_var("REVOLUT_API_BASE"); + std::env::remove_var("REVOLUT_API_VERSION"); + std::env::remove_var("REVOLUT_RETURN_URL"); + } + + #[test] + fn revolut_config_returns_none_when_unset() { + let _g = ENV_LOCK.lock().unwrap(); + clear_revolut_env(); + let cfg = RevolutConfig::from_env().unwrap(); + assert!(cfg.is_none(), "missing keys should map to None (degraded)"); + } + + #[test] + fn revolut_config_returns_none_when_only_api_key_set() { + let _g = ENV_LOCK.lock().unwrap(); + clear_revolut_env(); + std::env::set_var("REVOLUT_API_KEY", "sk_test"); + // No webhook secret -> still None. + let cfg = RevolutConfig::from_env().unwrap(); + assert!(cfg.is_none()); + clear_revolut_env(); + } + + #[test] + fn revolut_config_reads_inline_values_with_defaults() { + let _g = ENV_LOCK.lock().unwrap(); + clear_revolut_env(); + std::env::set_var("REVOLUT_API_KEY", "sk_test"); + std::env::set_var("REVOLUT_WEBHOOK_SECRET", "wsk_test"); + + let cfg = RevolutConfig::from_env().unwrap().expect("config present"); + assert_eq!(cfg.api_key, "sk_test"); + assert_eq!(cfg.webhook_secret, "wsk_test"); + assert_eq!(cfg.api_base, "https://sandbox-merchant.revolut.com"); + assert_eq!(cfg.api_version, "2024-09-01"); + assert!(cfg.return_url.is_none()); + + clear_revolut_env(); + } + + #[test] + fn revolut_config_reads_secrets_from_file_mounts() { + let _g = ENV_LOCK.lock().unwrap(); + clear_revolut_env(); + + // Unique per-test-run paths under the OS temp dir so parallel + // crates don't collide. + let dir = std::env::temp_dir(); + let key_path = dir.join("starstats-test-revolut-api-key"); + let sec_path = dir.join("starstats-test-revolut-webhook-secret"); + std::fs::write(&key_path, "sk_from_file\n").unwrap(); + std::fs::write(&sec_path, "wsk_from_file\r\n").unwrap(); + + std::env::set_var("REVOLUT_API_KEY_FILE", &key_path); + std::env::set_var("REVOLUT_WEBHOOK_SECRET_FILE", &sec_path); + std::env::set_var("REVOLUT_API_BASE", "https://merchant.revolut.com"); + std::env::set_var( + "REVOLUT_RETURN_URL", + "https://app.example.com/donate/return", + ); + + let cfg = RevolutConfig::from_env().unwrap().expect("config present"); + assert_eq!(cfg.api_key, "sk_from_file", "CRLF must be trimmed"); + assert_eq!(cfg.webhook_secret, "wsk_from_file"); + assert_eq!(cfg.api_base, "https://merchant.revolut.com"); + assert_eq!( + cfg.return_url.as_deref(), + Some("https://app.example.com/donate/return") + ); + + clear_revolut_env(); + let _ = std::fs::remove_file(&key_path); + let _ = std::fs::remove_file(&sec_path); + } + + #[test] + fn revolut_config_inline_env_overrides_file() { + let _g = ENV_LOCK.lock().unwrap(); + clear_revolut_env(); + + let dir = std::env::temp_dir(); + let key_path = dir.join("starstats-test-revolut-api-key-override"); + std::fs::write(&key_path, "sk_from_file").unwrap(); + + std::env::set_var("REVOLUT_API_KEY", "sk_inline_wins"); + std::env::set_var("REVOLUT_API_KEY_FILE", &key_path); + std::env::set_var("REVOLUT_WEBHOOK_SECRET", "wsk"); + + let cfg = RevolutConfig::from_env().unwrap().expect("config present"); + assert_eq!( + cfg.api_key, "sk_inline_wins", + "inline env should take precedence over _FILE" + ); + + clear_revolut_env(); + let _ = std::fs::remove_file(&key_path); + } } diff --git a/docs/REVOLUT-INTEGRATION-PLAN.md b/docs/REVOLUT-INTEGRATION-PLAN.md index 7eba14ac..99eeacd4 100644 --- a/docs/REVOLUT-INTEGRATION-PLAN.md +++ b/docs/REVOLUT-INTEGRATION-PLAN.md @@ -119,18 +119,47 @@ Optional (sensible defaults): ### Setup checklist (when you provision credentials) -1. In the Revolut Business dashboard, go to Merchant API → Issue API - key. Copy it — it's only shown once. -2. Go to Webhooks → Add webhook. Set the URL to - `https://api.example.com/v1/webhooks/revolut` (or local equivalent). -3. Subscribe to events: `ORDER_COMPLETED`, `ORDER_FAILED`, - `ORDER_CANCELLED`. (We tolerate other events but only act on these.) -4. Copy the webhook signing secret — also only shown once. -5. Set the env vars on the deploy. The server logs +1. In the Revolut Business dashboard, navigate to + **Merchant → APIs → Merchant API → API Keys** and issue a new + key. Copy it — only shown once. This is `REVOLUT_API_KEY`. + +2. Provision the webhook **via the API** (the dashboard no longer + has a Webhooks UI — webhook management is API-only as of the + `2024-09-01` Merchant API version). Run against sandbox first: + + ```bash + curl -X POST https://sandbox-merchant.revolut.com/api/1.0/webhooks \ + -H "Authorization: Bearer $REVOLUT_API_KEY" \ + -H "Revolut-Api-Version: 2024-09-01" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://api.example.com/v1/webhooks/revolut", + "events": ["ORDER_COMPLETED", "ORDER_FAILED", "ORDER_CANCELLED"] + }' + ``` + + For production swap the host to `https://merchant.revolut.com`. + The response carries `id` (webhook_id, needed later for PATCH / + DELETE / signing-secret rotation) and `signing_secret`. + +3. Copy `response.signing_secret` into `REVOLUT_WEBHOOK_SECRET`. The + secret is also retrievable later via + `GET /api/1.0/webhooks/{id}` if you lose it before storing — + unlike the API key, which is genuinely one-shot. + +4. Set the env vars on the deploy. The server logs `Revolut Business merchant API configured` at boot when the keys resolve; absence logs the matching "not configured" line and the donate routes 503. +Other Merchant API webhook events the server tolerates but does not +act on: `ORDER_AUTHORISED` (informational — for the instant-capture +donate flow it precedes `ORDER_COMPLETED` by milliseconds with no +useful action between them), plus the broader +`ORDER_PAYMENT_*` / `ORDER_INCREMENTAL_AUTHORISATION_*` families +that aren't relevant to one-shot hosted checkouts. Subscribing +to the three terminal events above is sufficient. + ## Tiers Defined as a `const` table in `revolut_routes.rs::TIERS`. Today's set: