diff --git a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/AppStorage.sol b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/AppStorage.sol index 3c0cf677..3bfaa72e 100644 --- a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/AppStorage.sol +++ b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/AppStorage.sol @@ -119,6 +119,13 @@ library AppStorage { EnumerableSet.StringSet nodeConfigurationKeys; mapping(string => string) nodeConfigurationValues; uint256 serverTriggerValue; + // Lambda-parity: per-usage-key flag gating off-hot-path spending-rule + // enforcement (rolling spend cap, rate/concurrency limits, origin + // allowlist) stored off-chain. False for every existing key, so the + // gateway does zero extra work unless it is explicitly set. Appended at + // the end of root storage so the existing layout is untouched. + // See plans/chipotle-lambda-parity.md. + mapping(uint256 => bool) usageKeyHasSpendingRules; } function getStorage() diff --git a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol index 0f04a81c..2fb54b62 100644 --- a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol +++ b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/ViewsFacet.sol @@ -405,6 +405,29 @@ contract ViewsFacet { return apiKeyCanExecuteForAnyGroup(apiKeyHash, groupIds); } + /// @notice Whether a usage API key has off-chain spending rules the gateway + /// must enforce. False for every key that never set it, so the + /// gateway's hot path stays free for keys without rules. + function getSpendingRulesFlag( + uint256 apiKeyHash + ) public view returns (bool) { + return AppStorage.getStorage().usageKeyHasSpendingRules[apiKeyHash]; + } + + /// @notice Combined hot-path check: returns (canExecute, hasSpendingRules) + /// in a single call so the gateway reads both in one RPC and pays no + /// extra round trip for the spending-rules gate. + /// See plans/chipotle-lambda-parity.md. + function canExecuteActionWithSpendingRules( + uint256 apiKeyHash, + uint256 cidHash + ) public view returns (bool canExecute, bool hasSpendingRules) { + canExecute = canExecuteAction(apiKeyHash, cidHash); + hasSpendingRules = AppStorage.getStorage().usageKeyHasSpendingRules[ + apiKeyHash + ]; + } + function canUseWalletInAction( uint256 apiKeyHash, uint256 cidHash, diff --git a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol index f4a2dfbb..5d616026 100644 --- a/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol +++ b/lit-api-server/blockchain/lit_node_express/contracts/AccountConfigFacets/WritesFacet.sol @@ -25,6 +25,11 @@ contract WritesFacet { uint256 indexed accountApiKeyHash, uint256 indexed usageApiKeyHash ); + event SpendingRulesFlagSet( + uint256 indexed accountApiKeyHash, + uint256 indexed usageApiKeyHash, + bool hasSpendingRules + ); event GroupAdded(uint256 indexed apiKeyHash, uint256 indexed groupId); event GroupUpdated( uint256 indexed accountApiKeyHash, @@ -321,6 +326,28 @@ contract WritesFacet { emit UsageApiKeySet(masterAccountApiKeyHash, usageApiKeyHash); } + /// @notice Set the off-chain spending-rules flag for a usage API key. + /// @dev When true, the gateway enforces this key's off-chain spending rules + /// (rolling spend cap, rate/concurrency limits, origin allowlist); when + /// false it skips all of that on the hot path. Same account-access + /// control as setUsageApiKey. The detailed rules live off-chain (see + /// lit-payments); this only flips the gate. See + /// plans/chipotle-lambda-parity.md. + function setSpendingRulesFlag( + uint256 accountApiKeyHash, + uint256 usageApiKeyHash, + bool hasSpendingRules + ) public { + SecurityLib.revertIfNoAccountAccess(accountApiKeyHash, msg.sender); + AppStorage.AccountConfigStorage storage s = AppStorage.getStorage(); + s.usageKeyHasSpendingRules[usageApiKeyHash] = hasSpendingRules; + emit SpendingRulesFlagSet( + accountApiKeyHash, + usageApiKeyHash, + hasSpendingRules + ); + } + function addGroup( uint256 apiKeyHash, string memory name, diff --git a/lit-api-server/src/accounts/blockchain_cache.rs b/lit-api-server/src/accounts/blockchain_cache.rs index cecab304..4067f5bf 100644 --- a/lit-api-server/src/accounts/blockchain_cache.rs +++ b/lit-api-server/src/accounts/blockchain_cache.rs @@ -35,6 +35,8 @@ pub struct BlockchainCache { use_wallet: Cache, /// `can_execute_action_and_use_wallet` results. execute_and_wallet: Cache, + /// `can_execute_action_with_spending_rules` results: (canExecute, hasSpendingRules). + execute_and_spending: Cache, /// `get_wallet_derivation` results. wallet_derivation: Cache, /// Per-account generation counter keyed by the string representation of @@ -62,6 +64,11 @@ impl BlockchainCache { .time_to_idle(ttl) .time_to_live(ttl) .build(); + let execute_and_spending = Cache::builder() + .max_capacity(MAX_CAPACITY) + .time_to_idle(ttl) + .time_to_live(ttl) + .build(); let wallet_derivation = Cache::builder() .max_capacity(MAX_CAPACITY) .time_to_idle(ttl) @@ -71,6 +78,7 @@ impl BlockchainCache { execute_action, use_wallet, execute_and_wallet, + execute_and_spending, wallet_derivation, generations: RwLock::new(HashMap::new()), } @@ -112,6 +120,13 @@ impl BlockchainCache { format!("{h}:g{g}:ew:{cid_hash}:{wallet:#x}") } + /// Build a cache key for `can_execute_action_with_spending_rules`. + pub fn execute_and_spending_key(&self, api_key_hash: U256, cid_hash: U256) -> String { + let h = api_key_hash.to_string(); + let g = self.generation(&h); + format!("{h}:g{g}:es:{cid_hash}") + } + /// Build a cache key for `get_wallet_derivation`. pub fn wallet_derivation_key(&self, api_key_hash: U256, wallet: Address) -> String { let h = api_key_hash.to_string(); @@ -134,6 +149,11 @@ impl BlockchainCache { &self.execute_and_wallet } + /// Reference to the `can_execute_action_with_spending_rules` cache. + pub fn execute_and_spending_cache(&self) -> &Cache { + &self.execute_and_spending + } + /// Reference to the `get_wallet_derivation` cache. pub fn wallet_derivation_cache(&self) -> &Cache { &self.wallet_derivation diff --git a/lit-api-server/src/accounts/mod.rs b/lit-api-server/src/accounts/mod.rs index 193d8534..6367718b 100644 --- a/lit-api-server/src/accounts/mod.rs +++ b/lit-api-server/src/accounts/mod.rs @@ -676,6 +676,67 @@ pub async fn can_execute_action(api_key: &str, cid_hash: U256) -> Result { Ok(can_execute) } +/// Scoped binding for the spending-rules view added in lambda-parity PR 3. +/// Defined here (not via the giant generated binding) so it tracks the workspace +/// alloy version directly; fold into the generated binding once it is +/// regenerated on the canonical toolchain. See `plans/chipotle-lambda-parity.md`. +mod spending_view { + alloy::sol! { + #[sol(rpc)] + contract SpendingView { + function canExecuteActionWithSpendingRules( + uint256 apiKeyHash, + uint256 cidHash + ) external view returns (bool canExecute, bool hasSpendingRules); + } + } +} + +async fn fetch_execute_and_spending( + account_api_key_hash: U256, + cid_hash_eth: U256, +) -> Result<(bool, bool)> { + let (client, address) = crate::accounts::signable_contract::read_only_client_and_address()?; + let contract = spending_view::SpendingView::new(address, client); + let result = contract + .canExecuteActionWithSpendingRules(account_api_key_hash, cid_hash_eth) + .call() + .await?; + Ok((result.canExecute, result.hasSpendingRules)) +} + +/// Combined hot-path check: `(can_execute, has_spending_rules)` in a single RPC. +/// +/// `has_spending_rules` is the zero-latency gate for per-key Lambda-parity +/// controls — false for every key that never set it, so the common path does no +/// extra work. See `plans/chipotle-lambda-parity.md`. +#[instrument( + name = "accounts::can_execute_action_with_spending_rules", + level = "debug", + skip_all, + err +)] +pub async fn can_execute_action_with_spending_rules( + api_key: &str, + cid_hash: U256, +) -> Result<(bool, bool)> { + let account_api_key_hash = api_key_hash(api_key); + let cid_hash_eth = cid_hash; + + if let Some(cache) = blockchain_cache::get() { + let key = cache.execute_and_spending_key(account_api_key_hash, cid_hash); + return cache + .execute_and_spending_cache() + .try_get_with(key, async move { + fetch_execute_and_spending(account_api_key_hash, cid_hash_eth).await + }) + .await + .map_err(|e: Arc| anyhow::anyhow!("{:#}", e)); + } + + fetch_execute_and_spending(account_api_key_hash, cid_hash_eth).await +} + #[instrument( name = "accounts::can_use_wallet_in_action", level = "debug", diff --git a/lit-api-server/src/accounts/signable_contract.rs b/lit-api-server/src/accounts/signable_contract.rs index de5c4451..fd94ce96 100644 --- a/lit-api-server/src/accounts/signable_contract.rs +++ b/lit-api-server/src/accounts/signable_contract.rs @@ -102,6 +102,21 @@ pub(crate) fn get_read_only_client() -> Result { }) } +/// Read-only provider + the AccountConfig address, for ad-hoc scoped `sol!` +/// interfaces that target functions not yet present in the regenerated giant +/// binding (e.g. the spending-rules view from lambda-parity PR 3). Tracks the +/// workspace alloy version directly; fold callers into the generated binding +/// once it is regenerated on the canonical toolchain. +pub(crate) fn read_only_client_and_address() -> Result<(SigningClient, Address)> { + let client = get_read_only_client()?; + let node_config = GLOBAL_NODE_CONFIG + .get() + .ok_or_else(|| anyhow::anyhow!("Node configuration not found"))?; + let account_config_address = + Address::from_slice(&hex_to_bytes(&node_config.contract_address)?); + Ok((client, account_config_address)) +} + pub(crate) async fn get_read_only_account_config_contract() -> Result { let client = GLOBAL_READ_ONLY_CLIENT .get() diff --git a/lit-api-server/src/core/core_features.rs b/lit-api-server/src/core/core_features.rs index 35c840ba..5d747520 100644 --- a/lit-api-server/src/core/core_features.rs +++ b/lit-api-server/src/core/core_features.rs @@ -1,5 +1,6 @@ -use crate::accounts::can_execute_action; +use crate::accounts::can_execute_action_with_spending_rules; use crate::accounts::chain_config::{ChainConfig, ConfigKeys}; +use crate::core::spending_rules::SpendingRulesState; use crate::actions::client::ClientBuilder; use crate::actions::client::models::DenoExecutionEnv; use crate::actions::client::{ @@ -32,6 +33,7 @@ pub async fn lit_action( http_client: &reqwest::Client, chain_config: Arc, stripe_state: Option>, + spending: &SpendingRulesState, lit_action_request: Json, ) -> Result { let request_id = request_span.request_id.clone(); @@ -49,7 +51,10 @@ pub async fn lit_action( ) .await?; let cid_hash = ipfs_cid_to_u256(&derived_ipfs_id)?; - let can_execute = can_execute_action(api_key, cid_hash) + // Single multicall returns the execute permission AND the zero-latency + // spending-rules gate. has_spending_rules is false for almost every key, so + // the spending-rules path below is skipped entirely for them. + let (can_execute, has_spending_rules) = can_execute_action_with_spending_rules(api_key, cid_hash) .instrument(tracing::debug_span!("lit_action::can_execute_action")) .await?; if !can_execute { @@ -59,6 +64,11 @@ pub async fn lit_action( return Err(ApiStatus::forbidden(msg)); } + // Enforce per-key spending rules (rolling cap / rate / concurrency) before + // execution. Inert unless the key is flagged AND enforcement is configured. + // The returned admission holds any concurrency permit until end of scope. + let admission = spending.admit(api_key, has_spending_rules).await?; + // Cache after authorization so unauthorized requests cannot pollute the cache. ipfs_cache .insert(derived_ipfs_id.clone(), Arc::new(code_to_run.clone())) @@ -96,6 +106,7 @@ pub async fn lit_action( action_ipfs_id: Some(derived_ipfs_id), }; + let exec_start = std::time::Instant::now(); let result = match client .execute_js(execution_options) .instrument(tracing::debug_span!("lit_action::execute_js")) @@ -105,6 +116,11 @@ pub async fn lit_action( Err(e) => return Err(anyhow::anyhow!("Actions failed with : {:?}", e).into()), }; + // Record execution against the key's rolling spend counter (no-op unless the + // key has a spend cap). Off the response path — the local update is in-memory + // and the lit-payments write is fire-and-forget. + admission.record_seconds(exec_start.elapsed().as_secs_f64().ceil() as u64); + let response = match serde_json::from_str::(&result.response) { Ok(response) => response, Err(e) => { diff --git a/lit-api-server/src/core/mod.rs b/lit-api-server/src/core/mod.rs index 34426b39..7961fc9e 100644 --- a/lit-api-server/src/core/mod.rs +++ b/lit-api-server/src/core/mod.rs @@ -3,6 +3,7 @@ use crate::utils::{parse_with_hash::pkp_id_to_h160, u256_to_derviation_path}; pub mod account_management; pub mod core_features; pub mod eip712; +pub mod spending_rules; pub mod v1; pub async fn pkp_id_to_derviation_path(api_key: &str, pkp_id: &str) -> Result { diff --git a/lit-api-server/src/core/spending_rules.rs b/lit-api-server/src/core/spending_rules.rs new file mode 100644 index 00000000..b96cbe7c --- /dev/null +++ b/lit-api-server/src/core/spending_rules.rs @@ -0,0 +1,430 @@ +//! Gateway-side enforcement of per-key spending rules (Lambda parity). +//! +//! Only reached when a key's on-chain `hasSpendingRules` flag is set (see +//! `accounts::can_execute_action_with_spending_rules`), so keys without rules +//! pay zero added latency. For a flagged key, the rules + current rolling spend +//! are fetched (cached) from lit-payments' `/internal` endpoints and enforced +//! before execution: +//! +//! - **rolling spend cap** (402 when reached), +//! - **rate limit** — per-node token bucket (429), +//! - **concurrency cap** — per-node in-flight counter (429). +//! +//! Spend is recorded back to lit-payments off the response path. Counters are +//! in-process and per-node — acceptable because the durable spend cap is the +//! real backstop. See `plans/chipotle-lambda-parity.md`. +//! +//! Inert until configured: if `LIT_PAYMENTS_INTERNAL_URL` / +//! `INTERNAL_SERVICE_TOKEN` are unset, `admit` always allows and records nothing. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use moka::future::Cache; +use serde::Deserialize; + +use crate::core::v1::helpers::api_status::ApiStatus; + +/// SWR-ish freshness for the rules cache (kept short; only flagged keys pay it). +const RULES_CACHE_TTL: Duration = Duration::from_secs(30); +const RULES_CACHE_CAPACITY: u64 = 100_000; +/// Bound the hot-path cold-miss fetch so a slow lit-payments can't stall a call. +const FETCH_TIMEOUT: Duration = Duration::from_secs(2); +/// Per-second execution cost, mirrored from the Stripe charge rate so the +/// per-key counter tracks roughly what the account is billed. +const COST_PER_SECOND_CENTS: i64 = crate::stripe::COST_LIT_ACTION_PER_SECOND_CENTS; + +/// The rules the gateway enforces for one key (subset of the lit-payments row). +#[derive(Debug, Clone, Deserialize)] +pub struct RuleSet { + pub spend_cap_cents: Option, + pub spend_window_seconds: Option, + pub rate_limit_rps: Option, + pub rate_limit_burst: Option, + pub max_concurrency: Option, + pub enabled: bool, +} + +/// Shape of `GET /internal/spending-rules/` from lit-payments. +#[derive(Debug, Deserialize)] +struct RulesWithUsage { + rules: RuleSet, + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct UsageRow { + spent_cents: i64, +} + +/// Local rolling-spend counter, seeded from lit-payments and incremented on each +/// charge. Window resets locally when it elapses (fixed-window). +#[derive(Debug)] +struct Usage { + window_start: Instant, + spent_cents: i64, +} + +impl Usage { + /// Reset the window if `window` has elapsed since it started. + fn roll(&mut self, now: Instant, window: Duration) { + if now.duration_since(self.window_start) >= window { + self.window_start = now; + self.spent_cents = 0; + } + } +} + +/// Per-key token bucket for rate limiting. +#[derive(Debug)] +struct Bucket { + tokens: f64, + last_refill: Instant, +} + +impl Bucket { + /// Refill by elapsed time and try to consume one token. Returns true if allowed. + fn try_take(&mut self, now: Instant, rps: f64, burst: f64) -> bool { + let elapsed = now.duration_since(self.last_refill).as_secs_f64(); + self.tokens = (self.tokens + elapsed * rps).min(burst); + self.last_refill = now; + if self.tokens >= 1.0 { + self.tokens -= 1.0; + true + } else { + false + } + } +} + +struct Inner { + enabled: bool, + base_url: String, + token: String, + http: reqwest::Client, + rules_cache: Cache>>, + usage: Mutex>, + buckets: Mutex>, + concurrency: Mutex>, +} + +/// Shared, cheaply-clonable spending-rules enforcer. Managed in Rocket state. +#[derive(Clone)] +pub struct SpendingRulesState { + inner: Arc, +} + +impl SpendingRulesState { + /// Build from env. Enforcement is enabled only when both + /// `LIT_PAYMENTS_INTERNAL_URL` and `INTERNAL_SERVICE_TOKEN` are set; + /// otherwise this is fully inert. + pub fn from_env() -> Self { + let base_url = std::env::var("LIT_PAYMENTS_INTERNAL_URL") + .ok() + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty()); + let token = std::env::var("INTERNAL_SERVICE_TOKEN") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let enabled = base_url.is_some() && token.is_some(); + if enabled { + tracing::info!("spending_rules: enforcement enabled"); + } else { + tracing::info!( + "spending_rules: disabled (set LIT_PAYMENTS_INTERNAL_URL + INTERNAL_SERVICE_TOKEN to enable)" + ); + } + + let http = reqwest::Client::builder() + .timeout(FETCH_TIMEOUT) + .build() + .unwrap_or_default(); + + Self { + inner: Arc::new(Inner { + enabled, + base_url: base_url.unwrap_or_default(), + token: token.unwrap_or_default(), + http, + rules_cache: Cache::builder() + .max_capacity(RULES_CACHE_CAPACITY) + .time_to_live(RULES_CACHE_TTL) + .build(), + usage: Mutex::new(HashMap::new()), + buckets: Mutex::new(HashMap::new()), + concurrency: Mutex::new(HashMap::new()), + }), + } + } + + /// Enforce a flagged key's rules before execution. Returns an [`Admission`] + /// the caller holds across execution (releasing any concurrency permit on + /// drop) and calls [`Admission::record_spend`] on afterwards. + /// + /// `has_spending_rules` is the on-chain gate; pass it so we skip all work + /// (and the lit-payments round trip) for keys without rules. + pub async fn admit(&self, api_key: &str, has_spending_rules: bool) -> Result { + if !self.inner.enabled || !has_spending_rules { + return Ok(Admission(AdmissionInner::Noop)); + } + let hash = key_hash(api_key); + let rules = match self.fetch_rules(&hash).await { + Some(r) if r.enabled => r, + // no row, disabled, or fetch failed → don't block + _ => return Ok(Admission(AdmissionInner::Noop)), + }; + + let now = Instant::now(); + + // Rate limit. + if let (Some(rps), Some(burst)) = (rules.rate_limit_rps, rules.rate_limit_burst) { + let mut buckets = self.inner.buckets.lock().unwrap(); + let bucket = buckets.entry(hash.clone()).or_insert(Bucket { + tokens: burst as f64, + last_refill: now, + }); + if !bucket.try_take(now, rps as f64, burst as f64) { + return Err(ApiStatus::too_many_requests(format!( + "rate limit exceeded for this API key ({rps} rps)" + ))); + } + } + + // Rolling spend cap. + if let (Some(cap), Some(window)) = (rules.spend_cap_cents, rules.spend_window_seconds) { + let mut usage = self.inner.usage.lock().unwrap(); + let u = usage.entry(hash.clone()).or_insert(Usage { + window_start: now, + spent_cents: 0, + }); + u.roll(now, Duration::from_secs(window.max(0) as u64)); + if u.spent_cents >= cap { + return Err(ApiStatus::payment_required(format!( + "spending cap reached for this API key ({cap} cents / {window}s window)" + ))); + } + } + + // Concurrency (acquire last, so a rejection above never leaks a permit). + let concurrency_guard = if let Some(max) = rules.max_concurrency { + let mut counts = self.inner.concurrency.lock().unwrap(); + let count = counts.entry(hash.clone()).or_insert(0); + if *count >= max as u32 { + return Err(ApiStatus::too_many_requests(format!( + "max concurrent executions reached for this API key ({max})" + ))); + } + *count += 1; + Some(ConcurrencyGuard { + inner: self.inner.clone(), + key_hash: hash.clone(), + }) + } else { + None + }; + + Ok(Admission(AdmissionInner::Active { + inner: self.inner.clone(), + key_hash: hash, + window_secs: rules.spend_window_seconds, + _concurrency: concurrency_guard, + })) + } + + /// Fetch a key's rules (cached, TTL). `Ok(None)` (no row / disabled) is + /// cached so we don't refetch every request; network errors are not. + /// + /// TODO: upgrade to serve-stale-while-revalidate (background refresh), like + /// `stripe::get_credit_balance`, so the cold tick never blocks the hot path. + async fn fetch_rules(&self, hash: &str) -> Option> { + let cache = self.inner.rules_cache.clone(); + let inner = self.inner.clone(); + let hash_owned = hash.to_string(); + cache + .try_get_with(hash.to_string(), async move { + let url = format!("{}/internal/spending-rules/{}", inner.base_url, hash_owned); + let resp = inner + .http + .get(&url) + .bearer_auth(&inner.token) + .send() + .await + .map_err(|e| format!("spending_rules fetch failed: {e}"))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok::<_, String>(None); + } + if !resp.status().is_success() { + return Err(format!("spending_rules fetch status {}", resp.status())); + } + let body: RulesWithUsage = resp + .json() + .await + .map_err(|e| format!("spending_rules decode failed: {e}"))?; + // Seed the local rolling counter from the server's value. + if let Some(usage) = &body.usage { + seed_usage(&inner, &hash_owned, usage.spent_cents); + } + Ok(Some(Arc::new(body.rules))) + }) + .await + .unwrap_or_else(|e| { + tracing::warn!("spending_rules: {e}"); + None // fail open on transient error — never block a real call + }) + } + + fn add_local_spend(&self, hash: &str, cents: i64, window: i64) { + let now = Instant::now(); + let mut usage = self.inner.usage.lock().unwrap(); + let u = usage.entry(hash.to_string()).or_insert(Usage { + window_start: now, + spent_cents: 0, + }); + u.roll(now, Duration::from_secs(window.max(0) as u64)); + u.spent_cents = u.spent_cents.saturating_add(cents); + } + + /// Fire-and-forget POST of `cents` to lit-payments' rolling counter. + fn spawn_record(&self, hash: String, cents: i64, window: i64) { + let inner = self.inner.clone(); + tokio::spawn(async move { + let url = format!("{}/internal/spending-usage/{}/charge", inner.base_url, hash); + let res = inner + .http + .post(&url) + .bearer_auth(&inner.token) + .json(&serde_json::json!({ "cents": cents, "window_seconds": window })) + .send() + .await; + if let Err(e) = res { + tracing::warn!("spending_rules: record_spend POST failed: {e}"); + } + }); + } +} + +/// Seed/refresh the local counter from the server's value, taking the max so a +/// background refresh never undoes a local optimistic increment (cf. +/// `stripe::should_update_balance_cache`). +fn seed_usage(inner: &Inner, hash: &str, server_spent: i64) { + let now = Instant::now(); + let mut usage = inner.usage.lock().unwrap(); + let entry = usage.entry(hash.to_string()).or_insert(Usage { + window_start: now, + spent_cents: 0, + }); + entry.spent_cents = entry.spent_cents.max(server_spent); +} + +/// Held across execution. On drop, releases any concurrency permit. Call +/// [`Admission::record_seconds`] after execution to bill the rolling counter. +pub struct Admission(AdmissionInner); + +enum AdmissionInner { + Noop, + Active { + inner: Arc, + key_hash: String, + window_secs: Option, + _concurrency: Option, + }, +} + +impl Admission { + /// Record `seconds` of execution against the key's rolling spend (local + + /// async POST to lit-payments). No-op when there is no spend cap to enforce. + pub fn record_seconds(&self, seconds: u64) { + if let AdmissionInner::Active { + inner, + key_hash, + window_secs: Some(window), + .. + } = &self.0 + { + let cents = (seconds.max(1) as i64).saturating_mul(COST_PER_SECOND_CENTS); + let state = SpendingRulesState { + inner: inner.clone(), + }; + state.add_local_spend(key_hash, cents, *window); + state.spawn_record(key_hash.clone(), cents, *window); + } + } +} + +/// RAII concurrency permit: decrements the in-flight count on drop. +pub struct ConcurrencyGuard { + inner: Arc, + key_hash: String, +} + +impl Drop for ConcurrencyGuard { + fn drop(&mut self) { + let mut counts = self.inner.concurrency.lock().unwrap(); + if let Some(c) = counts.get_mut(&self.key_hash) { + *c = c.saturating_sub(1); + } + } +} + +/// The key's on-chain identity hash as 0x-prefixed 32-byte lowercase hex — +/// matching lit-payments' `canonical_key_hash`. +fn key_hash(api_key: &str) -> String { + let h = crate::utils::parse_with_hash::api_key_hash(api_key); + format!("0x{:0>64}", format!("{h:x}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bucket_allows_burst_then_throttles() { + let now = Instant::now(); + let mut b = Bucket { + tokens: 2.0, + last_refill: now, + }; + // Two tokens available, no time passing → two allowed, third denied. + assert!(b.try_take(now, 1.0, 2.0)); + assert!(b.try_take(now, 1.0, 2.0)); + assert!(!b.try_take(now, 1.0, 2.0)); + } + + #[test] + fn bucket_refills_over_time() { + let now = Instant::now(); + let mut b = Bucket { + tokens: 0.0, + last_refill: now, + }; + assert!(!b.try_take(now, 10.0, 10.0)); + // 0.5s at 10rps → ~5 tokens. + let later = now + Duration::from_millis(500); + assert!(b.try_take(later, 10.0, 10.0)); + } + + #[test] + fn usage_window_resets_after_elapse() { + let now = Instant::now(); + let mut u = Usage { + window_start: now, + spent_cents: 500, + }; + u.roll(now + Duration::from_secs(5), Duration::from_secs(10)); + assert_eq!(u.spent_cents, 500); // within window + u.roll(now + Duration::from_secs(11), Duration::from_secs(10)); + assert_eq!(u.spent_cents, 0); // window elapsed → reset + } + + #[test] + fn key_hash_is_0x_64_lowercase_hex() { + let h = key_hash("some-api-key"); + assert!(h.starts_with("0x")); + assert_eq!(h.len(), 66); + assert!(h[2..].bytes().all(|b| b.is_ascii_hexdigit())); + assert_eq!(h, h.to_lowercase()); + } +} diff --git a/lit-api-server/src/core/v1/endpoints/actions.rs b/lit-api-server/src/core/v1/endpoints/actions.rs index 0f977784..81babfa6 100644 --- a/lit-api-server/src/core/v1/endpoints/actions.rs +++ b/lit-api-server/src/core/v1/endpoints/actions.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::accounts::chain_config::ChainConfig; use crate::actions::grpc::GrpcClientPool; use crate::core::core_features; +use crate::core::spending_rules::SpendingRulesState; use crate::core::v1::guards::billing::BilledLitActionApiKey; use crate::core::v1::guards::cpu_overload::CpuAvailable; use crate::core::v1::helpers::api_status::{ApiResult, ErrMessage}; @@ -30,6 +31,7 @@ pub(super) async fn lit_action( http_client: &State, chain_config: &State>, stripe_state: &State>>, + spending: &State, lit_action_request: Json, ) -> OpenApiResponse { OpenApiResponse { @@ -42,6 +44,7 @@ pub(super) async fn lit_action( http_client.inner(), chain_config.inner().clone(), stripe_state.inner().clone(), + spending.inner(), lit_action_request, ) .await, diff --git a/lit-api-server/src/core/v1/helpers/api_status.rs b/lit-api-server/src/core/v1/helpers/api_status.rs index 0919438a..62dbfce0 100644 --- a/lit-api-server/src/core/v1/helpers/api_status.rs +++ b/lit-api-server/src/core/v1/helpers/api_status.rs @@ -170,6 +170,15 @@ impl ApiStatus { message, } } + + pub fn too_many_requests(message: impl Into) -> Self { + let message = message.into(); + warn!("too_many_requests: {:?}", message); + Self { + status: Status::TooManyRequests, + message, + } + } pub fn option_not_found(message: impl Into) -> Self { let message = message.into(); warn!("Option not found: {:?}", message); diff --git a/lit-api-server/src/main.rs b/lit-api-server/src/main.rs index 06ef3c37..4bdf2dd1 100644 --- a/lit-api-server/src/main.rs +++ b/lit-api-server/src/main.rs @@ -378,6 +378,7 @@ fn build_rocket( .manage(chain_config) .manage(cpu_monitor) .manage(stripe_state) + .manage(core::spending_rules::SpendingRulesState::from_env()) .manage(internal_config) .manage(auth_resolver) .manage(core::v1::health::LitActionsSocketPath( diff --git a/lit-payments/migrations/20260604000001_spending_rules.sql b/lit-payments/migrations/20260604000001_spending_rules.sql new file mode 100644 index 00000000..5e4b0186 --- /dev/null +++ b/lit-payments/migrations/20260604000001_spending_rules.sql @@ -0,0 +1,59 @@ +-- Per-API-key spending rules + rolling usage for Lambda-parity blast-radius +-- controls on frontend-callable usage keys. See plans/chipotle-lambda-parity.md. +-- +-- The gateway (lit-api-server) reads `spending_rules` (cached, SWR) to enforce a +-- rolling spend cap, rate/concurrency limits, and an origin allowlist on keys +-- whose on-chain `hasSpendingRules` flag is set, and increments `spending_usage` +-- off the response path via the internal charge endpoint. Keys with no row here +-- are unaffected — the gateway never reaches this table unless the flag is set. +-- +-- `api_key_hash` is the keccak256 of the API key as a 0x-prefixed 32-byte hex +-- string (the same on-chain account identity used elsewhere), stored lowercase. + +CREATE TABLE spending_rules ( + api_key_hash TEXT PRIMARY KEY, + -- Billing/account wallet this key belongs to. Audit + grouping only. + account_wallet_address TEXT, + + -- Rolling spend cap (AWS-Budgets style). Both NULL = no spend cap. + spend_cap_cents BIGINT CHECK (spend_cap_cents IS NULL OR spend_cap_cents > 0), + spend_window_seconds BIGINT CHECK (spend_window_seconds IS NULL OR spend_window_seconds > 0), + + -- Per-key rate limit (token bucket). Both NULL = no rate limit. + rate_limit_rps INTEGER CHECK (rate_limit_rps IS NULL OR rate_limit_rps > 0), + rate_limit_burst INTEGER CHECK (rate_limit_burst IS NULL OR rate_limit_burst > 0), + + -- Max simultaneous in-flight executions. NULL = no concurrency cap. + max_concurrency INTEGER CHECK (max_concurrency IS NULL OR max_concurrency > 0), + + -- Browser origin allowlist (defense-in-depth). NULL/empty = no restriction. + allowed_origins TEXT[], + + -- Lets an operator disable a key's rules without deleting them. + enabled BOOLEAN NOT NULL DEFAULT TRUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- A spend cap needs both halves or neither. + CONSTRAINT spend_cap_complete CHECK ( + (spend_cap_cents IS NULL) = (spend_window_seconds IS NULL) + ), + -- A rate limit needs both halves or neither. + CONSTRAINT rate_limit_complete CHECK ( + (rate_limit_rps IS NULL) = (rate_limit_burst IS NULL) + ) +); + +CREATE INDEX spending_rules_wallet_idx ON spending_rules (account_wallet_address); + +-- Durable rolling spend counter, one row per key. Independent of spending_rules +-- (no FK) so the gateway's best-effort async charge never fails on a delete +-- race; orphan counters are harmless and cleared when rules are deleted. +CREATE TABLE spending_usage ( + api_key_hash TEXT PRIMARY KEY, + -- Anchor of the current rolling window; reset when the window elapses. + window_started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + spent_cents BIGINT NOT NULL DEFAULT 0 CHECK (spent_cents >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/lit-payments/src/config.rs b/lit-payments/src/config.rs index c4d80618..c7931987 100644 --- a/lit-payments/src/config.rs +++ b/lit-payments/src/config.rs @@ -45,6 +45,11 @@ pub struct Config { /// admin portal and rate poller run but LITKEY browser payments stay /// disabled. pub litkey_chain: Option, + /// Shared bearer token authenticating the gateway's internal calls to the + /// spending-rules endpoints (`/internal/*`). If unset, those endpoints are + /// disabled (503) — they are never left open. See + /// `crate::spending::service_auth`. + pub internal_service_token: Option, /// Base URL of `lit-api-server`, used for the auto-top-up cache /// invalidation callback after a successful credit. e.g., /// `https://api.litprotocol.com`. This is the ONLY remaining @@ -119,6 +124,7 @@ impl Config { max_daily_per_operator_cents: optional_i64("MAX_DAILY_PER_OPERATOR_CENTS", 10_000)?, litkey_discount_basis_points: parse_discount_basis_points()?, litkey_chain: parse_litkey_chain_config()?, + internal_service_token: optional_trimmed("INTERNAL_SERVICE_TOKEN"), lit_api_server_base_url: required("LIT_API_SERVER_BASE_URL")? .trim_end_matches('/') .to_string(), diff --git a/lit-payments/src/lib.rs b/lit-payments/src/lib.rs index bb7edf66..4be8cfa0 100644 --- a/lit-payments/src/lib.rs +++ b/lit-payments/src/lib.rs @@ -13,3 +13,4 @@ pub mod internal; pub mod mail; pub mod portal; pub mod rate; +pub mod spending; diff --git a/lit-payments/src/main.rs b/lit-payments/src/main.rs index 1ca2f3d4..d8042a34 100644 --- a/lit-payments/src/main.rs +++ b/lit-payments/src/main.rs @@ -11,6 +11,7 @@ use lit_payments::auto_topup::webhook::mutex::PerCustomerMutex; use lit_payments::billing as billing_routes; use lit_payments::portal::routes as portal_routes; use lit_payments::rate; +use lit_payments::spending::routes as spending_routes; use lit_payments::{auth, chain, config, db, mail}; use rocket::fs::{FileServer, NamedFile}; use rocket::http::Status; @@ -105,6 +106,12 @@ async fn rocket() -> _ { chain::get_payment_config, chain::claim_payment, rate::override_rate, + spending_routes::put_rules, + spending_routes::get_rules, + spending_routes::list_rules, + spending_routes::delete_rules, + spending_routes::internal_get_rules, + spending_routes::internal_charge, billing_routes::setup_intent::setup_intent, billing_routes::auto_topup_config::get_auto_topup_config, billing_routes::auto_topup_config::put_auto_topup_config, diff --git a/lit-payments/src/spending/db.rs b/lit-payments/src/spending/db.rs new file mode 100644 index 00000000..cc79aff5 --- /dev/null +++ b/lit-payments/src/spending/db.rs @@ -0,0 +1,120 @@ +//! Postgres queries for spending rules + rolling usage. +//! +//! Runtime `sqlx` (no compile-time DB), matching the rest of the service. + +use anyhow::Result; +use sqlx::PgPool; + +use super::types::{SpendingRules, SpendingUsage, UpsertRulesRequest}; + +/// Insert or replace the rules for a key, returning the stored row. +pub async fn upsert_rules( + pool: &PgPool, + api_key_hash: &str, + req: &UpsertRulesRequest, +) -> Result { + let row = sqlx::query_as::<_, SpendingRules>( + "INSERT INTO spending_rules ( + api_key_hash, account_wallet_address, spend_cap_cents, spend_window_seconds, + rate_limit_rps, rate_limit_burst, max_concurrency, allowed_origins, enabled, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) + ON CONFLICT (api_key_hash) DO UPDATE SET + account_wallet_address = EXCLUDED.account_wallet_address, + spend_cap_cents = EXCLUDED.spend_cap_cents, + spend_window_seconds = EXCLUDED.spend_window_seconds, + rate_limit_rps = EXCLUDED.rate_limit_rps, + rate_limit_burst = EXCLUDED.rate_limit_burst, + max_concurrency = EXCLUDED.max_concurrency, + allowed_origins = EXCLUDED.allowed_origins, + enabled = EXCLUDED.enabled, + updated_at = now() + RETURNING *", + ) + .bind(api_key_hash) + .bind(req.account_wallet_address.as_deref()) + .bind(req.spend_cap_cents) + .bind(req.spend_window_seconds) + .bind(req.rate_limit_rps) + .bind(req.rate_limit_burst) + .bind(req.max_concurrency) + .bind(req.allowed_origins.as_deref()) + .bind(req.enabled) + .fetch_one(pool) + .await?; + Ok(row) +} + +pub async fn get_rules(pool: &PgPool, api_key_hash: &str) -> Result> { + let row = sqlx::query_as::<_, SpendingRules>( + "SELECT * FROM spending_rules WHERE api_key_hash = $1", + ) + .bind(api_key_hash) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn get_usage(pool: &PgPool, api_key_hash: &str) -> Result> { + let row = sqlx::query_as::<_, SpendingUsage>( + "SELECT * FROM spending_usage WHERE api_key_hash = $1", + ) + .bind(api_key_hash) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn list_rules(pool: &PgPool, limit: i64) -> Result> { + let rows = sqlx::query_as::<_, SpendingRules>( + "SELECT * FROM spending_rules ORDER BY updated_at DESC LIMIT $1", + ) + .bind(limit) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Delete a key's rules and its usage counter. Returns whether a rules row existed. +pub async fn delete_rules(pool: &PgPool, api_key_hash: &str) -> Result { + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM spending_usage WHERE api_key_hash = $1") + .bind(api_key_hash) + .execute(&mut *tx) + .await?; + let res = sqlx::query("DELETE FROM spending_rules WHERE api_key_hash = $1") + .bind(api_key_hash) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(res.rows_affected() > 0) +} + +/// Add `cents` to a key's rolling spend counter, resetting the window first if +/// it has elapsed. One atomic statement so concurrent charges can't race the +/// read-modify-write. Returns the post-charge counter. +pub async fn record_charge( + pool: &PgPool, + api_key_hash: &str, + cents: i64, + window_seconds: i64, +) -> Result { + let row = sqlx::query_as::<_, SpendingUsage>( + "INSERT INTO spending_usage AS u (api_key_hash, window_started_at, spent_cents, updated_at) + VALUES ($1, now(), $2, now()) + ON CONFLICT (api_key_hash) DO UPDATE SET + window_started_at = CASE + WHEN now() - u.window_started_at >= make_interval(secs => $3) + THEN now() ELSE u.window_started_at END, + spent_cents = CASE + WHEN now() - u.window_started_at >= make_interval(secs => $3) + THEN $2 ELSE u.spent_cents + $2 END, + updated_at = now() + RETURNING *", + ) + .bind(api_key_hash) + .bind(cents) + .bind(window_seconds as f64) + .fetch_one(pool) + .await?; + Ok(row) +} diff --git a/lit-payments/src/spending/mod.rs b/lit-payments/src/spending/mod.rs new file mode 100644 index 00000000..7bbd3f92 --- /dev/null +++ b/lit-payments/src/spending/mod.rs @@ -0,0 +1,59 @@ +//! Per-API-key spending rules + rolling usage. +//! +//! Storage and HTTP surface for the Lambda-parity blast-radius controls +//! (rolling spend cap, rate/concurrency limits, origin allowlist) that the +//! gateway enforces on frontend-callable usage keys. See +//! `plans/chipotle-lambda-parity.md`. +//! +//! - Operator (cookie-authed) routes under `/api/spending-rules` let the admin +//! UI read/set/clear a key's rules. +//! - Internal ([`ServiceAuth`]-authed) routes under `/internal` let the gateway +//! fetch rules for its cache and record spend off the response path. + +pub mod db; +pub mod routes; +pub mod service_auth; +pub mod types; + +pub use service_auth::ServiceAuth; + +/// Canonicalize an `api_key_hash` path/param: a 0x-prefixed 32-byte (64 hex +/// char) keccak256 hash, normalized to lowercase. The operator UI and the +/// gateway must agree on this exact representation. +pub fn canonical_key_hash(raw: &str) -> Result { + let s = raw.trim(); + let body = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s); + if body.len() != 64 || !body.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("api_key_hash must be a 0x-prefixed 32-byte hex string".into()); + } + Ok(format!("0x{}", body.to_ascii_lowercase())) +} + +#[cfg(test)] +mod tests { + use super::canonical_key_hash; + + #[test] + fn normalizes_case_and_prefix() { + let h = "ABCD".repeat(16); // 64 hex chars + let with_prefix = format!("0x{h}"); + assert_eq!( + canonical_key_hash(&with_prefix).unwrap(), + format!("0x{}", h.to_ascii_lowercase()) + ); + // Accepts the unprefixed form too. + assert_eq!( + canonical_key_hash(&h).unwrap(), + format!("0x{}", h.to_ascii_lowercase()) + ); + } + + #[test] + fn rejects_wrong_length_or_non_hex() { + assert!(canonical_key_hash("0x1234").is_err()); + assert!(canonical_key_hash(&"zz".repeat(32)).is_err()); + } +} diff --git a/lit-payments/src/spending/routes.rs b/lit-payments/src/spending/routes.rs new file mode 100644 index 00000000..b56d1103 --- /dev/null +++ b/lit-payments/src/spending/routes.rs @@ -0,0 +1,147 @@ +//! Spending-rules HTTP routes. +//! +//! Operator-authed CRUD under `/api/spending-rules` (browser admin UI) and +//! `ServiceAuth`-authed endpoints under `/internal` (the gateway). + +use rocket::http::Status; +use rocket::serde::json::Json; +use rocket::{State, delete, get, post, put}; +use sqlx::PgPool; + +use super::db; +use super::types::{ + ChargeRequest, DeleteResponse, ErrorResponse, RulesListResponse, RulesWithUsage, SpendingRules, + SpendingUsage, UpsertRulesRequest, +}; +use super::{ServiceAuth, canonical_key_hash}; +use crate::auth::Operator; + +const DEFAULT_RULES_LIMIT: i64 = 100; +const MAX_RULES_LIMIT: i64 = 500; + +type ApiError = (Status, Json); +type ApiResult = Result, ApiError>; + +fn err(status: Status, message: impl Into) -> ApiError { + ( + status, + Json(ErrorResponse { + error: message.into(), + }), + ) +} + +fn server_err(e: impl std::fmt::Display + std::fmt::Debug) -> ApiError { + tracing::warn!(error = %e, error_debug = ?e, "spending route internal error"); + err(Status::InternalServerError, "internal error") +} + +fn parse_hash(raw: &str) -> Result { + canonical_key_hash(raw).map_err(|e| err(Status::BadRequest, e)) +} + +// ─── Operator (admin UI) ──────────────────────────────────────────────────── + +/// `PUT /api/spending-rules/` — create or replace a key's rules. +#[put("/api/spending-rules/", format = "json", data = "")] +pub async fn put_rules( + _operator: Operator, + api_key_hash: &str, + req: Json, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let req = req.into_inner(); + req.validate().map_err(|e| err(Status::BadRequest, e))?; + let rules = db::upsert_rules(pool, &hash, &req) + .await + .map_err(server_err)?; + Ok(Json(rules)) +} + +/// `GET /api/spending-rules/` — a key's rules + current usage. +#[get("/api/spending-rules/")] +pub async fn get_rules( + _operator: Operator, + api_key_hash: &str, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let rules = db::get_rules(pool, &hash) + .await + .map_err(server_err)? + .ok_or_else(|| err(Status::NotFound, "no rules for that key"))?; + let usage = db::get_usage(pool, &hash).await.map_err(server_err)?; + Ok(Json(RulesWithUsage { rules, usage })) +} + +/// `GET /api/spending-rules?limit=N` — recently-updated rules, newest first. +#[get("/api/spending-rules?")] +pub async fn list_rules( + _operator: Operator, + limit: Option, + pool: &State, +) -> ApiResult { + let limit = limit.unwrap_or(DEFAULT_RULES_LIMIT).clamp(1, MAX_RULES_LIMIT); + let rules = db::list_rules(pool, limit).await.map_err(server_err)?; + Ok(Json(RulesListResponse { rules })) +} + +/// `DELETE /api/spending-rules/` — clear a key's rules + usage counter. +#[delete("/api/spending-rules/")] +pub async fn delete_rules( + _operator: Operator, + api_key_hash: &str, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let deleted = db::delete_rules(pool, &hash).await.map_err(server_err)?; + Ok(Json(DeleteResponse { deleted })) +} + +// ─── Internal (gateway) ───────────────────────────────────────────────────── + +/// `GET /internal/spending-rules/` — rules + usage for the gateway's +/// cache. 404 when the key has no rules (the gateway caches that as "no rules"). +#[get("/internal/spending-rules/")] +pub async fn internal_get_rules( + _svc: ServiceAuth, + api_key_hash: &str, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let rules = db::get_rules(pool, &hash) + .await + .map_err(server_err)? + .ok_or_else(|| err(Status::NotFound, "no rules for that key"))?; + let usage = db::get_usage(pool, &hash).await.map_err(server_err)?; + Ok(Json(RulesWithUsage { rules, usage })) +} + +/// `POST /internal/spending-usage//charge` — add to the rolling spend +/// counter (resetting the window if elapsed). Called by the gateway off the +/// response path; best-effort. +#[post( + "/internal/spending-usage//charge", + format = "json", + data = "" +)] +pub async fn internal_charge( + _svc: ServiceAuth, + api_key_hash: &str, + req: Json, + pool: &State, +) -> ApiResult { + let hash = parse_hash(api_key_hash)?; + let req = req.into_inner(); + if req.cents <= 0 { + return Err(err(Status::BadRequest, "cents must be positive")); + } + if req.window_seconds <= 0 { + return Err(err(Status::BadRequest, "window_seconds must be positive")); + } + let usage = db::record_charge(pool, &hash, req.cents, req.window_seconds) + .await + .map_err(server_err)?; + Ok(Json(usage)) +} diff --git a/lit-payments/src/spending/service_auth.rs b/lit-payments/src/spending/service_auth.rs new file mode 100644 index 00000000..50ef5b8a --- /dev/null +++ b/lit-payments/src/spending/service_auth.rs @@ -0,0 +1,78 @@ +//! `ServiceAuth` request guard — authenticates internal service-to-service +//! calls (the gateway reading rules / recording usage) via a shared bearer +//! token (`INTERNAL_SERVICE_TOKEN`). +//! +//! Distinct from the cookie-based [`Operator`](crate::auth::Operator) guard used +//! by the browser admin UI. If the token is not configured, internal endpoints +//! are disabled (503) rather than open. + +use rocket::http::Status; +use rocket::request::{FromRequest, Outcome, Request}; + +use crate::config::Config; + +pub struct ServiceAuth; + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ServiceAuth { + type Error = (); + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let Some(cfg) = req.rocket().state::() else { + tracing::error!("ServiceAuth guard: Config not in Rocket state"); + return Outcome::Error((Status::InternalServerError, ())); + }; + let Some(expected) = cfg.internal_service_token.as_deref() else { + // Not configured → internal endpoints are off, not open. + return Outcome::Error((Status::ServiceUnavailable, ())); + }; + match bearer(req) { + Some(provided) if constant_time_eq(provided.as_bytes(), expected.as_bytes()) => { + Outcome::Success(ServiceAuth) + } + _ => Outcome::Error((Status::Unauthorized, ())), + } + } +} + +/// Extract a `Authorization: Bearer ` value. +fn bearer(req: &Request<'_>) -> Option { + let v = req.headers().get_one("Authorization")?; + let mut parts = v.split_whitespace(); + match (parts.next(), parts.next()) { + (Some(scheme), Some(token)) + if scheme.eq_ignore_ascii_case("bearer") && !token.trim().is_empty() => + { + Some(token.trim().to_string()) + } + _ => None, + } +} + +/// Length-aware constant-time byte comparison (the length is allowed to leak). +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::constant_time_eq; + + #[test] + fn equal_tokens_match() { + assert!(constant_time_eq(b"s3cret-token", b"s3cret-token")); + } + + #[test] + fn different_tokens_or_lengths_fail() { + assert!(!constant_time_eq(b"s3cret-token", b"s3cret-tokeX")); + assert!(!constant_time_eq(b"short", b"longer-token")); + } +} diff --git a/lit-payments/src/spending/types.rs b/lit-payments/src/spending/types.rs new file mode 100644 index 00000000..be80ab95 --- /dev/null +++ b/lit-payments/src/spending/types.rs @@ -0,0 +1,186 @@ +//! Request/response + row shapes for per-key spending rules and rolling usage. +//! +//! See `plans/chipotle-lambda-parity.md`. The gateway reads these to enforce a +//! rolling spend cap, rate/concurrency limits, and an origin allowlist on +//! frontend-callable usage keys. + +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use time::OffsetDateTime; + +/// A row of `spending_rules` — the configured limits for one API key. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct SpendingRules { + pub api_key_hash: String, + pub account_wallet_address: Option, + pub spend_cap_cents: Option, + pub spend_window_seconds: Option, + pub rate_limit_rps: Option, + pub rate_limit_burst: Option, + pub max_concurrency: Option, + pub allowed_origins: Option>, + pub enabled: bool, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +/// A row of `spending_usage` — the rolling spend counter for one API key. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct SpendingUsage { + pub api_key_hash: String, + #[serde(with = "time::serde::rfc3339")] + pub window_started_at: OffsetDateTime, + pub spent_cents: i64, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +/// Body of `PUT /api/spending-rules/` (operator) — the editable fields. +/// Omitted fields default to "no limit". Paired fields (cap+window, rps+burst) +/// must be supplied together; validated before write. +#[derive(Debug, Default, Deserialize)] +pub struct UpsertRulesRequest { + #[serde(default)] + pub account_wallet_address: Option, + #[serde(default)] + pub spend_cap_cents: Option, + #[serde(default)] + pub spend_window_seconds: Option, + #[serde(default)] + pub rate_limit_rps: Option, + #[serde(default)] + pub rate_limit_burst: Option, + #[serde(default)] + pub max_concurrency: Option, + #[serde(default)] + pub allowed_origins: Option>, + /// Defaults to enabled when omitted. + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + +/// What the gateway fetches for its rules cache: the rules plus current usage. +#[derive(Debug, Serialize)] +pub struct RulesWithUsage { + pub rules: SpendingRules, + pub usage: Option, +} + +/// Body of the internal `POST /internal/spending-usage//charge`. The +/// gateway supplies the window length (it has the rules cached) so the counter +/// can self-reset without a second query. +#[derive(Debug, Deserialize)] +pub struct ChargeRequest { + pub cents: i64, + pub window_seconds: i64, +} + +#[derive(Debug, Serialize)] +pub struct RulesListResponse { + pub rules: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DeleteResponse { + pub deleted: bool, +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +impl UpsertRulesRequest { + /// Reject incomplete pairs / non-positive values before they hit the DB + /// CHECK constraints, so callers get a clear 400 instead of a 500. + pub fn validate(&self) -> Result<(), String> { + if self.spend_cap_cents.is_some() != self.spend_window_seconds.is_some() { + return Err("spend_cap_cents and spend_window_seconds must be set together".into()); + } + if self.rate_limit_rps.is_some() != self.rate_limit_burst.is_some() { + return Err("rate_limit_rps and rate_limit_burst must be set together".into()); + } + for (name, v) in [ + ("spend_cap_cents", self.spend_cap_cents), + ("spend_window_seconds", self.spend_window_seconds), + ] { + if let Some(v) = v + && v <= 0 + { + return Err(format!("{name} must be positive")); + } + } + for (name, v) in [ + ("rate_limit_rps", self.rate_limit_rps), + ("rate_limit_burst", self.rate_limit_burst), + ("max_concurrency", self.max_concurrency), + ] { + if let Some(v) = v + && v <= 0 + { + return Err(format!("{name} must be positive")); + } + } + if let Some(origins) = &self.allowed_origins + && origins.iter().any(|o| o.trim().is_empty()) + { + return Err("allowed_origins must not contain empty entries".into()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base() -> UpsertRulesRequest { + UpsertRulesRequest { + enabled: true, + ..Default::default() + } + } + + #[test] + fn empty_rules_are_valid() { + assert!(base().validate().is_ok()); + } + + #[test] + fn spend_cap_requires_both_halves() { + let mut r = base(); + r.spend_cap_cents = Some(1000); + assert!(r.validate().is_err()); + r.spend_window_seconds = Some(86_400); + assert!(r.validate().is_ok()); + } + + #[test] + fn rate_limit_requires_both_halves() { + let mut r = base(); + r.rate_limit_rps = Some(10); + assert!(r.validate().is_err()); + r.rate_limit_burst = Some(20); + assert!(r.validate().is_ok()); + } + + #[test] + fn rejects_non_positive() { + let mut r = base(); + r.max_concurrency = Some(0); + assert!(r.validate().is_err()); + } + + #[test] + fn rejects_empty_origin() { + let mut r = base(); + r.allowed_origins = Some(vec!["https://app.example.com".into(), " ".into()]); + assert!(r.validate().is_err()); + } +} diff --git a/plans/chipotle-lambda-parity.md b/plans/chipotle-lambda-parity.md new file mode 100644 index 00000000..717dacef --- /dev/null +++ b/plans/chipotle-lambda-parity.md @@ -0,0 +1,351 @@ +# Chipotle: Lambda-Parity for Frontend-Callable Actions + +Status: design proposal / work plan +Author: Chris (with Claude) +Date: 2026-06-04 +Related: [private-apps-backend.md](./private-apps-backend.md) — this is the +gateway-side dependency that lets that plan drop its relay requirement. + +## Goal + +Make a Chipotle **usage API key safe to embed directly in a frontend**, with +abuse/spend blast-radius control on par with a **public AWS Lambda Function URL**. +Achieving this lets "private apps" host only a frontend and call Lit Actions +directly — no relay needed. + +## The framing (why this is achievable) + +A public Lambda URL is itself a credential-free, internet-callable endpoint. +Anyone can hit it; AWS's protection is **not secrecy**, it's **bounded, +configurable blast radius**: the URL invokes only that one function, throttled, +concurrency-capped, with budgets + alarms. A determined griefer can still run up +*capped* cost — parity means the worst case is a number you chose and got alerted +about, not "drains the account." + +Two things are notably **out of scope of the risk** and must stay that way: + +- **Confidentiality is unaffected.** Action plaintext/secrets are TEE- and + CID-gated. Griefing a public key burns money/availability; it can never read + encrypted data. Nothing here touches that guarantee. +- **CID scoping already bounds *what* runs.** Usage keys are group-scoped + (`execute_in_groups`), groups pin exact CIDs, and execute permission is checked + on-chain (`accounts/mod.rs::can_execute_action`). A leaked key on a group that + pins only your public actions can run **only those actions** — nothing else, + no account management. This is the "Function URL → one function" property, and + it already exists. + +So the gaps are all about bounding **rate, concurrency, and spend per key**, plus +defense-in-depth. + +## What already exists (verify + document, don't rebuild) + +| Capability | Where | Status | +|---|---|---| +| Per-key CID scoping (leak radius = pinned actions) | `accounts/mod.rs::can_execute_action`; group `cid_hashes` | ✅ works | +| Global overload shedding → 429 | `core/v1/guards/cpu_overload.rs` (CPL-202, commit d2743fdb) | ✅ but global, not per-key | +| Account-wide prepaid credits → 402 when empty | `core/v1/guards/billing.rs::BilledLitActionApiKey`; `stripe.rs` | ✅ but account-wide, crude | +| Per-second execution charge | `actions/client/execution.rs::flush_unbilled_seconds`; `stripe.rs` `COST_LIT_ACTION_PER_SECOND_CENTS` | ✅ | + +## Architecture: where state lives & the zero-latency path + +The hot path today is already built on one principle, and everything here follows +it: **authoritative data lives in a slow store (chain or Stripe); the request path +only ever touches an in-process [moka](https://docs.rs/moka) cache with TTL + +stale-while-revalidate (SWR) + request coalescing; staleness is tolerated within +the TTL; money/usage settles asynchronously off the response path.** We are not +inventing an architecture — we are adding cached fields that obey the existing one. + +What the code already establishes (verified): + +- **On-chain permission reads are already cached on the hot path.** + `accounts/blockchain_cache.rs` caches `canExecuteAction` / `canUseWalletInAction` + / wallet derivation (60-min TTL, per-account **generation-counter** invalidation + bumped on any write). The key's on-chain record is in memory when a request runs. +- **Stripe reads are cached too.** `stripe.rs`: `wallet_cache` (key→wallet, 1h), + `customer_cache` (10m), `balance_cache` (10m TTL + **SWR** + background refresh + + coalescing). The credit check in `BilledLitActionApiKey::from_request` is a + memory read after warmup. +- **Charging is off the response path.** `stripe::charge()` reads the cached + balance, does an **optimistic in-memory decrement**, records the event, and + `spawn`s the real Stripe balance transaction fire-and-forget (there's a + `billing.charge.settlement_failed` metric for when it doesn't land). + `flush_unbilled_seconds` awaits only the cache ops, not the network. So + per-request charging costs microseconds, not a round trip. + +### The zero-latency gate + +The on-chain key record is **already read and cached** on the hot path (it's what +`canExecuteAction` returns). So we pack a single **`hasSpendingRules` bit** into +that record. Reading it costs **zero** extra network and zero extra cache lookup — +it rides along in data already in memory. Fetched in the **same multicall** as +`canExecuteAction` (trivial Solidity change — return multiple values), even a cold +cache miss adds no extra round trip. + +``` +guard (before execution): + (canExec, hasSpendingRules) = blockchain_cache.permissions(keyHash) # 1 cached multicall + if !hasSpendingRules: return Success # ← the 99% with no caps: ZERO added latency + else: enforce rules (all in-memory; see below) +``` + +### Where each kind of state lives + +The three things we store have different shapes (config vs. counter, write-rarely +vs. write-per-request), so they don't share one home: + +| Data | Shape | Home | Hot-path read | +|---|---|---|---| +| `hasSpendingRules` flag | 1 bit, toggled rarely | **On-chain** (opt 2), in the key record | Free — already in `BlockchainCache` | +| Cap + window, rate/burst, concurrency, IP/origin allowlist | Config, edited occasionally | **lit-payments DB** (opt 3), edited via its backend+frontend | New moka cache (TTL+SWR), **only read when flag set** | +| Per-key cumulative spend (rolling window) | High-write counter, durable | **lit-payments DB**, written on the existing **async** charge path | Optimistic in-memory decrement (mirrors `balance_cache`) | +| Rate-limit token buckets, concurrency counts, per-IP counters | High-write, ephemeral, rolling | **In-process memory** (per-node) | Native — already in memory | + +**Why this split:** + +- **Flag on-chain, not the cap value.** The flag is a permission (belongs with the + others) and is the one thing that must be free to read for *everyone*. Toggled + only when a key gains/loses its first rule (1 tx, rare). Keeping the cap *value* + off-chain means tuning caps in the lit-payments UI needs no gas/tx, and the DB + read is gated behind the flag so non-cap accounts never touch it. +- **Rule details + per-key usage in the DB, not Stripe metadata.** Stripe metadata + is per-*customer* (account), not per-key; writing it is an API call; it's the + wrong tool for rolling windows, rate config, or analytics. Keep Stripe for the + money relationship that already exists (opt 1 stays as-is). +- **Counters in memory, per-node.** Rate buckets / concurrency counts can't go + on-chain (per-request writes) or in Stripe. Per-node is acceptable because the + **durable spend cap is the real backstop**; per-node rate limiting yields an + effective cluster rate of ≈ limit × N nodes, which is fine for griefing control + (AWS API Gateway throttling is approximate too). Start per-node; add a shared + store (Redis-like) only if cluster-exact limits are ever required. + +### Request flow for an opted-in key + +``` +1. blockchain_cache: (canExecuteAction, hasSpendingRules) 1 cached multicall +2. flag set → rules_cache.get(keyHash) memory; DB read only on cold miss (SWR) +3. spend_cache.get(keyHash) vs rolling cap memory; reject 402 if over +4. rate bucket + concurrency counter memory; reject 429/503 if over +5. origin / IP allowlist check memory; reject 403 if not allowed +6. execute +7. POST-response, in the existing spawned settlement task: + - Stripe balance txn (already there) + - increment per-key rolling spend in DB + optimistic in-mem decrement +``` + +Steps 1–5 are memory reads (low microseconds). Step 7 is entirely off the response +path. An opted-in key pays one cold DB read per ~TTL window; a no-cap key pays +nothing. The staleness tolerance is the same bargain CPL-246 already accepted for +balances: a tiny possible overspend inside the TTL window in exchange for a fast +hot path. + +### Resolved design decisions + +1. **Cap semantics → rolling window** (match AWS Budgets / Lambda), not a lifetime + balance. The per-key spend counter resets on the window boundary. +2. **Rules-cache freshness → SWR** (short TTL + background refresh, no cross-service + invalidation plumbing), mirroring `balance_cache`. A cap edit takes effect + within the TTL window. +3. **Counter durability → per-key spend reloads from the DB on cache miss** + (durable across deploys/restarts); rate/concurrency buckets may reset on restart + (acceptable — the spend cap is the durable backstop). +4. **`hasSpendingRules` is fetched in the same contract multicall as + `canExecuteAction`** so a cold cache miss adds no extra round trip (a simple + Solidity change to return multiple values). +5. **"Turn rules on" ordering:** write the DB rule first, *then* flip the on-chain + bit. The bit going true is what activates enforcement, so this ordering avoids a + window where the flag is set but rules aren't loaded. + +## Work items, in priority order + +Priority = how much it bounds the worst case per dollar of effort. P0 items are +the ones that turn "drains the account" into "burns a number you chose." + +--- + +### P0.0 — `hasSpendingRules` flag + multicall (foundation for everything below) + +**What.** A single `hasSpendingRules` bit on the on-chain key record, returned in +the **same multicall** as `canExecuteAction`, surfaced through `BlockchainCache`, +and a request guard that short-circuits to "no enforcement" when it's false. + +**Why first.** This is the zero-latency gate. Every per-key control below +(spend cap, rate, concurrency, origin) reads it to decide whether to do *any* extra +work. Land it first so the rest can assume "if we got here, rules exist." It also +guarantees the headline property: **keys with no rules pay zero added latency.** + +**Build.** +- Solidity: extend the `canExecuteAction` read to also return `hasSpendingRules` + (decision 4 — just more return values). +- `accounts/blockchain_cache.rs`: cache the bit alongside the existing + `execute_action` / `execute_and_wallet` results (same generation-counter + invalidation, so flipping it bumps the generation and is picked up immediately). +- A request guard (sibling to `BilledLitActionApiKey` / `cpu_overload`) that reads + the cached bit and returns `Success` immediately when false. + +**Acceptance.** A key with no rules executes with no extra reads beyond today's +cached permission check; flipping the bit on-chain takes effect on the next request +(generation bump), no redeploy. + +--- + +### P0.1 — Per-key spend cap with auto-disable ⭐ highest leverage + +**What.** A usage key carries its own budget (e.g. credits or a cents cap over a +window). Each execution decrements it; when exhausted the key is rejected (and +flagged disabled), independent of the account's overall balance. + +**Why it's #1.** This is the single control that converts the failure mode from +"a leaked frontend key can spend the whole account" into "a leaked key can spend +*its* budget, then stops." It's the analog of an AWS per-function/per-budget cap. + +See [Architecture: where state lives](#architecture-where-state-lives--the-zero-latency-path) +for the storage split this builds on. The cap is a **rolling window** (decision 1): +a per-key spend-to-date counter that resets on the window boundary, checked against +a cap configured in the lit-payments DB, gated by the on-chain `hasSpendingRules` +flag (P0.0) so non-cap keys pay zero latency. + +**Build.** +- **On-chain:** the `hasSpendingRules` bit lands via P0.0 (fetched in the + `canExecuteAction` multicall). The existing no-op `balance` field on the key + (`add_usage_api_key` in `core/v1/models/request.rs`; hardcoded `10_000_000` in + `account_management.rs`, never read) can be repurposed as the flag or retired. +- **DB (lit-payments):** store the cap + window per key; track the per-key rolling + spend-to-date. New endpoints on the lit-payments backend to read/set them. +- **Hot path (`guards/billing.rs`):** when the flag is set, read the cached rules + + cached per-key spend (both in-memory; cold miss = one DB read, SWR) and reject + with **402** if the rolling spend would exceed the cap. Mark the key disabled for + the remainder of the window so subsequent calls short-circuit. +- **Async (off response path):** in the existing spawned settlement task that + already writes Stripe (`stripe::charge`), also increment the per-key rolling + spend in the DB and apply the optimistic in-memory decrement (mirror + `balance_cache`). Per-key spend reloads from the DB on cache miss (decision 3). + +**Acceptance.** A key with a $X/window cap, hammered in a loop, stops at ~$X spent +within the window and is rejected until the window rolls; the account's other keys +and overall balance are untouched; a key with no rules sees no added latency. + +--- + +### P0.2 — Per-key + per-IP rate limiting (rate + burst) + +**What.** Token-bucket throttle keyed on the API key, and a coarser one keyed on +client IP, on the `/core/v1/lit_action` path. Configurable rate + burst per key. + +**Why.** Today the only throttle is the **global** CPU-overload 429 — it protects +the node, not your wallet, and one abusive key can still spend fast within global +capacity. This is API Gateway's per-key/per-stage throttling. + +**Build.** +- A request guard (sits with the other `core/v1/guards/`) that runs before + execution, after key resolution. Reuse the existing 429 response plumbing from + CPL-202 so OpenAPI/docs stay consistent. +- Limits per key default to sane values, overridable per key (store next to + `balance`). +- Per-IP limit as a second bucket (defense against many anonymous callers on one + public key). + +**Acceptance.** A key exceeding its configured rate gets 429 with a `Retry-After`; +other keys unaffected; the global CPU guard still independently sheds load. + +**Decided.** Bucket store is **in-process, per-node** (decision in Architecture): +effective cluster rate ≈ limit × N nodes, acceptable for griefing control since +the spend cap (P0.1) is the durable backstop. Add a shared store (Redis-like) only +if cluster-exact limits are ever required. Buckets gated behind `hasSpendingRules` +(P0.0) so non-rule keys are untouched. + +--- + +### P1 — Per-key concurrency cap (reserved-concurrency analog) + +**What.** Cap simultaneous in-flight executions per key. + +**Why.** Rate limits bound requests/sec; a concurrency cap bounds *simultaneous* +spend and node load — the thing AWS reserved concurrency exists for. Cheap ins- +urance once P0.2 exists; together they tightly bound burn velocity. + +**Build.** A counter (incremented at execution start, decremented at end/timeout) +in the execution dispatch path (`actions/client/execution.rs` / the dispatch in +`lit-actions/server`), checked against the key's configured max. Over-cap → +429/503. + +**Acceptance.** A key with concurrency=N never has >N actions running at once; +the N+1th waits or is rejected per policy. + +--- + +### P2.1 — Per-key origin allowlist (replace wildcard CORS) + +**What.** Each key (esp. public ones) carries an allowed-origins list; the gateway +sets/validates CORS against it instead of today's wildcard. + +**Why.** Today CORS is `AllowedOrigins::all()` (`main.rs`). An origin allowlist is +standard hygiene for a browser-callable endpoint and stops casual cross-site +reuse. **Defense-in-depth only** — `Origin`/`Referer` are browser-enforced and +trivially spoofed by non-browser clients, so it rides *on top of* P0.1/P0.2, +never instead of them. + +**Build.** Add `allowed_origins` to the key model; per-request CORS reflection + +rejection in the Rocket CORS layer / a guard. Empty list = same-as-today (or +deny, for `public` keys — see P2.2). + +**Acceptance.** A public key configured for `app.example.com` rejects browser +calls from other origins; server-to-server calls still work (documented as not a +security boundary). + +--- + +### P2.2 — "Public / frontend-safe" key type + Dashboard guardrails + +**What.** A flag marking a key as intended for the browser. The Dashboard (and +API) then *require* the blast-radius controls to be set before issuing it: a spend +cap (P0.1), rate limits (P0.2), concurrency cap (P1), and an origin allowlist +(P2.1). + +**Why.** Makes the safe path the default path. Without this, someone ships an +unbounded key to the browser and we're back to square one. This is the DX glue +that makes the whole effort land. + +**Build.** A `public: bool` (or key class) on the key model; validation that +public keys have non-default caps; Dashboard UX that surfaces "this key is +frontend-safe; here's your max blast radius: $X/day, N rps, M concurrent." + +**Acceptance.** Creating a public key without caps is refused with a clear message; +the Dashboard shows the bounded worst case for a public key at a glance. + +--- + +### P3 — Optional hardening (post-parity) + +- **Alerting / budget alarms.** Spend-velocity alerts and a notification when a + key auto-disables (the CloudWatch-alarm analog). Parity = bounded worst case + **+ you find out**. +- **App-check / PoW / captcha** for *unauthenticated* public endpoints, to raise + the cost of scripted abuse before it hits the spend cap. +- **Per-IP WAF-style rules** (geo, known-bad ranges, anomaly) beyond the P0.2 + bucket. +- **Gateway authorizer hook** for teams that want auth enforced at the edge in + addition to in-action. + +## Suggested sequencing + +``` +P0.0 hasSpendingRules flag + multicall ← foundation; the zero-latency gate +P0.1 per-key spend cap (rolling) + auto-disable ← biggest blast-radius cut +P0.2 per-key + per-IP rate/burst ← parallel-able with P0.1, both gated by P0.0 + └─ P1 per-key concurrency cap ← small add once P0.2 lands +P2.1 per-key origin allowlist ← independent; defense-in-depth +P2.2 public key type + Dashboard caps ← depends on P0.0/P0.1/P0.2/P1/P2.1 existing +P3 alarms / app-check / WAF / authz ← post-parity hardening +``` + +After **P0.0 + P0.1 + P0.2 + P1 + P2.2**, a usage key is safe to ship in a frontend with +bounded, configurable blast radius — Lambda parity — and +[private-apps-backend.md](./private-apps-backend.md) can make its relay optional. + +## The honest caveat (same one AWS has) + +None of this makes a public endpoint un-griefable. A determined attacker can run +up cost *within the caps you set*. Parity is **bounded, configurable worst case + +alerting**, not zero risk. The caps (P0/P1) make the worst case a number you chose; +the alarms (P3) make sure you hear about it. That is exactly what AWS reserved +concurrency + Budgets + CloudWatch deliver — and what this plan brings to Chipotle. diff --git a/plans/private-apps-backend.md b/plans/private-apps-backend.md new file mode 100644 index 00000000..de610bed --- /dev/null +++ b/plans/private-apps-backend.md @@ -0,0 +1,416 @@ +# Private Apps on Lit — A Backend-as-Lit-Actions Framework + +Status: design proposal / RFC +Author: Chris (with Claude) +Date: 2026-06-04 + +## The pitch + +Let a developer host only a frontend (Vercel, Netlify, S3, IPFS — anywhere) and +have **every backend route run as a Lit Action**, with application data living in +Postgres where **row contents are encrypted at rest and only ever decrypted +inside the TEE**, on an as-needed basis. Index fields stay plaintext so the +database can still filter, sort, and join. The result: a "serverless backend" +where the people running the database and the relay **cannot read the data**, and +where the exact code that touches plaintext is auditable by its IPFS CID. + +This is the natural generalization of `examples/dark-pool/` — which already +proves the "encrypted Postgres + decrypt-and-compute-in-TEE" mechanic for one +narrow use case. This plan turns that mechanic into a reusable **framework** for +building arbitrary private CRUD apps. + +## The form-factor question (the honest answer up front) + +> "Is the best form factor a JS library people import into their web app and use +> as the backend instead of hosting a backend elsewhere?" + +**Almost — but a pure frontend-only library is not possible, and it's worth being +precise about why.** Running a Lit Action requires a **usage API key** that gates +execution at the gateway (see `examples/dark-pool/scripts/setup.js:217` +`createUsageApiKey`, scoped to `execute_in_groups`). That key is a secret. If you +ship it in the browser bundle, anyone can pull it and run your actions / drain +your account. So you cannot eliminate the server entirely. + +What you **can** do is shrink the server to a **stateless ~50-line relay** that: + +- holds the usage API key, +- maps a route name → an action CID, +- forwards `{ route, args, userAuth }` to `POST /core/v1/lit_action`, +- returns the response verbatim. + +The relay holds **no application logic, no database credentials, and never sees +plaintext**. All of that lives in Lit Actions and the TEE. So the *effective* +answer to the user is: "you write your backend as a library of route handlers, +and the only thing you deploy yourself is a trivial relay (or use ours)." + +**Therefore the recommended form factor is a framework, not a single library** — +made of four pieces: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 1. @lit/private-app in-action runtime (imported into the │ +│ action code via jsDelivr ESM): │ +│ router · encrypted-ORM · Neon client · │ +│ auth/session verification · decrypt- │ +│ DB-url helper │ +│ │ +│ 2. lit-app (CLI) bundle routes → action(s), compute │ +│ CIDs, mint vault PKP + group + usage │ +│ key, run migrations, write config, │ +│ deploy the relay. Wraps setup.js. │ +│ │ +│ 3. @lit/private-app/client frontend SDK: typed RPC to your routes │ +│ through the relay; user auth helpers │ +│ │ +│ 4. relay stateless edge function (Cloudflare / │ +│ Vercel) OR a tiny Express app — holds │ +│ the usage key, forwards calls │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +(Working name TBD; "Salsa" / "Burrito" fit the existing `chipotle` theme of the +API host `api.chipotle.litprotocol.com`. Use a placeholder `@lit/private-app` +below.) + +## Non-goals + +- **Replacing Postgres-the-database.** We use a SQL-over-HTTP provider (Neon, or + any HTTP-fronted Postgres). A Lit Action only has `fetch` — it cannot open a + raw TCP Postgres socket (see `examples/dark-pool/README.md:79`). +- **Full encrypted-query / homomorphic search.** Equality search on sensitive + columns is supported via blind indexes; range queries and full-text search on + encrypted columns are out of scope. +- **Hiding metadata.** Plaintext index columns, row counts, and timing are + visible to the DB operator — same caveat as the dark pool + (`examples/dark-pool/README.md:102` privacy table). We document this loudly, + we don't pretend to solve it. +- **A new persistence engine inside the TEE.** Actions stay stateless; all state + is in Postgres. + +## How a developer builds an app (target DX) + +The whole point is that this should *feel* like writing Next.js API routes or a +tiny Express app — the privacy is mechanical, not something the dev hand-rolls. + +### 1. Define models — declare which fields are encrypted vs. indexed + +```js +// app/models.js +import { defineModel, indexed, encrypted } from "@lit/private-app"; + +export const Note = defineModel("notes", { + id: indexed.uuid(), // plaintext PK + owner: indexed.address(), // plaintext — filterable / joinable + createdAt: indexed.timestamp(), // plaintext — sortable + title: encrypted.text(), // sealed at rest + body: encrypted.text(), // sealed at rest + tags: encrypted.json(), // sealed at rest + emailHash: indexed.blind(), // HMAC blind index — equality-searchable + // without exposing the email +}); +``` + +- `indexed.*` → a real plaintext column. You can `WHERE` / `ORDER BY` / `JOIN` + on it. The DB operator can read it. +- `encrypted.*` → folded into **one** `ciphertext` column per row (see Data + model below). Never queryable; only visible inside the TEE. +- `indexed.blind()` → stores `HMAC(secret, value)`. Lets you do exact-match + lookups (`where({ emailHash: blind(email) })`) on an otherwise-secret value + without ever storing it in the clear. The HMAC key is itself an encrypted + secret decrypted in-action. + +### 2. Write routes — plain async handlers + +```js +// app/routes/notes.js +import { route } from "@lit/private-app"; +import { Note } from "../models.js"; + +export const create = route(async (ctx, { title, body, tags }) => { + const user = await ctx.requireUser(); // verifies the session + return Note.insert({ + owner: user.address, title, body, tags, createdAt: ctx.now, + }); // auto-encrypts title/body/tags +}); + +export const list = route(async (ctx, { limit = 20, cursor }) => { + const user = await ctx.requireUser(); + // Filters on the PLAINTEXT `owner` index, paginates, then decrypts only the + // rows on this page — keeps decrypt count bounded (see Performance below). + return Note.where({ owner: user.address }) + .orderBy("createdAt", "desc") + .paginate({ limit, cursor }) + .all(); // auto-decrypts the page +}); + +export const remove = route(async (ctx, { id }) => { + const user = await ctx.requireUser(); + const note = await Note.find(id); + if (!note || note.owner !== user.address) throw ctx.forbidden(); + return Note.delete(id); +}); +``` + +### 3. Deploy + +```bash +npx lit-app deploy +``` + +The CLI (wrapping the `examples/dark-pool/scripts/setup.js` flow) does: + +1. Bundle each route file into Lit Action source (the `@lit/private-app` + in-action runtime is inlined via the existing SWC bundler / jsDelivr imports). +2. Compute each action's CID (`get_lit_action_ipfs_id`). +3. Mint the **vault PKP** (encrypts row contents + the DB URL + the blind-index + HMAC key) — `create_wallet`. +4. Create a **group**, add the PKP, **pin every route's CID** (no wildcard — + `cid_hashes_permitted: []` then explicit `add_action_to_group`, exactly as + dark-pool does at `setup.js:99-104`). +5. Mint a **usage API key** scoped to `execute_in_groups: [groupId]`. +6. Encrypt `DATABASE_URL` (and the HMAC key) against the vault PKP; store only + ciphertext. +7. Run migrations to create the plaintext index columns + the `ciphertext` + column for each model. +8. Write `.lit-app/config.json` (route → CID map, PKP id, encrypted DB url, + group id) and deploy/print the relay with the usage key as its only secret. + +### 4. Call it from the frontend + +```js +// web/api.js +import { createClient } from "@lit/private-app/client"; +export const api = createClient({ url: "/api" }); // points at the relay + +// anywhere in the app: +await api.notes.create({ title: "secret", body: "...", tags: ["x"] }); +const page = await api.notes.list({ limit: 20 }); +``` + +`api.notes.create` → `POST /api` `{ route: "notes.create", args, auth }` → relay +adds the usage key → `/core/v1/lit_action` with `js_params` → the `notes.create` +action runs in the TEE → returns the result. + +## Architecture + +``` + browser (frontend only) relay (stateless, ~50 LOC) + ┌────────────────────┐ POST /api ┌───────────────────────┐ + │ @lit/private-app/ │ {route,args,auth} │ holds usage API key │ + │ client ├─────────────────────►│ route → CID lookup │ + │ - typed RPC │ │ injects api key │ + │ - user auth (sign │ └──────────┬────────────┘ + │ in w/ wallet/JWT)│ │ POST /core/v1/lit_action + └────────────────────┘ │ {code|ipfs_id, js_params} + ▼ + ┌──────────────────────────────┐ + │ TEE: Lit Action (one per │ + │ route, pinned CID) │ + │ @lit/private-app runtime: │ + │ 1. verify ctx.user (auth) │ + │ 2. Decrypt(DB url) │ + │ 3. query Neon over HTTPS │ + │ (filter on plaintext idx) │ + │ 4. Encrypt on write / │ + │ Decrypt the page on read │ + │ 5. return result (no secrets)│ + └───────────────┬──────────────┘ + │ SQL over HTTPS + ▼ + ┌──────────────────────────────┐ + │ Neon Postgres │ + │ idx cols: plaintext │ + │ ciphertext col: opaque blob │ + └──────────────────────────────┘ +``` + +The relay operator and the DB operator each see only ciphertext + plaintext index +metadata. Plaintext app data exists **only** in TEE memory during a call. + +## Data model: one ciphertext per row + +The single most important design decision, driven by Lit's limits (below): +**bundle all `encrypted.*` columns of a row into one JSON blob and seal it as a +single ciphertext column.** Not one ciphertext per field. + +```sql +CREATE TABLE notes ( + id uuid PRIMARY KEY, + owner text NOT NULL, -- indexed.address + created_at timestamptz NOT NULL, -- indexed.timestamp + email_hash bytea, -- indexed.blind (HMAC) + ciphertext text NOT NULL -- Encrypt(JSON{title, body, tags}) +); +CREATE INDEX ON notes (owner, created_at DESC); +``` + +- **Write** = 1 `Lit.Actions.Encrypt` call per row. +- **Read** = 1 `Lit.Actions.Decrypt` call per row returned. + +This keeps the count of cryptographic remote ops equal to *rows touched*, not +*rows × encrypted-columns*. The ORM hides the (de)serialization; the dev just +sees fields. + +Cost of this choice: rotating or selectively disclosing a single encrypted field +means re-encrypting the whole row blob (acceptable for app data; documented). + +## Auth & per-user isolation + +Two layers, both needed: + +1. **Gateway** — the relay's usage key is required to run any action at all. + Random internet traffic without the relay can't execute routes. +2. **In-action user identity** — `ctx.requireUser()` verifies an end-user + credential *inside the action*. Options the runtime supports: + - **Wallet signature**: client signs a session challenge (nonce + deadline); + the action recovers it with `ethers.utils.verifyMessage` and exposes + `ctx.user.address` (the pattern in `docs/lit-actions/patterns.mdx:231` and + `examples/dark-pool` order auth). Also available: `Lit.Auth.authSigAddress`. + - **App JWT / session token**: action `fetch`es the app's auth endpoint to + verify, à la `docs/lit-actions/patterns.mdx:318`. + +Row ownership is then enforced in the handler (`note.owner === ctx.user.address`) +— the gateway proves *a* legitimate caller; the in-action check proves *which* +user and what they may touch. This mirrors dark-pool's "the usage key lets you +run the action, but per-record authority comes from a signature verified in the +enclave." + +**Stronger isolation (optional, phase 4+):** a **vault PKP per user** so each +user's rows are encrypted under a distinct key (`docs/lit-actions/patterns.mdx:291` +shows `user-alice-data-vault`). This gives crypto-level blast-radius isolation but +multiplies key/group management and runs into PKP-per-user scale; default to +**one app vault PKP** and gate by `owner`, offer per-user vaults as an advanced +mode. + +## Performance & limits (the real constraints) + +From `docs/lit-actions/limits.mdx`: + +| Limit | Default | Implication for the framework | +|---|---|---| +| Execution time | 15 min | fine for CRUD | +| Memory | 64 MB | bounds page size / payload | +| Outbound HTTP per action | **50** | each Neon query is 1 fetch; budget queries per route | +| Response payload | **1 MB** | hard cap on a page of decrypted rows | +| Console log | 100 KB | never log plaintext anyway | +| **Key/signature requests per action** | **10** | ⚠️ **see open question** | + +Two things shape the ORM: + +1. **Decrypt is a remote op.** Each `Decrypt`/`Encrypt` is a gRPC round-trip to + the node (`lit-actions/ext/bindings.rs`), and recent work explicitly targets + this cost (`a8a69edd perf: avoid deferred Lit Action remote ops`). So: filter + and paginate on **plaintext index columns first**, then decrypt only the rows + you actually return. Never "decrypt the table to filter it." + +2. **The per-action key/signature cap.** Docs say **10**. But `matchEpoch.js` + decrypts the DB URL **plus every order in a batch (up to 200)** in a single + action and works — so either `Decrypt` against a PKP-derived symmetric key + does **not** count toward that cap, or the cap is raised per account. **This is + load-bearing and must be confirmed** (see Open questions). The framework should + (a) minimize decrypts regardless, and (b) expose a `maxPageSize` the CLI can + tune to the account's real cap; if the cap genuinely binds at ~10, the default + page size shrinks and large lists paginate. + +Other notes: +- **No connection pooling** (stateless actions) — Neon HTTP is per-call; this is + inherent and acceptable for Neon's serverless driver. +- **Cold start** — first call to a freshly-bundled action pays bundle/CID + resolution; warm thereafter (the API server LRU-caches code by CID). + +## Security & trust model (state it plainly, like dark-pool does) + +- **You trust the TEE.** Plaintext exists in enclave memory during a call. + TEEs have known side-channel attacks (`examples/dark-pool/README.md:170`). +- **You trust the pinned action CIDs.** A permitted action *can* exfiltrate + plaintext if its code chooses to (`docs/lit-actions/secrets.mdx:18`). The + framework's value is that the in-action runtime is open and the deployed CIDs + are auditable — `lit-app deploy` prints them, and you can diff a CID against + the published `@lit/private-app` version. **The CLI must make "what code is + pinned" trivially verifiable**, or the privacy claim is hand-wavy. +- **Metadata leaks.** Index columns, row counts, and timing are visible to the + DB operator. `indexed.blind()` mitigates *value* exposure for equality columns + but not existence/count/timing. Document per-model what's plaintext. +- **The relay is untrusted for confidentiality** (sees only ciphertext-bound + traffic) but **is trusted for availability and rate-limiting** (it holds the + usage key; if compromised, an attacker can run your actions / burn your + account quota, but cannot read data). Recommend the relay also rate-limits and + optionally checks the user credential cheaply before forwarding. + +## Where this lives / build order + +Mirror how `dark-pool` proved the mechanic before generalizing: + +- **Phase 0 — Reference app (validates the whole pattern end-to-end).** + Add `examples/private-app-starter/` — a runnable private notes (or chat) app: + models, a few routes, the setup script (forked from dark-pool's), a tiny relay, + and a minimal frontend. No framework abstraction yet; hand-written. Proves + encrypt-on-write / decrypt-the-page / plaintext-index-filter / wallet-auth all + work together and surfaces the real decrypt-count behavior. + +- **Phase 1 — `@lit/private-app` in-action runtime.** + Extract from Phase 0: `defineModel`, `indexed`/`encrypted`/`blind` field types, + the query builder (`where/orderBy/paginate/insert/find/delete`), the Neon HTTP + client (lifted from `matchEpoch.js:253`), the row encrypt/decrypt codec, the + `route()` wrapper + `ctx` (`requireUser`, `now`, `forbidden`). Lives under + `lit-actions/packages/` next to `naga-la-types`. Ship TS types. + +- **Phase 2 — `lit-app` CLI.** + Generalize `dark-pool/scripts/setup.js` into a project-aware tool: discover + models + routes, bundle, compute CIDs, mint PKP/group/usage key, pin CIDs, run + migrations, write `.lit-app/config.json`, print/deploy the relay. Add + `lit-app migrate`, `lit-app verify` (re-derive and diff pinned CIDs), and + `lit-app dev` (local loop). + +- **Phase 3 — client SDK + relay templates.** + `@lit/private-app/client` (`createClient`, typed route proxies, wallet/JWT + sign-in). Relay templates for Cloudflare Workers, Vercel Edge, and a tiny + Express app (this repo is literally `lit-node-express` — an Express template is + the natural reference). + +- **Phase 4 — hardening / advanced.** + Per-user vault PKPs; blind-index range tricks; migration/rotation tooling; a + `lit-triggers`-driven background-job story (cron/webhook routes, since triggers + already exist in `examples/lit-triggers/`); request-level rate limiting in the + relay. + +## Open questions (resolve before/within Phase 0) + +1. **Does `Lit.Actions.Decrypt` count toward the per-action 10 key/signature + cap?** `matchEpoch.js` decrypting ~200 rows says no (or the cap is raised). + This single answer sets the default `maxPageSize` and whether large reads must + chunk across multiple action calls. Confirm with the runtime/limits owner; + measure in Phase 0. +2. **One mega-router action vs. one action per route.** Per-route CIDs give + finer audit + permission granularity (you can pin/unpin a single route) but + more CIDs to manage and re-pin on change; a single router action is simpler + but any route edit re-CIDs the whole backend. Lean **per-route** for + auditability; let the CLI bundle small apps into one action as an option. +3. **Blind-index HMAC key custody.** Store it as an encrypted secret under the + vault PKP (decrypted in-action) — confirm that's acceptable vs. a separate + vault, and define rotation (rotating re-derives every blind index → a + migration job). +4. **Migrations against an encrypted column.** Adding/removing an `encrypted.*` + field changes the row-blob shape. Define a backfill/migration story (lazy + re-encrypt on next write vs. a one-shot migration action that pages through + and re-seals rows — itself bounded by the decrypt cap). +5. **Where does user-auth verification belong** — purely in-action (max trust, + every call pays a `fetch` to the auth service) vs. partly in the relay (cheap + pre-check, but the relay becomes slightly trusted)? Default in-action; + document the relay pre-check as an optimization. +6. **Multi-tenant relay vs. self-hosted.** Offer a hosted relay (Lit-run) so devs + truly deploy "frontend only," with self-hosting as the escape hatch. Decide if + the hosted relay is in scope. + +## TL;DR recommendation + +Ship a **framework** (`@lit/private-app` in-action runtime + `lit-app` CLI + +client SDK + a stateless relay template), not a single browser library — because +the usage API key forces a thin server, but that server can be a logic-free, +plaintext-blind relay. Model data as **plaintext index columns + one sealed +ciphertext blob per row**, filter/paginate on the indexes and decrypt only the +returned page, and gate every route with a gateway usage key *plus* an in-action +user-identity check. Prove it first as `examples/private-app-starter/` (a private +notes app), then extract the framework. The dark pool already demonstrates every +primitive this needs; this plan is mostly about packaging them into a DX a +developer can adopt in an afternoon.