From fcd5819b2722f054ef6cb62e2b8a1301bff04971 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Mon, 13 Oct 2025 00:51:08 +0300 Subject: [PATCH 01/13] feat: add budget limits on api-keys service --- .../20251002120000_add_spending_limits.sql | 70 ++ core/api-keys/src/app/mod.rs | 4 + core/api-keys/src/graphql/schema.rs | 345 +++++++++- core/api-keys/src/lib.rs | 1 + core/api-keys/src/limits/error.rs | 16 + core/api-keys/src/limits/mod.rs | 649 ++++++++++++++++++ core/api-keys/src/server/mod.rs | 203 +++++- core/api-keys/subgraph/schema.graphql | 68 ++ .../apollo-federation/supergraph.graphql | 80 +++ 9 files changed, 1433 insertions(+), 3 deletions(-) create mode 100644 core/api-keys/migrations/20251002120000_add_spending_limits.sql create mode 100644 core/api-keys/src/limits/error.rs create mode 100644 core/api-keys/src/limits/mod.rs diff --git a/core/api-keys/migrations/20251002120000_add_spending_limits.sql b/core/api-keys/migrations/20251002120000_add_spending_limits.sql new file mode 100644 index 0000000000..2ae4528ce1 --- /dev/null +++ b/core/api-keys/migrations/20251002120000_add_spending_limits.sql @@ -0,0 +1,70 @@ +-- Add spending limits feature for API keys (rolling 24-hour window) +-- Limits are optional per API key and measured in satoshis +-- If no limit is configured for an API key, it has no spending restrictions (unlimited) + +-- Table 1: Optional limit configuration per API key +CREATE TABLE api_key_limits ( + api_key_id UUID PRIMARY KEY REFERENCES identity_api_keys(id) ON DELETE CASCADE, + daily_limit_sats BIGINT, + weekly_limit_sats BIGINT, + monthly_limit_sats BIGINT, + annual_limit_sats BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT positive_daily_limit CHECK (daily_limit_sats IS NULL OR daily_limit_sats > 0), + CONSTRAINT positive_weekly_limit CHECK (weekly_limit_sats IS NULL OR weekly_limit_sats > 0), + CONSTRAINT positive_monthly_limit CHECK (monthly_limit_sats IS NULL OR monthly_limit_sats > 0), + CONSTRAINT positive_annual_limit CHECK (annual_limit_sats IS NULL OR annual_limit_sats > 0), + CONSTRAINT at_least_one_limit CHECK ( + daily_limit_sats IS NOT NULL OR + weekly_limit_sats IS NOT NULL OR + monthly_limit_sats IS NOT NULL OR + annual_limit_sats IS NOT NULL + ) +); + +COMMENT ON TABLE api_key_limits IS 'Optional spending limits per API key (rolling windows, in satoshis). If no row exists for an API key, it has no limit. Each limit is independent.'; +COMMENT ON COLUMN api_key_limits.daily_limit_sats IS 'Maximum spending per rolling 24 hours in satoshis (e.g., 100000000 = 1 BTC)'; +COMMENT ON COLUMN api_key_limits.weekly_limit_sats IS 'Maximum spending per rolling 7 days in satoshis'; +COMMENT ON COLUMN api_key_limits.monthly_limit_sats IS 'Maximum spending per rolling 30 days in satoshis'; +COMMENT ON COLUMN api_key_limits.annual_limit_sats IS 'Maximum spending per rolling 365 days in satoshis'; + +-- Table 2: Individual transaction records for rolling 24h calculation +CREATE TABLE api_key_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + api_key_id UUID NOT NULL REFERENCES identity_api_keys(id) ON DELETE CASCADE, + amount_sats BIGINT NOT NULL, + transaction_id VARCHAR, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT positive_amount CHECK (amount_sats > 0) +); + +COMMENT ON TABLE api_key_transactions IS 'Individual transaction records for rolling window limit calculations. Records older than 400 days are periodically cleaned up.'; +COMMENT ON COLUMN api_key_transactions.amount_sats IS 'Transaction amount in satoshis'; +COMMENT ON COLUMN api_key_transactions.transaction_id IS 'Optional reference to the transaction ID from the main ledger'; + +-- Critical index for rolling window queries (WHERE created_at > NOW() - INTERVAL '24 hours') +CREATE INDEX idx_api_key_tx_window + ON api_key_transactions(api_key_id, created_at DESC); + +-- Index for cleanup job (delete transactions older than 48 hours) +-- Simple index on created_at for efficient cleanup queries +CREATE INDEX idx_api_key_tx_cleanup + ON api_key_transactions(created_at); + +-- Function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to automatically update updated_at on api_key_limits +CREATE TRIGGER update_api_key_limits_updated_at + BEFORE UPDATE ON api_key_limits + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/core/api-keys/src/app/mod.rs b/core/api-keys/src/app/mod.rs index 9987026838..c659a29941 100644 --- a/core/api-keys/src/app/mod.rs +++ b/core/api-keys/src/app/mod.rs @@ -79,4 +79,8 @@ impl ApiKeysApp { ) -> Result { Ok(self.identities.revoke_api_key(subject, key_id).await?) } + + pub fn pool(&self) -> Pool { + self.pool.clone() + } } diff --git a/core/api-keys/src/graphql/schema.rs b/core/api-keys/src/graphql/schema.rs index 735c50da25..268719d790 100644 --- a/core/api-keys/src/graphql/schema.rs +++ b/core/api-keys/src/graphql/schema.rs @@ -1,7 +1,7 @@ use async_graphql::*; use chrono::{DateTime, TimeZone, Utc}; -use crate::{app::ApiKeysApp, identity::IdentityApiKeyId, scope::*}; +use crate::{app::ApiKeysApp, identity::IdentityApiKeyId, limits::Limits, scope::*}; pub struct AuthSubject { pub id: String, @@ -53,6 +53,7 @@ impl Query { } #[derive(SimpleObject)] +#[graphql(complex)] pub(super) struct ApiKey { pub id: ID, pub name: String, @@ -65,6 +66,89 @@ pub(super) struct ApiKey { pub scopes: Vec, } +#[ComplexObject] +impl ApiKey { + /// Daily spending limit in satoshis (rolling 24h window). Returns null if no limit is set. + async fn daily_limit_sats(&self, ctx: &Context<'_>) -> Result> { + let app = ctx.data_unchecked::(); + let limits = Limits::new(app.pool()); + let api_key_id = self.id.parse::()?; + + let summary = limits.get_spending_summary(api_key_id).await?; + Ok(summary.daily_limit_sats) + } + + /// Weekly spending limit in satoshis (rolling 7 days). Returns null if no limit is set. + async fn weekly_limit_sats(&self, ctx: &Context<'_>) -> Result> { + let app = ctx.data_unchecked::(); + let limits = Limits::new(app.pool()); + let api_key_id = self.id.parse::()?; + + let summary = limits.get_spending_summary(api_key_id).await?; + Ok(summary.weekly_limit_sats) + } + + /// Monthly spending limit in satoshis (rolling 30 days). Returns null if no limit is set. + async fn monthly_limit_sats(&self, ctx: &Context<'_>) -> Result> { + let app = ctx.data_unchecked::(); + let limits = Limits::new(app.pool()); + let api_key_id = self.id.parse::()?; + + let summary = limits.get_spending_summary(api_key_id).await?; + Ok(summary.monthly_limit_sats) + } + + /// Annual spending limit in satoshis (rolling 365 days). Returns null if no limit is set. + async fn annual_limit_sats(&self, ctx: &Context<'_>) -> Result> { + let app = ctx.data_unchecked::(); + let limits = Limits::new(app.pool()); + let api_key_id = self.id.parse::()?; + + let summary = limits.get_spending_summary(api_key_id).await?; + Ok(summary.annual_limit_sats) + } + + /// Amount spent in the last 24 hours (rolling window) in satoshis + async fn spent_last_24h_sats(&self, ctx: &Context<'_>) -> Result { + let app = ctx.data_unchecked::(); + let limits = Limits::new(app.pool()); + let api_key_id = self.id.parse::()?; + + let summary = limits.get_spending_summary(api_key_id).await?; + Ok(summary.spent_last_24h_sats) + } + + /// Amount spent in the last 7 days (rolling window) in satoshis + async fn spent_last_7d_sats(&self, ctx: &Context<'_>) -> Result { + let app = ctx.data_unchecked::(); + let limits = Limits::new(app.pool()); + let api_key_id = self.id.parse::()?; + + let summary = limits.get_spending_summary(api_key_id).await?; + Ok(summary.spent_last_7d_sats) + } + + /// Amount spent in the last 30 days (rolling window) in satoshis + async fn spent_last_30d_sats(&self, ctx: &Context<'_>) -> Result { + let app = ctx.data_unchecked::(); + let limits = Limits::new(app.pool()); + let api_key_id = self.id.parse::()?; + + let summary = limits.get_spending_summary(api_key_id).await?; + Ok(summary.spent_last_30d_sats) + } + + /// Amount spent in the last 365 days (rolling window) in satoshis + async fn spent_last_365d_sats(&self, ctx: &Context<'_>) -> Result { + let app = ctx.data_unchecked::(); + let limits = Limits::new(app.pool()); + let api_key_id = self.id.parse::()?; + + let summary = limits.get_spending_summary(api_key_id).await?; + Ok(summary.spent_last_365d_sats) + } +} + #[derive(SimpleObject)] #[graphql(extends)] #[graphql(complex)] @@ -116,6 +200,40 @@ struct ApiKeyRevokeInput { id: ID, } +#[derive(InputObject)] +struct ApiKeySetDailyLimitInput { + id: ID, + daily_limit_sats: i64, +} + +#[derive(InputObject)] +struct ApiKeySetWeeklyLimitInput { + id: ID, + weekly_limit_sats: i64, +} + +#[derive(InputObject)] +struct ApiKeySetMonthlyLimitInput { + id: ID, + monthly_limit_sats: i64, +} + +#[derive(InputObject)] +struct ApiKeySetAnnualLimitInput { + id: ID, + annual_limit_sats: i64, +} + +#[derive(SimpleObject)] +pub(super) struct ApiKeySetLimitPayload { + pub api_key: ApiKey, +} + +#[derive(InputObject)] +struct ApiKeyRemoveLimitInput { + id: ID, +} + #[Object] impl Mutation { async fn api_key_create( @@ -147,4 +265,229 @@ impl Mutation { .await?; Ok(ApiKeyRevokePayload::from(api_key)) } + + async fn api_key_set_daily_limit( + &self, + ctx: &Context<'_>, + input: ApiKeySetDailyLimitInput, + ) -> async_graphql::Result { + let app = ctx.data_unchecked::(); + let subject = ctx.data::()?; + if !subject.can_write { + return Err("Permission denied".into()); + } + + let api_key_id = input.id.parse::()?; + let limits = Limits::new(app.pool()); + + // Verify the API key belongs to the subject + let api_keys = app.list_api_keys_for_subject(&subject.id).await?; + let api_key = api_keys + .into_iter() + .find(|k| k.id == api_key_id) + .ok_or("API key not found")?; + + limits + .set_daily_limit(api_key_id, input.daily_limit_sats) + .await?; + + Ok(ApiKeySetLimitPayload { + api_key: ApiKey::from(api_key), + }) + } + + async fn api_key_set_weekly_limit( + &self, + ctx: &Context<'_>, + input: ApiKeySetWeeklyLimitInput, + ) -> async_graphql::Result { + let app = ctx.data_unchecked::(); + let subject = ctx.data::()?; + if !subject.can_write { + return Err("Permission denied".into()); + } + + let api_key_id = input.id.parse::()?; + let limits = Limits::new(app.pool()); + + let api_keys = app.list_api_keys_for_subject(&subject.id).await?; + let api_key = api_keys + .into_iter() + .find(|k| k.id == api_key_id) + .ok_or("API key not found")?; + + limits + .set_weekly_limit(api_key_id, input.weekly_limit_sats) + .await?; + + Ok(ApiKeySetLimitPayload { + api_key: ApiKey::from(api_key), + }) + } + + async fn api_key_set_monthly_limit( + &self, + ctx: &Context<'_>, + input: ApiKeySetMonthlyLimitInput, + ) -> async_graphql::Result { + let app = ctx.data_unchecked::(); + let subject = ctx.data::()?; + if !subject.can_write { + return Err("Permission denied".into()); + } + + let api_key_id = input.id.parse::()?; + let limits = Limits::new(app.pool()); + + let api_keys = app.list_api_keys_for_subject(&subject.id).await?; + let api_key = api_keys + .into_iter() + .find(|k| k.id == api_key_id) + .ok_or("API key not found")?; + + limits + .set_monthly_limit(api_key_id, input.monthly_limit_sats) + .await?; + + Ok(ApiKeySetLimitPayload { + api_key: ApiKey::from(api_key), + }) + } + + async fn api_key_set_annual_limit( + &self, + ctx: &Context<'_>, + input: ApiKeySetAnnualLimitInput, + ) -> async_graphql::Result { + let app = ctx.data_unchecked::(); + let subject = ctx.data::()?; + if !subject.can_write { + return Err("Permission denied".into()); + } + + let api_key_id = input.id.parse::()?; + let limits = Limits::new(app.pool()); + + let api_keys = app.list_api_keys_for_subject(&subject.id).await?; + let api_key = api_keys + .into_iter() + .find(|k| k.id == api_key_id) + .ok_or("API key not found")?; + + limits + .set_annual_limit(api_key_id, input.annual_limit_sats) + .await?; + + Ok(ApiKeySetLimitPayload { + api_key: ApiKey::from(api_key), + }) + } + + async fn api_key_remove_daily_limit( + &self, + ctx: &Context<'_>, + input: ApiKeyRemoveLimitInput, + ) -> async_graphql::Result { + let app = ctx.data_unchecked::(); + let subject = ctx.data::()?; + if !subject.can_write { + return Err("Permission denied".into()); + } + + let api_key_id = input.id.parse::()?; + let limits = Limits::new(app.pool()); + + let api_keys = app.list_api_keys_for_subject(&subject.id).await?; + let api_key = api_keys + .into_iter() + .find(|k| k.id == api_key_id) + .ok_or("API key not found")?; + + limits.remove_daily_limit(api_key_id).await?; + + Ok(ApiKeySetLimitPayload { + api_key: ApiKey::from(api_key), + }) + } + + async fn api_key_remove_weekly_limit( + &self, + ctx: &Context<'_>, + input: ApiKeyRemoveLimitInput, + ) -> async_graphql::Result { + let app = ctx.data_unchecked::(); + let subject = ctx.data::()?; + if !subject.can_write { + return Err("Permission denied".into()); + } + + let api_key_id = input.id.parse::()?; + let limits = Limits::new(app.pool()); + + let api_keys = app.list_api_keys_for_subject(&subject.id).await?; + let api_key = api_keys + .into_iter() + .find(|k| k.id == api_key_id) + .ok_or("API key not found")?; + + limits.remove_weekly_limit(api_key_id).await?; + + Ok(ApiKeySetLimitPayload { + api_key: ApiKey::from(api_key), + }) + } + + async fn api_key_remove_monthly_limit( + &self, + ctx: &Context<'_>, + input: ApiKeyRemoveLimitInput, + ) -> async_graphql::Result { + let app = ctx.data_unchecked::(); + let subject = ctx.data::()?; + if !subject.can_write { + return Err("Permission denied".into()); + } + + let api_key_id = input.id.parse::()?; + let limits = Limits::new(app.pool()); + + let api_keys = app.list_api_keys_for_subject(&subject.id).await?; + let api_key = api_keys + .into_iter() + .find(|k| k.id == api_key_id) + .ok_or("API key not found")?; + + limits.remove_monthly_limit(api_key_id).await?; + + Ok(ApiKeySetLimitPayload { + api_key: ApiKey::from(api_key), + }) + } + + async fn api_key_remove_annual_limit( + &self, + ctx: &Context<'_>, + input: ApiKeyRemoveLimitInput, + ) -> async_graphql::Result { + let app = ctx.data_unchecked::(); + let subject = ctx.data::()?; + if !subject.can_write { + return Err("Permission denied".into()); + } + + let api_key_id = input.id.parse::()?; + let limits = Limits::new(app.pool()); + + let api_keys = app.list_api_keys_for_subject(&subject.id).await?; + let api_key = api_keys + .into_iter() + .find(|k| k.id == api_key_id) + .ok_or("API key not found")?; + + limits.remove_annual_limit(api_key_id).await?; + + Ok(ApiKeySetLimitPayload { + api_key: ApiKey::from(api_key), + }) + } } diff --git a/core/api-keys/src/lib.rs b/core/api-keys/src/lib.rs index 3429322a92..0b4ef2d407 100644 --- a/core/api-keys/src/lib.rs +++ b/core/api-keys/src/lib.rs @@ -5,5 +5,6 @@ pub mod app; pub mod cli; pub mod graphql; pub mod identity; +pub mod limits; pub mod scope; pub mod server; diff --git a/core/api-keys/src/limits/error.rs b/core/api-keys/src/limits/error.rs new file mode 100644 index 0000000000..39c68fe29d --- /dev/null +++ b/core/api-keys/src/limits/error.rs @@ -0,0 +1,16 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum LimitError { + #[error("Database error: {0}")] + Sqlx(#[from] sqlx::Error), + + #[error("Negative amount not allowed")] + NegativeAmount, + + #[error("Amount must be positive")] + NonPositiveAmount, + + #[error("Invalid limit value (must be positive)")] + InvalidLimit, +} diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs new file mode 100644 index 0000000000..e408a065b3 --- /dev/null +++ b/core/api-keys/src/limits/mod.rs @@ -0,0 +1,649 @@ +mod error; + +use sqlx::{Pool, Postgres, Row}; + +use crate::identity::IdentityApiKeyId; + +pub use error::*; + +// No default limit - API keys without explicit limits are unlimited + +#[derive(Debug, Clone)] +pub struct LimitCheckResult { + pub allowed: bool, + pub daily_limit_sats: Option, // None if no limit configured + pub weekly_limit_sats: Option, // None if no limit configured + pub monthly_limit_sats: Option, // None if no limit configured + pub annual_limit_sats: Option, // None if no limit configured + pub spent_last_24h_sats: i64, + pub spent_last_7d_sats: i64, + pub spent_last_30d_sats: i64, + pub spent_last_365d_sats: i64, +} + +#[derive(Debug, Clone)] +pub struct SpendingSummary { + pub daily_limit_sats: Option, // None if no limit configured + pub weekly_limit_sats: Option, // None if no limit configured + pub monthly_limit_sats: Option, // None if no limit configured + pub annual_limit_sats: Option, // None if no limit configured + pub spent_last_24h_sats: i64, + pub spent_last_7d_sats: i64, + pub spent_last_30d_sats: i64, + pub spent_last_365d_sats: i64, + pub transaction_count_24h: i32, + pub transaction_count_7d: i32, + pub transaction_count_30d: i32, + pub transaction_count_365d: i32, +} + +#[derive(Debug, Clone)] +struct AllLimits { + daily_limit_sats: Option, + weekly_limit_sats: Option, + monthly_limit_sats: Option, + annual_limit_sats: Option, +} + +pub struct Limits { + pool: Pool, +} + +impl Limits { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + /// Check if a spending amount would exceed any configured limits + /// If no limits are configured for the API key, returns allowed=true with all limits=None + #[tracing::instrument(name = "limits.check_spending_limit", skip(self))] + pub async fn check_spending_limit( + &self, + api_key_id: IdentityApiKeyId, + amount_sats: i64, + ) -> Result { + if amount_sats < 0 { + return Err(LimitError::NegativeAmount); + } + + // Get all configured limits for this API key + let limits = self.get_all_limits(api_key_id).await?; + + // If no limits configured, allow unlimited + if limits.daily_limit_sats.is_none() + && limits.weekly_limit_sats.is_none() + && limits.monthly_limit_sats.is_none() + && limits.annual_limit_sats.is_none() + { + return Ok(LimitCheckResult { + allowed: true, + daily_limit_sats: None, + weekly_limit_sats: None, + monthly_limit_sats: None, + annual_limit_sats: None, + spent_last_24h_sats: 0, + spent_last_7d_sats: 0, + spent_last_30d_sats: 0, + spent_last_365d_sats: 0, + }); + } + + // Calculate spent amounts for all windows + let spent_24h = self.get_spending_last_24h(api_key_id).await?; + let spent_7d = self.get_spending_last_7d(api_key_id).await?; + let spent_30d = self.get_spending_last_30d(api_key_id).await?; + let spent_365d = self.get_spending_last_365d(api_key_id).await?; + + // Check each configured limit + let mut allowed = true; + + if let Some(limit) = limits.daily_limit_sats { + if limit - spent_24h < amount_sats { + allowed = false; + } + } + + if let Some(limit) = limits.weekly_limit_sats { + if limit - spent_7d < amount_sats { + allowed = false; + } + } + + if let Some(limit) = limits.monthly_limit_sats { + if limit - spent_30d < amount_sats { + allowed = false; + } + } + + if let Some(limit) = limits.annual_limit_sats { + if limit - spent_365d < amount_sats { + allowed = false; + } + } + + Ok(LimitCheckResult { + allowed, + daily_limit_sats: limits.daily_limit_sats, + weekly_limit_sats: limits.weekly_limit_sats, + monthly_limit_sats: limits.monthly_limit_sats, + annual_limit_sats: limits.annual_limit_sats, + spent_last_24h_sats: spent_24h, + spent_last_7d_sats: spent_7d, + spent_last_30d_sats: spent_30d, + spent_last_365d_sats: spent_365d, + }) + } + + /// Record a transaction for an API key + /// Inserts a new record into api_key_transactions table + #[tracing::instrument(name = "limits.record_spending", skip(self))] + pub async fn record_spending( + &self, + api_key_id: IdentityApiKeyId, + amount_sats: i64, + transaction_id: Option, + ) -> Result<(), LimitError> { + if amount_sats <= 0 { + return Err(LimitError::NonPositiveAmount); + } + + sqlx::query( + r#" + INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at) + VALUES ($1, $2, $3, NOW()) + "#, + ) + .bind(api_key_id) + .bind(amount_sats) + .bind(transaction_id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Get spending summary for an API key (for GraphQL queries) + #[tracing::instrument(name = "limits.get_spending_summary", skip(self))] + pub async fn get_spending_summary( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let limits = self.get_all_limits(api_key_id).await?; + let spent_24h = self.get_spending_last_24h(api_key_id).await?; + let spent_7d = self.get_spending_last_7d(api_key_id).await?; + let spent_30d = self.get_spending_last_30d(api_key_id).await?; + let spent_365d = self.get_spending_last_365d(api_key_id).await?; + + let transaction_count_24h = self.get_transaction_count_last_24h(api_key_id).await?; + let transaction_count_7d = self.get_transaction_count_last_7d(api_key_id).await?; + let transaction_count_30d = self.get_transaction_count_last_30d(api_key_id).await?; + let transaction_count_365d = self.get_transaction_count_last_365d(api_key_id).await?; + + Ok(SpendingSummary { + daily_limit_sats: limits.daily_limit_sats, + weekly_limit_sats: limits.weekly_limit_sats, + monthly_limit_sats: limits.monthly_limit_sats, + annual_limit_sats: limits.annual_limit_sats, + spent_last_24h_sats: spent_24h, + spent_last_7d_sats: spent_7d, + spent_last_30d_sats: spent_30d, + spent_last_365d_sats: spent_365d, + transaction_count_24h, + transaction_count_7d, + transaction_count_30d, + transaction_count_365d, + }) + } + + /// Set a daily limit for an API key (in satoshis) + #[tracing::instrument(name = "limits.set_daily_limit", skip(self))] + pub async fn set_daily_limit( + &self, + api_key_id: IdentityApiKeyId, + daily_limit_sats: i64, + ) -> Result<(), LimitError> { + if daily_limit_sats <= 0 { + return Err(LimitError::InvalidLimit); + } + + sqlx::query( + r#" + INSERT INTO api_key_limits (api_key_id, daily_limit_sats) + VALUES ($1, $2) + ON CONFLICT (api_key_id) + DO UPDATE SET daily_limit_sats = $2 + "#, + ) + .bind(api_key_id) + .bind(daily_limit_sats) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Set a weekly limit for an API key (in satoshis) + #[tracing::instrument(name = "limits.set_weekly_limit", skip(self))] + pub async fn set_weekly_limit( + &self, + api_key_id: IdentityApiKeyId, + weekly_limit_sats: i64, + ) -> Result<(), LimitError> { + if weekly_limit_sats <= 0 { + return Err(LimitError::InvalidLimit); + } + + sqlx::query( + r#" + INSERT INTO api_key_limits (api_key_id, weekly_limit_sats) + VALUES ($1, $2) + ON CONFLICT (api_key_id) + DO UPDATE SET weekly_limit_sats = $2 + "#, + ) + .bind(api_key_id) + .bind(weekly_limit_sats) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Set a monthly limit for an API key (in satoshis) + #[tracing::instrument(name = "limits.set_monthly_limit", skip(self))] + pub async fn set_monthly_limit( + &self, + api_key_id: IdentityApiKeyId, + monthly_limit_sats: i64, + ) -> Result<(), LimitError> { + if monthly_limit_sats <= 0 { + return Err(LimitError::InvalidLimit); + } + + sqlx::query( + r#" + INSERT INTO api_key_limits (api_key_id, monthly_limit_sats) + VALUES ($1, $2) + ON CONFLICT (api_key_id) + DO UPDATE SET monthly_limit_sats = $2 + "#, + ) + .bind(api_key_id) + .bind(monthly_limit_sats) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Set an annual limit for an API key (in satoshis) + #[tracing::instrument(name = "limits.set_annual_limit", skip(self))] + pub async fn set_annual_limit( + &self, + api_key_id: IdentityApiKeyId, + annual_limit_sats: i64, + ) -> Result<(), LimitError> { + if annual_limit_sats <= 0 { + return Err(LimitError::InvalidLimit); + } + + sqlx::query( + r#" + INSERT INTO api_key_limits (api_key_id, annual_limit_sats) + VALUES ($1, $2) + ON CONFLICT (api_key_id) + DO UPDATE SET annual_limit_sats = $2 + "#, + ) + .bind(api_key_id) + .bind(annual_limit_sats) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Remove a daily limit for an API key + #[tracing::instrument(name = "limits.remove_daily_limit", skip(self))] + pub async fn remove_daily_limit( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result<(), LimitError> { + sqlx::query( + r#" + UPDATE api_key_limits + SET daily_limit_sats = NULL + WHERE api_key_id = $1 + "#, + ) + .bind(api_key_id) + .execute(&self.pool) + .await?; + + // Delete the row if no limits remain + self.cleanup_empty_limits(api_key_id).await?; + + Ok(()) + } + + /// Remove a weekly limit for an API key + #[tracing::instrument(name = "limits.remove_weekly_limit", skip(self))] + pub async fn remove_weekly_limit( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result<(), LimitError> { + sqlx::query( + r#" + UPDATE api_key_limits + SET weekly_limit_sats = NULL + WHERE api_key_id = $1 + "#, + ) + .bind(api_key_id) + .execute(&self.pool) + .await?; + + // Delete the row if no limits remain + self.cleanup_empty_limits(api_key_id).await?; + + Ok(()) + } + + /// Remove a monthly limit for an API key + #[tracing::instrument(name = "limits.remove_monthly_limit", skip(self))] + pub async fn remove_monthly_limit( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result<(), LimitError> { + sqlx::query( + r#" + UPDATE api_key_limits + SET monthly_limit_sats = NULL + WHERE api_key_id = $1 + "#, + ) + .bind(api_key_id) + .execute(&self.pool) + .await?; + + // Delete the row if no limits remain + self.cleanup_empty_limits(api_key_id).await?; + + Ok(()) + } + + /// Remove an annual limit for an API key + #[tracing::instrument(name = "limits.remove_annual_limit", skip(self))] + pub async fn remove_annual_limit( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result<(), LimitError> { + sqlx::query( + r#" + UPDATE api_key_limits + SET annual_limit_sats = NULL + WHERE api_key_id = $1 + "#, + ) + .bind(api_key_id) + .execute(&self.pool) + .await?; + + // Delete the row if no limits remain + self.cleanup_empty_limits(api_key_id).await?; + + Ok(()) + } + + /// Remove all limits for an API key (reverts to unlimited) + #[tracing::instrument(name = "limits.remove_all_limits", skip(self))] + pub async fn remove_all_limits( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result<(), LimitError> { + sqlx::query( + r#" + DELETE FROM api_key_limits + WHERE api_key_id = $1 + "#, + ) + .bind(api_key_id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Cleanup old transaction records (delete transactions older than specified hours) + #[tracing::instrument(name = "limits.cleanup_old_transactions", skip(self))] + pub async fn cleanup_old_transactions(&self, hours_to_keep: i32) -> Result { + let result = sqlx::query( + r#" + DELETE FROM api_key_transactions + WHERE created_at < NOW() - ($1 || ' hours')::INTERVAL + "#, + ) + .bind(hours_to_keep) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected()) + } + + // Private helper methods + + /// Get all configured limits for an API key + async fn get_all_limits( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let row = sqlx::query( + r#" + SELECT daily_limit_sats, weekly_limit_sats, monthly_limit_sats, annual_limit_sats + FROM api_key_limits + WHERE api_key_id = $1 + "#, + ) + .bind(api_key_id) + .fetch_optional(&self.pool) + .await?; + + if let Some(row) = row { + Ok(AllLimits { + daily_limit_sats: row.get("daily_limit_sats"), + weekly_limit_sats: row.get("weekly_limit_sats"), + monthly_limit_sats: row.get("monthly_limit_sats"), + annual_limit_sats: row.get("annual_limit_sats"), + }) + } else { + Ok(AllLimits { + daily_limit_sats: None, + weekly_limit_sats: None, + monthly_limit_sats: None, + annual_limit_sats: None, + }) + } + } + + /// Delete limit row if all limits are NULL + async fn cleanup_empty_limits( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result<(), LimitError> { + sqlx::query( + r#" + DELETE FROM api_key_limits + WHERE api_key_id = $1 + AND daily_limit_sats IS NULL + AND weekly_limit_sats IS NULL + AND monthly_limit_sats IS NULL + AND annual_limit_sats IS NULL + "#, + ) + .bind(api_key_id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Calculate total spending in the last 24 hours (rolling window) + async fn get_spending_last_24h( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let row = sqlx::query( + r#" + SELECT COALESCE(SUM(amount_sats), 0)::bigint as spent + FROM api_key_transactions + WHERE api_key_id = $1 + AND created_at > NOW() - INTERVAL '24 hours' + "#, + ) + .bind(api_key_id) + .fetch_one(&self.pool) + .await?; + + Ok(row.get("spent")) + } + + /// Calculate total spending in the last 7 days (rolling window) + async fn get_spending_last_7d( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let row = sqlx::query( + r#" + SELECT COALESCE(SUM(amount_sats), 0)::bigint as spent + FROM api_key_transactions + WHERE api_key_id = $1 + AND created_at > NOW() - INTERVAL '7 days' + "#, + ) + .bind(api_key_id) + .fetch_one(&self.pool) + .await?; + + Ok(row.get("spent")) + } + + /// Calculate total spending in the last 30 days (rolling window) + async fn get_spending_last_30d( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let row = sqlx::query( + r#" + SELECT COALESCE(SUM(amount_sats), 0)::bigint as spent + FROM api_key_transactions + WHERE api_key_id = $1 + AND created_at > NOW() - INTERVAL '30 days' + "#, + ) + .bind(api_key_id) + .fetch_one(&self.pool) + .await?; + + Ok(row.get("spent")) + } + + /// Calculate total spending in the last 365 days (rolling window) + async fn get_spending_last_365d( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let row = sqlx::query( + r#" + SELECT COALESCE(SUM(amount_sats), 0)::bigint as spent + FROM api_key_transactions + WHERE api_key_id = $1 + AND created_at > NOW() - INTERVAL '365 days' + "#, + ) + .bind(api_key_id) + .fetch_one(&self.pool) + .await?; + + Ok(row.get("spent")) + } + + /// Get transaction count in the last 24 hours + async fn get_transaction_count_last_24h( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let row = sqlx::query( + r#" + SELECT COUNT(*)::int as count + FROM api_key_transactions + WHERE api_key_id = $1 + AND created_at > NOW() - INTERVAL '24 hours' + "#, + ) + .bind(api_key_id) + .fetch_one(&self.pool) + .await?; + + Ok(row.get("count")) + } + + /// Get transaction count in the last 7 days + async fn get_transaction_count_last_7d( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let row = sqlx::query( + r#" + SELECT COUNT(*)::int as count + FROM api_key_transactions + WHERE api_key_id = $1 + AND created_at > NOW() - INTERVAL '7 days' + "#, + ) + .bind(api_key_id) + .fetch_one(&self.pool) + .await?; + + Ok(row.get("count")) + } + + /// Get transaction count in the last 30 days + async fn get_transaction_count_last_30d( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let row = sqlx::query( + r#" + SELECT COUNT(*)::int as count + FROM api_key_transactions + WHERE api_key_id = $1 + AND created_at > NOW() - INTERVAL '30 days' + "#, + ) + .bind(api_key_id) + .fetch_one(&self.pool) + .await?; + + Ok(row.get("count")) + } + + /// Get transaction count in the last 365 days + async fn get_transaction_count_last_365d( + &self, + api_key_id: IdentityApiKeyId, + ) -> Result { + let row = sqlx::query( + r#" + SELECT COUNT(*)::int as count + FROM api_key_transactions + WHERE api_key_id = $1 + AND created_at > NOW() - INTERVAL '365 days' + "#, + ) + .bind(api_key_id) + .fetch_one(&self.pool) + .await?; + + Ok(row.get("count")) + } +} diff --git a/core/api-keys/src/server/mod.rs b/core/api-keys/src/server/mod.rs index 5eec283916..3bc5910523 100644 --- a/core/api-keys/src/server/mod.rs +++ b/core/api-keys/src/server/mod.rs @@ -3,7 +3,12 @@ mod jwks; use async_graphql::*; use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; -use axum::{extract::State, routing::get, Extension, Json, Router}; +use axum::{ + extract::{Query, State}, + http::StatusCode, + routing::{get, post}, + Extension, Json, Router, +}; use axum_extra::headers::HeaderMap; use serde::{Deserialize, Serialize}; use tracing::instrument; @@ -13,6 +18,8 @@ use std::sync::Arc; use crate::{ app::{ApiKeysApp, ApplicationError}, graphql, + identity::IdentityApiKeyId, + limits::Limits, }; pub use config::*; @@ -28,6 +35,7 @@ pub struct JwtClaims { pub async fn run_server(config: ServerConfig, api_keys_app: ApiKeysApp) -> anyhow::Result<()> { let schema = graphql::schema(Some(api_keys_app.clone())); + let limits = Arc::new(Limits::new(api_keys_app.pool())); let jwks_decoder = Arc::new(RemoteJwksDecoder::new(config.jwks_url.clone())); let decoder = jwks_decoder.clone(); @@ -35,6 +43,12 @@ pub async fn run_server(config: ServerConfig, api_keys_app: ApiKeysApp) -> anyho decoder.refresh_keys_periodically().await; }); + // Spawn background task to cleanup old transaction records + let cleanup_limits = limits.clone(); + tokio::spawn(async move { + cleanup_old_transactions_periodically(cleanup_limits).await; + }); + let app = Router::new() .route( "/graphql", @@ -44,6 +58,18 @@ pub async fn run_server(config: ServerConfig, api_keys_app: ApiKeysApp) -> anyho "/auth/check", get(check_handler).with_state((config.api_key_auth_header, api_keys_app)), ) + .route( + "/limits/check", + get(limits_check_handler).with_state(limits.clone()), + ) + .route( + "/limits/remaining", + get(limits_remaining_handler).with_state(limits.clone()), + ) + .route( + "/spending/record", + post(spending_record_handler).with_state(limits.clone()), + ) .with_state(JwtDecoderState { decoder: jwks_decoder, }) @@ -61,6 +87,45 @@ pub async fn run_server(config: ServerConfig, api_keys_app: ApiKeysApp) -> anyho struct CheckResponse { sub: String, scope: String, + api_key_id: String, +} + +#[derive(Debug, Deserialize)] +struct LimitsCheckQuery { + api_key_id: String, + amount_sats: i64, +} + +#[derive(Debug, Serialize)] +struct LimitsCheckResponse { + allowed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + daily_limit_sats: Option, + spent_last_24h_sats: i64, +} + +#[derive(Debug, Deserialize)] +struct LimitsRemainingQuery { + api_key_id: String, +} + +#[derive(Debug, Serialize)] +struct LimitsRemainingResponse { + #[serde(skip_serializing_if = "Option::is_none")] + daily_limit_sats: Option, + spent_last_24h_sats: i64, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_sats: Option, + transaction_count: i32, +} + +#[derive(Debug, Deserialize)] +struct SpendingRecordRequest { + api_key_id: String, + amount_sats: i64, + transaction_id: Option, } #[instrument( @@ -86,7 +151,11 @@ async fn check_handler( span.record("sub", &sub); span.record("scope", &scope); - Ok(Json(CheckResponse { sub, scope })) + Ok(Json(CheckResponse { + sub, + scope, + api_key_id: id.to_string(), + })) } pub async fn graphql_handler( @@ -110,3 +179,133 @@ async fn playground() -> impl axum::response::IntoResponse { async_graphql::http::GraphQLPlaygroundConfig::new("/graphql"), )) } + +#[instrument( + name = "api-keys.server.limits_check", + skip_all, + fields(api_key_id, amount_sats) +)] +async fn limits_check_handler( + State(limits): State>, + Query(params): Query, +) -> Result, (StatusCode, String)> { + let api_key_id = params.api_key_id.parse::().map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid API key ID: {}", e), + ) + })?; + + let span = tracing::Span::current(); + span.record("api_key_id", &tracing::field::display(&api_key_id)); + span.record("amount_sats", params.amount_sats); + + let result = limits + .check_spending_limit(api_key_id, params.amount_sats) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Calculate remaining from limit and spent + let remaining_sats = result + .daily_limit_sats + .map(|limit| limit - result.spent_last_24h_sats); + + Ok(Json(LimitsCheckResponse { + allowed: result.allowed, + remaining_sats, + daily_limit_sats: result.daily_limit_sats, + spent_last_24h_sats: result.spent_last_24h_sats, + })) +} + +#[instrument( + name = "api-keys.server.limits_remaining", + skip_all, + fields(api_key_id) +)] +async fn limits_remaining_handler( + State(limits): State>, + Query(params): Query, +) -> Result, (StatusCode, String)> { + let api_key_id = params.api_key_id.parse::().map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid API key ID: {}", e), + ) + })?; + + let span = tracing::Span::current(); + span.record("api_key_id", &tracing::field::display(&api_key_id)); + + let summary = limits + .get_spending_summary(api_key_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Calculate remaining from limit and spent + let remaining_sats = summary + .daily_limit_sats + .map(|limit| limit - summary.spent_last_24h_sats); + + Ok(Json(LimitsRemainingResponse { + daily_limit_sats: summary.daily_limit_sats, + spent_last_24h_sats: summary.spent_last_24h_sats, + remaining_sats, + transaction_count: summary.transaction_count_24h, + })) +} + +#[instrument( + name = "api-keys.server.spending_record", + skip_all, + fields(api_key_id, amount_sats) +)] +async fn spending_record_handler( + State(limits): State>, + Json(payload): Json, +) -> Result { + let api_key_id = payload + .api_key_id + .parse::() + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Invalid API key ID: {}", e), + ) + })?; + + let span = tracing::Span::current(); + span.record("api_key_id", &tracing::field::display(&api_key_id)); + span.record("amount_sats", payload.amount_sats); + + limits + .record_spending(api_key_id, payload.amount_sats, payload.transaction_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + +/// Cleanup old transaction records periodically +/// Runs every 24 hours and deletes transactions older than 400 days (to support 365-day annual limits) +async fn cleanup_old_transactions_periodically(limits: Arc) { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(24 * 60 * 60)); // 24 hours + loop { + interval.tick().await; + + match limits.cleanup_old_transactions(400 * 24).await { + Ok(count) => { + tracing::info!( + deleted_rows = count, + "Successfully cleaned up old transaction records" + ); + } + Err(e) => { + tracing::error!( + error = %e, + "Failed to cleanup old transaction records" + ); + } + } + } +} diff --git a/core/api-keys/subgraph/schema.graphql b/core/api-keys/subgraph/schema.graphql index a5d4a35ecc..f1df22e277 100644 --- a/core/api-keys/subgraph/schema.graphql +++ b/core/api-keys/subgraph/schema.graphql @@ -8,6 +8,38 @@ type ApiKey { expiresAt: Timestamp readOnly: Boolean! scopes: [Scope!]! + """ + Daily spending limit in satoshis (rolling 24h window). Returns null if no limit is set. + """ + dailyLimitSats: Int + """ + Weekly spending limit in satoshis (rolling 7 days). Returns null if no limit is set. + """ + weeklyLimitSats: Int + """ + Monthly spending limit in satoshis (rolling 30 days). Returns null if no limit is set. + """ + monthlyLimitSats: Int + """ + Annual spending limit in satoshis (rolling 365 days). Returns null if no limit is set. + """ + annualLimitSats: Int + """ + Amount spent in the last 24 hours (rolling window) in satoshis + """ + spentLast24HSats: Int! + """ + Amount spent in the last 7 days (rolling window) in satoshis + """ + spentLast7DSats: Int! + """ + Amount spent in the last 30 days (rolling window) in satoshis + """ + spentLast30DSats: Int! + """ + Amount spent in the last 365 days (rolling window) in satoshis + """ + spentLast365DSats: Int! } input ApiKeyCreateInput { @@ -21,6 +53,10 @@ type ApiKeyCreatePayload { apiKeySecret: String! } +input ApiKeyRemoveLimitInput { + id: ID! +} + input ApiKeyRevokeInput { id: ID! } @@ -29,6 +65,30 @@ type ApiKeyRevokePayload { apiKey: ApiKey! } +input ApiKeySetAnnualLimitInput { + id: ID! + annualLimitSats: Int! +} + +input ApiKeySetDailyLimitInput { + id: ID! + dailyLimitSats: Int! +} + +type ApiKeySetLimitPayload { + apiKey: ApiKey! +} + +input ApiKeySetMonthlyLimitInput { + id: ID! + monthlyLimitSats: Int! +} + +input ApiKeySetWeeklyLimitInput { + id: ID! + weeklyLimitSats: Int! +} + @@ -36,6 +96,14 @@ type ApiKeyRevokePayload { type Mutation { apiKeyCreate(input: ApiKeyCreateInput!): ApiKeyCreatePayload! apiKeyRevoke(input: ApiKeyRevokeInput!): ApiKeyRevokePayload! + apiKeySetDailyLimit(input: ApiKeySetDailyLimitInput!): ApiKeySetLimitPayload! + apiKeySetWeeklyLimit(input: ApiKeySetWeeklyLimitInput!): ApiKeySetLimitPayload! + apiKeySetMonthlyLimit(input: ApiKeySetMonthlyLimitInput!): ApiKeySetLimitPayload! + apiKeySetAnnualLimit(input: ApiKeySetAnnualLimitInput!): ApiKeySetLimitPayload! + apiKeyRemoveDailyLimit(input: ApiKeyRemoveLimitInput!): ApiKeySetLimitPayload! + apiKeyRemoveWeeklyLimit(input: ApiKeyRemoveLimitInput!): ApiKeySetLimitPayload! + apiKeyRemoveMonthlyLimit(input: ApiKeyRemoveLimitInput!): ApiKeySetLimitPayload! + apiKeyRemoveAnnualLimit(input: ApiKeyRemoveLimitInput!): ApiKeySetLimitPayload! } diff --git a/dev/config/apollo-federation/supergraph.graphql b/dev/config/apollo-federation/supergraph.graphql index af3cfad19a..dd7971f9ec 100644 --- a/dev/config/apollo-federation/supergraph.graphql +++ b/dev/config/apollo-federation/supergraph.graphql @@ -185,6 +185,38 @@ type ApiKey expiresAt: Timestamp readOnly: Boolean! scopes: [Scope!]! + + """ + Daily spending limit in satoshis (rolling 24h window). Returns null if no limit is set. + """ + dailyLimitSats: Int + + """ + Weekly spending limit in satoshis (rolling 7 days). Returns null if no limit is set. + """ + weeklyLimitSats: Int + + """ + Monthly spending limit in satoshis (rolling 30 days). Returns null if no limit is set. + """ + monthlyLimitSats: Int + + """ + Annual spending limit in satoshis (rolling 365 days). Returns null if no limit is set. + """ + annualLimitSats: Int + + """Amount spent in the last 24 hours (rolling window) in satoshis""" + spentLast24HSats: Int! + + """Amount spent in the last 7 days (rolling window) in satoshis""" + spentLast7DSats: Int! + + """Amount spent in the last 30 days (rolling window) in satoshis""" + spentLast30DSats: Int! + + """Amount spent in the last 365 days (rolling window) in satoshis""" + spentLast365DSats: Int! } input ApiKeyCreateInput @@ -202,6 +234,12 @@ type ApiKeyCreatePayload apiKeySecret: String! } +input ApiKeyRemoveLimitInput + @join__type(graph: API_KEYS) +{ + id: ID! +} + input ApiKeyRevokeInput @join__type(graph: API_KEYS) { @@ -214,6 +252,40 @@ type ApiKeyRevokePayload apiKey: ApiKey! } +input ApiKeySetAnnualLimitInput + @join__type(graph: API_KEYS) +{ + id: ID! + annualLimitSats: Int! +} + +input ApiKeySetDailyLimitInput + @join__type(graph: API_KEYS) +{ + id: ID! + dailyLimitSats: Int! +} + +type ApiKeySetLimitPayload + @join__type(graph: API_KEYS) +{ + apiKey: ApiKey! +} + +input ApiKeySetMonthlyLimitInput + @join__type(graph: API_KEYS) +{ + id: ID! + monthlyLimitSats: Int! +} + +input ApiKeySetWeeklyLimitInput + @join__type(graph: API_KEYS) +{ + id: ID! + weeklyLimitSats: Int! +} + type Authorization @join__type(graph: PUBLIC) { @@ -1274,6 +1346,14 @@ type Mutation { apiKeyCreate(input: ApiKeyCreateInput!): ApiKeyCreatePayload! @join__field(graph: API_KEYS) apiKeyRevoke(input: ApiKeyRevokeInput!): ApiKeyRevokePayload! @join__field(graph: API_KEYS) + apiKeySetDailyLimit(input: ApiKeySetDailyLimitInput!): ApiKeySetLimitPayload! @join__field(graph: API_KEYS) + apiKeySetWeeklyLimit(input: ApiKeySetWeeklyLimitInput!): ApiKeySetLimitPayload! @join__field(graph: API_KEYS) + apiKeySetMonthlyLimit(input: ApiKeySetMonthlyLimitInput!): ApiKeySetLimitPayload! @join__field(graph: API_KEYS) + apiKeySetAnnualLimit(input: ApiKeySetAnnualLimitInput!): ApiKeySetLimitPayload! @join__field(graph: API_KEYS) + apiKeyRemoveDailyLimit(input: ApiKeyRemoveLimitInput!): ApiKeySetLimitPayload! @join__field(graph: API_KEYS) + apiKeyRemoveWeeklyLimit(input: ApiKeyRemoveLimitInput!): ApiKeySetLimitPayload! @join__field(graph: API_KEYS) + apiKeyRemoveMonthlyLimit(input: ApiKeyRemoveLimitInput!): ApiKeySetLimitPayload! @join__field(graph: API_KEYS) + apiKeyRemoveAnnualLimit(input: ApiKeyRemoveLimitInput!): ApiKeySetLimitPayload! @join__field(graph: API_KEYS) statefulNotificationAcknowledge(input: StatefulNotificationAcknowledgeInput!): StatefulNotificationAcknowledgePayload! @join__field(graph: NOTIFICATIONS) accountDelete: AccountDeletePayload! @join__field(graph: PUBLIC) accountDisableNotificationCategory(input: AccountDisableNotificationCategoryInput!): AccountUpdateNotificationSettingsPayload! @join__field(graph: PUBLIC) From 5bce912f21c6ed44fd62cda4f38d8e23500e1ab0 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Mon, 13 Oct 2025 00:55:44 +0300 Subject: [PATCH 02/13] feat: integrate api-keys budget check on core api --- core/api/src/app/payments/send-lightning.ts | 61 +++++++++++++ core/api/src/app/wallets/index.types.d.ts | 1 + core/api/src/config/env.ts | 4 + core/api/src/config/index.ts | 2 + core/api/src/domain/api-keys/errors.ts | 32 +++++++ core/api/src/domain/api-keys/index.ts | 1 + core/api/src/domain/errors.ts | 7 ++ core/api/src/graphql/error-map.ts | 12 +++ .../root/mutation/ln-invoice-payment-send.ts | 3 +- core/api/src/servers/index.files.d.ts | 1 + core/api/src/servers/middlewares/session.ts | 2 + core/api/src/services/api-keys/client.ts | 87 +++++++++++++++++++ dev/config/ory/oathkeeper_rules.yaml | 2 +- 13 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 core/api/src/domain/api-keys/errors.ts create mode 100644 core/api/src/domain/api-keys/index.ts create mode 100644 core/api/src/services/api-keys/client.ts diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index 57dea4ad31..81880ba624 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -54,6 +54,11 @@ import { DealerPriceService } from "@/services/dealer-price" import { LedgerService } from "@/services/ledger" import { LockService } from "@/services/lock" import { NotificationsService } from "@/services/notifications" +import { + checkApiKeySpendingLimit, + recordApiKeySpending, +} from "@/services/api-keys/client" +import { ApiKeyDailyLimitExceededError } from "@/domain/errors" import * as LedgerFacade from "@/services/ledger/facade" import { @@ -85,6 +90,7 @@ export const payInvoiceByWalletId = async ({ memo, senderWalletId: uncheckedSenderWalletId, senderAccount, + apiKeyId, }: PayInvoiceByWalletIdArgs): Promise => { addAttributesToCurrentSpan({ "payment.initiation_method": PaymentInitiationMethod.Lightning, @@ -121,6 +127,7 @@ export const payInvoiceByWalletId = async ({ paymentFlow, senderAccount, memo, + apiKeyId, }) } @@ -144,6 +151,7 @@ export const payInvoiceByWalletId = async ({ senderAccount, recipientAccount, memo, + apiKeyId, }) if (paymentSendResult instanceof Error) return paymentSendResult @@ -166,6 +174,7 @@ const payNoAmountInvoiceByWalletId = async ({ memo, senderWalletId: uncheckedSenderWalletId, senderAccount, + apiKeyId, }: PayNoAmountInvoiceByWalletIdArgs): Promise => { addAttributesToCurrentSpan({ "payment.initiation_method": PaymentInitiationMethod.Lightning, @@ -204,6 +213,7 @@ const payNoAmountInvoiceByWalletId = async ({ paymentFlow, senderAccount, memo, + apiKeyId, }) } @@ -228,6 +238,7 @@ const payNoAmountInvoiceByWalletId = async ({ senderAccount, recipientAccount, memo, + apiKeyId, }) if (paymentSendResult instanceof Error) return paymentSendResult @@ -432,12 +443,14 @@ const executePaymentViaIntraledger = async < senderWalletId, recipientAccount, memo, + apiKeyId, }: { paymentFlow: PaymentFlow senderAccount: Account senderWalletId: WalletId recipientAccount: Account memo: string | null + apiKeyId?: string }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -445,6 +458,18 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + // Check API key spending limit if authenticated via API key + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const apiKeyLimitCheck = await checkApiKeySpendingLimit({ + apiKeyId, + amountSats, + }) + if (apiKeyLimitCheck instanceof Error) return apiKeyLimitCheck + if (!apiKeyLimitCheck.allowed) { + return new ApiKeyDailyLimitExceededError(apiKeyLimitCheck.remaining_sats) + } + } const paymentHash = paymentFlow.paymentHashForFlow() if (paymentHash instanceof Error) return paymentHash @@ -546,6 +571,17 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) + // Record API key spending after successful payment + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const recordResult = await recordApiKeySpending({ + apiKeyId, + amountSats, + transactionId: journalId, + }) + if (recordResult instanceof Error) return recordResult + } + return { status: PaymentSendStatus.Success, transaction: senderWalletTransaction, @@ -727,11 +763,13 @@ const executePaymentViaLn = async ({ paymentFlow, senderAccount, memo, + apiKeyId, }: { decodedInvoice: LnInvoice paymentFlow: PaymentFlow senderAccount: Account memo: string | null + apiKeyId?: string }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.Lightning, @@ -741,6 +779,18 @@ const executePaymentViaLn = async ({ const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + // Check API key spending limit if authenticated via API key + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const apiKeyLimitCheck = await checkApiKeySpendingLimit({ + apiKeyId, + amountSats, + }) + if (apiKeyLimitCheck instanceof Error) return apiKeyLimitCheck + if (!apiKeyLimitCheck.allowed) { + return new ApiKeyDailyLimitExceededError(apiKeyLimitCheck.remaining_sats) + } + } const limitCheck = await checkWithdrawalLimits({ amount: paymentFlow.usdPaymentAmount, @@ -813,6 +863,17 @@ const executePaymentViaLn = async ({ }) default: + // Record API key spending after successful payment + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const recordResult = await recordApiKeySpending({ + apiKeyId, + amountSats, + transactionId: paymentSendAttemptResult.journalId, + }) + if (recordResult instanceof Error) return recordResult + } + return { status: PaymentSendStatus.Success, transaction: walletTransaction, diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index 147498f45f..fa5327f84a 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -98,6 +98,7 @@ type PaymentSendArgs = { senderWalletId: WalletId senderAccount: Account memo: string | null + apiKeyId?: string } type PayInvoiceByWalletIdArgs = PaymentSendArgs & { diff --git a/core/api/src/config/env.ts b/core/api/src/config/env.ts index 18f66ee917..ad4e4aa989 100644 --- a/core/api/src/config/env.ts +++ b/core/api/src/config/env.ts @@ -62,6 +62,8 @@ export const env = createEnv({ .pipe(z.coerce.number()) .default(6685), + API_KEYS_SERVICE_URL: z.string().url().default("http://localhost:5397"), + GEETEST_ID: z.string().min(1).optional(), GEETEST_KEY: z.string().min(1).optional(), @@ -193,6 +195,8 @@ export const env = createEnv({ NOTIFICATIONS_HOST: process.env.NOTIFICATIONS_HOST, NOTIFICATIONS_PORT: process.env.NOTIFICATIONS_PORT, + API_KEYS_SERVICE_URL: process.env.API_KEYS_SERVICE_URL || "http://localhost:5397", + GEETEST_ID: process.env.GEETEST_ID, GEETEST_KEY: process.env.GEETEST_KEY, diff --git a/core/api/src/config/index.ts b/core/api/src/config/index.ts index 9c54a7b838..14bb0deac1 100644 --- a/core/api/src/config/index.ts +++ b/core/api/src/config/index.ts @@ -84,6 +84,8 @@ export const getCallbackServiceConfig = (): SvixConfig => { export const getBriaConfig = getBriaPartialConfigFromYaml +export const getApiKeysServiceUrl = () => env.API_KEYS_SERVICE_URL + export const isTelegramPassportEnabled = () => !!env.TELEGRAM_BOT_API_TOKEN && !!env.TELEGRAM_PASSPORT_PRIVATE_KEY diff --git a/core/api/src/domain/api-keys/errors.ts b/core/api/src/domain/api-keys/errors.ts new file mode 100644 index 0000000000..dd28b5942f --- /dev/null +++ b/core/api/src/domain/api-keys/errors.ts @@ -0,0 +1,32 @@ +import { DomainError, ErrorLevel } from "@/domain/shared" + +export class ApiKeyDailyLimitExceededError extends DomainError { + level = ErrorLevel.Warn + + constructor(public readonly remainingSats: number | null) { + super( + remainingSats !== null + ? `API key daily spending limit exceeded. Remaining: ${remainingSats} sats` + : "API key daily spending limit exceeded", + ) + this.name = "ApiKeyDailyLimitExceededError" + } +} + +export class ApiKeyLimitCheckError extends DomainError { + level = ErrorLevel.Warn + + constructor(message?: string) { + super(message || "Failed to check API key spending limit") + this.name = "ApiKeyLimitCheckError" + } +} + +export class ApiKeySpendingRecordError extends DomainError { + level = ErrorLevel.Info // Lower severity - this doesn't block the payment + + constructor(message?: string) { + super(message || "Failed to record API key spending") + this.name = "ApiKeySpendingRecordError" + } +} diff --git a/core/api/src/domain/api-keys/index.ts b/core/api/src/domain/api-keys/index.ts new file mode 100644 index 0000000000..a079f46484 --- /dev/null +++ b/core/api/src/domain/api-keys/index.ts @@ -0,0 +1 @@ +export * from "./errors" diff --git a/core/api/src/domain/errors.ts b/core/api/src/domain/errors.ts index bd1307394e..7974e0561d 100644 --- a/core/api/src/domain/errors.ts +++ b/core/api/src/domain/errors.ts @@ -153,6 +153,13 @@ export class LimitsExceededError extends ValidationError {} export class WithdrawalLimitsExceededError extends LimitsExceededError {} export class IntraledgerLimitsExceededError extends LimitsExceededError {} export class TradeIntraAccountLimitsExceededError extends LimitsExceededError {} +export class ApiKeyDailyLimitExceededError extends LimitsExceededError {} +export class ApiKeyLimitCheckError extends DomainError { + level = ErrorLevel.Warn +} +export class ApiKeySpendingRecordError extends DomainError { + level = ErrorLevel.Critical +} export class LnRouteValidationError extends ValidationError {} export class BadAmountForRouteError extends LnRouteValidationError {} diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 6c9efdd21b..0980a6cf7d 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -63,6 +63,18 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { message = error.message return new TransactionRestrictedError({ message, logger: baseLogger }) + case "ApiKeyDailyLimitExceededError": + message = error.message + return new TransactionRestrictedError({ message, logger: baseLogger }) + + case "ApiKeyLimitCheckError": + message = error.message || "Failed to check API key spending limit" + return new UnknownClientError({ message, logger: baseLogger }) + + case "ApiKeySpendingRecordError": + message = error.message || "Failed to record API key spending" + return new UnknownClientError({ message, logger: baseLogger }) + case "AlreadyPaidError": message = "Invoice is already paid" return new LightningPaymentError({ message, logger: baseLogger }) diff --git a/core/api/src/graphql/public/root/mutation/ln-invoice-payment-send.ts b/core/api/src/graphql/public/root/mutation/ln-invoice-payment-send.ts index 033c539a32..bc798d0782 100644 --- a/core/api/src/graphql/public/root/mutation/ln-invoice-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/ln-invoice-payment-send.ts @@ -49,7 +49,7 @@ const LnInvoicePaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(LnInvoicePaymentInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, paymentRequest, memo } = args.input if (walletId instanceof InputValidationError) { return { errors: [{ message: walletId.message }] } @@ -66,6 +66,7 @@ const LnInvoicePaymentSendMutation = GT.Field< uncheckedPaymentRequest: paymentRequest, memo: memo ?? null, senderAccount: domainAccount, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/servers/index.files.d.ts b/core/api/src/servers/index.files.d.ts index add09bc539..a640143199 100644 --- a/core/api/src/servers/index.files.d.ts +++ b/core/api/src/servers/index.files.d.ts @@ -18,6 +18,7 @@ type GraphQLPublicContextAuth = GraphQLPublicContext & { domainAccount: Account scope: ScopesOauth2[] | undefined appId: string | undefined + apiKeyId?: string } type GraphQLAdminContext = { diff --git a/core/api/src/servers/middlewares/session.ts b/core/api/src/servers/middlewares/session.ts index 8cda27eb1f..f1b200fd77 100644 --- a/core/api/src/servers/middlewares/session.ts +++ b/core/api/src/servers/middlewares/session.ts @@ -30,6 +30,7 @@ export const sessionPublicContext = async ({ ) const sub = tokenPayload?.sub const appId = tokenPayload?.client_id + const apiKeyId = tokenPayload?.api_key_id // note: value should match (ie: "anon") if not an accountId // settings from dev/ory/oathkeeper.yml/authenticator/anonymous/config/subjet @@ -87,6 +88,7 @@ export const sessionPublicContext = async ({ sessionId, scope, appId, + apiKeyId, } } diff --git a/core/api/src/services/api-keys/client.ts b/core/api/src/services/api-keys/client.ts new file mode 100644 index 0000000000..2e0ce465c2 --- /dev/null +++ b/core/api/src/services/api-keys/client.ts @@ -0,0 +1,87 @@ +import axios from "axios" + +import { baseLogger } from "@/services/logger" +import { + ApiKeyLimitCheckError, + ApiKeySpendingRecordError, +} from "@/domain/api-keys" +import { getApiKeysServiceUrl } from "@/config" + +const API_KEYS_SERVICE_URL = getApiKeysServiceUrl() + +export type LimitCheckResult = { + allowed: boolean + remaining_sats: number | null + daily_limit_sats: number | null + spent_last_24h_sats: number +} + +/** + * Check if a spending amount would exceed the API key's daily limit (rolling 24h window) + * Returns allowed=true if no limit is configured for the API key + */ +export const checkApiKeySpendingLimit = async ({ + apiKeyId, + amountSats, +}: { + apiKeyId: string + amountSats: number +}): Promise => { + try { + const response = await axios.get( + `${API_KEYS_SERVICE_URL}/limits/check`, + { + params: { + api_key_id: apiKeyId, + amount_sats: amountSats, + }, + timeout: 5000, // 5 second timeout + }, + ) + + return response.data + } catch (err) { + baseLogger.error( + { err, apiKeyId, amountSats }, + "Failed to check API key spending limit", + ) + return new ApiKeyLimitCheckError("Failed to check API key limit") + } +} + +/** + * Record spending for an API key after a successful payment + * This is fire-and-forget - errors are logged but not propagated + */ +export const recordApiKeySpending = async ({ + apiKeyId, + amountSats, + transactionId, +}: { + apiKeyId: string + amountSats: number + transactionId: string +}): Promise => { + try { + await axios.post( + `${API_KEYS_SERVICE_URL}/spending/record`, + { + api_key_id: apiKeyId, + amount_sats: amountSats, + transaction_id: transactionId, + }, + { + timeout: 5000, // 5 second timeout + }, + ) + } catch (err) { + baseLogger.error( + { err, apiKeyId, amountSats, transactionId }, + "Failed to record API key spending", + ) + return new ApiKeySpendingRecordError("Failed to record API key spending") + } +} + +// Re-export error types for convenience +export { ApiKeyLimitCheckError, ApiKeySpendingRecordError } diff --git a/dev/config/ory/oathkeeper_rules.yaml b/dev/config/ory/oathkeeper_rules.yaml index 7cd4cff2d4..82b0d1f499 100644 --- a/dev/config/ory/oathkeeper_rules.yaml +++ b/dev/config/ory/oathkeeper_rules.yaml @@ -78,7 +78,7 @@ mutators: - handler: id_token config: #! TODO: add aud: {"aud": ["https://api/graphql"] } - claims: '{"sub": "{{ print .Subject }}", "session_id": "{{ print .Extra.id }}", "expires_at": "{{ print .Extra.expires_at }}", "scope": "{{ print .Extra.scope }}", "client_id": "{{ print .Extra.client_id }}"}' + claims: '{"sub": "{{ print .Subject }}", "session_id": "{{ print .Extra.id }}", "expires_at": "{{ print .Extra.expires_at }}", "scope": "{{ print .Extra.scope }}", "client_id": "{{ print .Extra.client_id }}", "api_key_id": "{{ print .Extra.api_key_id }}"}' - id: admin-backend upstream: From 590157fa270a48e541214e2ff326b7d166e5af92 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Mon, 13 Oct 2025 00:56:25 +0300 Subject: [PATCH 03/13] feat: dashboard add api key budget limits --- apps/dashboard/app/api-keys/server-actions.ts | 224 ++++++- apps/dashboard/components/api-keys/limit.tsx | 288 +++++++++ apps/dashboard/components/api-keys/list.tsx | 152 ++++- apps/dashboard/services/graphql/generated.ts | 555 +++++++++++++++++- .../services/graphql/mutations/api-keys.ts | 299 ++++++++++ .../services/graphql/queries/api-keys.ts | 8 + 6 files changed, 1503 insertions(+), 23 deletions(-) create mode 100644 apps/dashboard/components/api-keys/limit.tsx diff --git a/apps/dashboard/app/api-keys/server-actions.ts b/apps/dashboard/app/api-keys/server-actions.ts index f27072ceea..537c9ddd2a 100644 --- a/apps/dashboard/app/api-keys/server-actions.ts +++ b/apps/dashboard/app/api-keys/server-actions.ts @@ -5,7 +5,18 @@ import { revalidatePath } from "next/cache" import { ApiKeyResponse } from "./api-key.types" import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { createApiKey, revokeApiKey } from "@/services/graphql/mutations/api-keys" +import { + createApiKey, + revokeApiKey, + setApiKeyDailyLimit, + setApiKeyWeeklyLimit, + setApiKeyMonthlyLimit, + setApiKeyAnnualLimit, + removeApiKeyLimit, + removeApiKeyWeeklyLimit, + removeApiKeyMonthlyLimit, + removeApiKeyAnnualLimit, +} from "@/services/graphql/mutations/api-keys" import { Scope } from "@/services/graphql/generated" export const revokeApiKeyServerAction = async (id: string) => { @@ -119,3 +130,214 @@ export const createApiKeyServerAction = async ( responsePayload: { apiKeySecret: data?.apiKeyCreate.apiKeySecret }, } } + +export const setDailyLimit = async ({ + id, + dailyLimitSats, +}: { + id: string + dailyLimitSats: number +}) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + if (!dailyLimitSats || dailyLimitSats <= 0) { + throw new Error("Daily limit must be greater than 0") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await setApiKeyDailyLimit({ id, dailyLimitSats }) + } catch (err) { + console.log("error in setApiKeyDailyLimit ", err) + throw new Error("Failed to set API key daily limit") + } + + revalidatePath("/api-keys") +} + +// Keep old name for backward compatibility +export const setLimit = setDailyLimit + +export const setWeeklyLimit = async ({ + id, + weeklyLimitSats, +}: { + id: string + weeklyLimitSats: number +}) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + if (!weeklyLimitSats || weeklyLimitSats <= 0) { + throw new Error("Weekly limit must be greater than 0") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await setApiKeyWeeklyLimit({ id, weeklyLimitSats }) + } catch (err) { + console.log("error in setApiKeyWeeklyLimit ", err) + throw new Error("Failed to set API key weekly limit") + } + + revalidatePath("/api-keys") +} + +export const setMonthlyLimit = async ({ + id, + monthlyLimitSats, +}: { + id: string + monthlyLimitSats: number +}) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + if (!monthlyLimitSats || monthlyLimitSats <= 0) { + throw new Error("Monthly limit must be greater than 0") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await setApiKeyMonthlyLimit({ id, monthlyLimitSats }) + } catch (err) { + console.log("error in setApiKeyMonthlyLimit ", err) + throw new Error("Failed to set API key monthly limit") + } + + revalidatePath("/api-keys") +} + +export const setAnnualLimit = async ({ + id, + annualLimitSats, +}: { + id: string + annualLimitSats: number +}) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + if (!annualLimitSats || annualLimitSats <= 0) { + throw new Error("Annual limit must be greater than 0") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await setApiKeyAnnualLimit({ id, annualLimitSats }) + } catch (err) { + console.log("error in setApiKeyAnnualLimit ", err) + throw new Error("Failed to set API key annual limit") + } + + revalidatePath("/api-keys") +} + +export const removeLimit = async ({ id }: { id: string }) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await removeApiKeyLimit({ id }) + } catch (err) { + console.log("error in removeApiKeyLimit ", err) + throw new Error("Failed to remove API key limit") + } + + revalidatePath("/api-keys") +} + +export const removeWeeklyLimit = async ({ id }: { id: string }) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await removeApiKeyWeeklyLimit({ id }) + } catch (err) { + console.log("error in removeApiKeyWeeklyLimit ", err) + throw new Error("Failed to remove API key weekly limit") + } + + revalidatePath("/api-keys") +} + +export const removeMonthlyLimit = async ({ id }: { id: string }) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await removeApiKeyMonthlyLimit({ id }) + } catch (err) { + console.log("error in removeApiKeyMonthlyLimit ", err) + throw new Error("Failed to remove API key monthly limit") + } + + revalidatePath("/api-keys") +} + +export const removeAnnualLimit = async ({ id }: { id: string }) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await removeApiKeyAnnualLimit({ id }) + } catch (err) { + console.log("error in removeApiKeyAnnualLimit ", err) + throw new Error("Failed to remove API key annual limit") + } + + revalidatePath("/api-keys") +} diff --git a/apps/dashboard/components/api-keys/limit.tsx b/apps/dashboard/components/api-keys/limit.tsx new file mode 100644 index 0000000000..5a9a40862e --- /dev/null +++ b/apps/dashboard/components/api-keys/limit.tsx @@ -0,0 +1,288 @@ +import React, { useState } from "react" +import { + Button, + Modal, + ModalDialog, + Typography, + Input, + FormControl, + FormLabel, + Stack, + Tabs, + TabList, + Tab, + TabPanel, + Box, +} from "@mui/joy" +import { + setDailyLimit, + setWeeklyLimit, + setMonthlyLimit, + setAnnualLimit, + removeLimit, + removeWeeklyLimit, + removeMonthlyLimit, + removeAnnualLimit, +} from "@/app/api-keys/server-actions" + +type LimitPeriod = "daily" | "weekly" | "monthly" | "annual" + +interface LimitProps { + id: string + limits: { + daily: number | null + weekly: number | null + monthly: number | null + annual: number | null + } + spent: { + last24h: number + last7d: number + last30d: number + last365d: number + } +} + +const Limit: React.FC = ({ id, limits, spent }) => { + const [open, setOpen] = useState(false) + const [selectedPeriod, setSelectedPeriod] = useState("daily") + const [limitValues, setLimitValues] = useState({ + daily: limits.daily?.toString() || "", + weekly: limits.weekly?.toString() || "", + monthly: limits.monthly?.toString() || "", + annual: limits.annual?.toString() || "", + }) + const [loading, setLoading] = useState(false) + + const periodConfig = { + daily: { + label: "Daily (24h)", + description: "Set a rolling 24-hour spending limit", + currentLimit: limits.daily, + spent: spent.last24h, + setValue: (val: string) => setLimitValues({ ...limitValues, daily: val }), + getValue: () => limitValues.daily, + }, + weekly: { + label: "Weekly (7 days)", + description: "Set a rolling 7-day spending limit", + currentLimit: limits.weekly, + spent: spent.last7d, + setValue: (val: string) => setLimitValues({ ...limitValues, weekly: val }), + getValue: () => limitValues.weekly, + }, + monthly: { + label: "Monthly (30 days)", + description: "Set a rolling 30-day spending limit", + currentLimit: limits.monthly, + spent: spent.last30d, + setValue: (val: string) => setLimitValues({ ...limitValues, monthly: val }), + getValue: () => limitValues.monthly, + }, + annual: { + label: "Annual (365 days)", + description: "Set a rolling 365-day spending limit", + currentLimit: limits.annual, + spent: spent.last365d, + setValue: (val: string) => setLimitValues({ ...limitValues, annual: val }), + getValue: () => limitValues.annual, + }, + } + + const handleSetLimit = async (period: LimitPeriod) => { + const config = periodConfig[period] + const limitValue = config.getValue() + + if (!limitValue || parseInt(limitValue) <= 0) { + alert("Please enter a valid limit in satoshis") + return + } + + setLoading(true) + try { + const satsValue = parseInt(limitValue) + switch (period) { + case "daily": + await setDailyLimit({ id, dailyLimitSats: satsValue }) + break + case "weekly": + await setWeeklyLimit({ id, weeklyLimitSats: satsValue }) + break + case "monthly": + await setMonthlyLimit({ id, monthlyLimitSats: satsValue }) + break + case "annual": + await setAnnualLimit({ id, annualLimitSats: satsValue }) + break + } + setOpen(false) + window.location.reload() // Refresh to show updated data + } catch (error) { + console.error("Error setting limit:", error) + alert("Failed to set limit. Please try again.") + } finally { + setLoading(false) + } + } + + const handleRemoveLimit = async (period: LimitPeriod) => { + const periodLabels = { + daily: "daily", + weekly: "weekly", + monthly: "monthly", + annual: "annual", + } + + if ( + !confirm( + `Are you sure you want to remove the ${periodLabels[period]} spending limit?`, + ) + ) { + return + } + + setLoading(true) + try { + switch (period) { + case "daily": + await removeLimit({ id }) + break + case "weekly": + await removeWeeklyLimit({ id }) + break + case "monthly": + await removeMonthlyLimit({ id }) + break + case "annual": + await removeAnnualLimit({ id }) + break + } + setOpen(false) + window.location.reload() // Refresh to show updated data + } catch (error) { + console.error("Error removing limit:", error) + alert("Failed to remove limit. Please try again.") + } finally { + setLoading(false) + } + } + + const formatSats = (sats: number | null) => { + if (sats === null) return "Unlimited" + return `${sats.toLocaleString()} sats` + } + + const hasAnyLimit = limits.daily || limits.weekly || limits.monthly || limits.annual + + return ( + <> + + + setOpen(false)}> + + Budget Limits + + Configure rolling budget limits for different time periods + + + setSelectedPeriod(value as LimitPeriod)} + > + + Daily + Weekly + Monthly + Annual + + + {(Object.keys(periodConfig) as LimitPeriod[]).map((period) => { + const config = periodConfig[period] + const remaining = config.currentLimit + ? config.currentLimit - config.spent + : null + + return ( + + + {config.description} + + {config.currentLimit && ( + + + + Current Limit:{" "} + {formatSats(config.currentLimit)} + + + Spent: {formatSats(config.spent)} + + + Remaining: {formatSats(remaining)} + + + + )} + + + {config.label} Limit (satoshis) + config.setValue(e.target.value)} + placeholder="Enter limit in sats (e.g., 100000)" + disabled={loading} + /> + + + + + {config.currentLimit && ( + + )} + + + + ) + })} + + + + + + + + + ) +} + +export default Limit diff --git a/apps/dashboard/components/api-keys/list.tsx b/apps/dashboard/components/api-keys/list.tsx index 13d8a9d217..0c03d79e59 100644 --- a/apps/dashboard/components/api-keys/list.tsx +++ b/apps/dashboard/components/api-keys/list.tsx @@ -1,9 +1,13 @@ +"use client" + import React from "react" import Table from "@mui/joy/Table" import Typography from "@mui/joy/Typography" import Divider from "@mui/joy/Divider" +import { Stack } from "@mui/joy" import RevokeKey from "./revoke" +import Limit from "./limit" import { formatDate, getScopeText } from "./utils" import { ApiKey } from "@/services/graphql/generated" @@ -25,29 +29,137 @@ const ApiKeysList: React.FC = ({ - - - - - - + + + + + + + - {activeKeys.map(({ id, name, expiresAt, lastUsedAt, scopes }) => { - return ( - - - - - - - - - ) - })} + {activeKeys.map( + ({ + id, + name, + expiresAt, + lastUsedAt, + scopes, + dailyLimitSats, + weeklyLimitSats, + monthlyLimitSats, + annualLimitSats, + spentLast24HSats, + spentLast7DSats, + spentLast30DSats, + spentLast365DSats, + }) => { + const remainingDailyLimitSats = + dailyLimitSats !== null && dailyLimitSats !== undefined + ? dailyLimitSats - (spentLast24HSats || 0) + : null + + const hasAnyLimit = + dailyLimitSats || weeklyLimitSats || monthlyLimitSats || annualLimitSats + + return ( + + + + + + + + + + ) + }, + )}
NameAPI Key IDScopeExpires AtLast UsedActionNameAPI Key IDScopeBudget LimitsExpires AtLast UsedActions
{name}{id}{getScopeText(scopes)}{expiresAt ? formatDate(expiresAt) : "Never"}{lastUsedAt ? formatDate(lastUsedAt) : "Never"} - -
{name}{id}{getScopeText(scopes)} + {hasAnyLimit ? ( + + {dailyLimitSats && ( +
+ + Daily: {dailyLimitSats.toLocaleString()}{" "} + sats + + + Spent: {spentLast24HSats?.toLocaleString() || 0} / + Remaining: {remainingDailyLimitSats?.toLocaleString() || 0} + +
+ )} + {weeklyLimitSats && ( +
+ + Weekly: {weeklyLimitSats.toLocaleString()}{" "} + sats + + + Spent: {spentLast7DSats?.toLocaleString() || 0} / Remaining:{" "} + {( + weeklyLimitSats - (spentLast7DSats || 0) + ).toLocaleString()} + +
+ )} + {monthlyLimitSats && ( +
+ + Monthly:{" "} + {monthlyLimitSats.toLocaleString()} sats + + + Spent: {spentLast30DSats?.toLocaleString() || 0} / + Remaining:{" "} + {( + monthlyLimitSats - (spentLast30DSats || 0) + ).toLocaleString()} + +
+ )} + {annualLimitSats && ( +
+ + Annual: {annualLimitSats.toLocaleString()}{" "} + sats + + + Spent: {spentLast365DSats?.toLocaleString() || 0} / + Remaining:{" "} + {( + annualLimitSats - (spentLast365DSats || 0) + ).toLocaleString()} + +
+ )} +
+ ) : ( + + Unlimited + + )} +
{expiresAt ? formatDate(expiresAt) : "Never"}{lastUsedAt ? formatDate(lastUsedAt) : "Never"} + + + + +
{activeKeys.length === 0 && No active keys to display.} diff --git a/apps/dashboard/services/graphql/generated.ts b/apps/dashboard/services/graphql/generated.ts index 712af87310..2e81506601 100644 --- a/apps/dashboard/services/graphql/generated.ts +++ b/apps/dashboard/services/graphql/generated.ts @@ -226,15 +226,31 @@ export type AccountUpdateNotificationSettingsPayload = { export type ApiKey = { readonly __typename: 'ApiKey'; + /** Annual spending limit in satoshis (rolling 365 days). Returns null if no limit is set. */ + readonly annualLimitSats?: Maybe; readonly createdAt: Scalars['Timestamp']['output']; + /** Daily spending limit in satoshis (rolling 24h window). Returns null if no limit is set. */ + readonly dailyLimitSats?: Maybe; readonly expired: Scalars['Boolean']['output']; readonly expiresAt?: Maybe; readonly id: Scalars['ID']['output']; readonly lastUsedAt?: Maybe; + /** Monthly spending limit in satoshis (rolling 30 days). Returns null if no limit is set. */ + readonly monthlyLimitSats?: Maybe; readonly name: Scalars['String']['output']; readonly readOnly: Scalars['Boolean']['output']; readonly revoked: Scalars['Boolean']['output']; readonly scopes: ReadonlyArray; + /** Amount spent in the last 7 days (rolling window) in satoshis */ + readonly spentLast7DSats: Scalars['Int']['output']; + /** Amount spent in the last 24 hours (rolling window) in satoshis */ + readonly spentLast24HSats: Scalars['Int']['output']; + /** Amount spent in the last 30 days (rolling window) in satoshis */ + readonly spentLast30DSats: Scalars['Int']['output']; + /** Amount spent in the last 365 days (rolling window) in satoshis */ + readonly spentLast365DSats: Scalars['Int']['output']; + /** Weekly spending limit in satoshis (rolling 7 days). Returns null if no limit is set. */ + readonly weeklyLimitSats?: Maybe; }; export type ApiKeyCreateInput = { @@ -249,6 +265,10 @@ export type ApiKeyCreatePayload = { readonly apiKeySecret: Scalars['String']['output']; }; +export type ApiKeyRemoveLimitInput = { + readonly id: Scalars['ID']['input']; +}; + export type ApiKeyRevokeInput = { readonly id: Scalars['ID']['input']; }; @@ -258,6 +278,31 @@ export type ApiKeyRevokePayload = { readonly apiKey: ApiKey; }; +export type ApiKeySetAnnualLimitInput = { + readonly annualLimitSats: Scalars['Int']['input']; + readonly id: Scalars['ID']['input']; +}; + +export type ApiKeySetDailyLimitInput = { + readonly dailyLimitSats: Scalars['Int']['input']; + readonly id: Scalars['ID']['input']; +}; + +export type ApiKeySetLimitPayload = { + readonly __typename: 'ApiKeySetLimitPayload'; + readonly apiKey: ApiKey; +}; + +export type ApiKeySetMonthlyLimitInput = { + readonly id: Scalars['ID']['input']; + readonly monthlyLimitSats: Scalars['Int']['input']; +}; + +export type ApiKeySetWeeklyLimitInput = { + readonly id: Scalars['ID']['input']; + readonly weeklyLimitSats: Scalars['Int']['input']; +}; + export type AuthTokenPayload = { readonly __typename: 'AuthTokenPayload'; readonly authToken?: Maybe; @@ -1032,7 +1077,15 @@ export type Mutation = { readonly accountUpdateDefaultWalletId: AccountUpdateDefaultWalletIdPayload; readonly accountUpdateDisplayCurrency: AccountUpdateDisplayCurrencyPayload; readonly apiKeyCreate: ApiKeyCreatePayload; + readonly apiKeyRemoveAnnualLimit: ApiKeySetLimitPayload; + readonly apiKeyRemoveDailyLimit: ApiKeySetLimitPayload; + readonly apiKeyRemoveMonthlyLimit: ApiKeySetLimitPayload; + readonly apiKeyRemoveWeeklyLimit: ApiKeySetLimitPayload; readonly apiKeyRevoke: ApiKeyRevokePayload; + readonly apiKeySetAnnualLimit: ApiKeySetLimitPayload; + readonly apiKeySetDailyLimit: ApiKeySetLimitPayload; + readonly apiKeySetMonthlyLimit: ApiKeySetLimitPayload; + readonly apiKeySetWeeklyLimit: ApiKeySetLimitPayload; readonly callbackEndpointAdd: CallbackEndpointAddPayload; readonly callbackEndpointDelete: SuccessPayload; readonly captchaCreateChallenge: CaptchaCreateChallengePayload; @@ -1191,11 +1244,51 @@ export type MutationApiKeyCreateArgs = { }; +export type MutationApiKeyRemoveAnnualLimitArgs = { + input: ApiKeyRemoveLimitInput; +}; + + +export type MutationApiKeyRemoveDailyLimitArgs = { + input: ApiKeyRemoveLimitInput; +}; + + +export type MutationApiKeyRemoveMonthlyLimitArgs = { + input: ApiKeyRemoveLimitInput; +}; + + +export type MutationApiKeyRemoveWeeklyLimitArgs = { + input: ApiKeyRemoveLimitInput; +}; + + export type MutationApiKeyRevokeArgs = { input: ApiKeyRevokeInput; }; +export type MutationApiKeySetAnnualLimitArgs = { + input: ApiKeySetAnnualLimitInput; +}; + + +export type MutationApiKeySetDailyLimitArgs = { + input: ApiKeySetDailyLimitInput; +}; + + +export type MutationApiKeySetMonthlyLimitArgs = { + input: ApiKeySetMonthlyLimitInput; +}; + + +export type MutationApiKeySetWeeklyLimitArgs = { + input: ApiKeySetWeeklyLimitInput; +}; + + export type MutationCallbackEndpointAddArgs = { input: CallbackEndpointAddInput; }; @@ -2464,7 +2557,7 @@ export type ApiKeyCreateMutationVariables = Exact<{ }>; -export type ApiKeyCreateMutation = { readonly __typename: 'Mutation', readonly apiKeyCreate: { readonly __typename: 'ApiKeyCreatePayload', readonly apiKeySecret: string, readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray } } }; +export type ApiKeyCreateMutation = { readonly __typename: 'Mutation', readonly apiKeyCreate: { readonly __typename: 'ApiKeyCreatePayload', readonly apiKeySecret: string, readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } }; export type ApiKeyRevokeMutationVariables = Exact<{ input: ApiKeyRevokeInput; @@ -2473,6 +2566,62 @@ export type ApiKeyRevokeMutationVariables = Exact<{ export type ApiKeyRevokeMutation = { readonly __typename: 'Mutation', readonly apiKeyRevoke: { readonly __typename: 'ApiKeyRevokePayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray } } }; +export type ApiKeySetDailyLimitMutationVariables = Exact<{ + input: ApiKeySetDailyLimitInput; +}>; + + +export type ApiKeySetDailyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetDailyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } }; + +export type ApiKeyRemoveDailyLimitMutationVariables = Exact<{ + input: ApiKeyRemoveLimitInput; +}>; + + +export type ApiKeyRemoveDailyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveDailyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } }; + +export type ApiKeySetWeeklyLimitMutationVariables = Exact<{ + input: ApiKeySetWeeklyLimitInput; +}>; + + +export type ApiKeySetWeeklyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetWeeklyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } }; + +export type ApiKeyRemoveWeeklyLimitMutationVariables = Exact<{ + input: ApiKeyRemoveLimitInput; +}>; + + +export type ApiKeyRemoveWeeklyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveWeeklyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } }; + +export type ApiKeySetMonthlyLimitMutationVariables = Exact<{ + input: ApiKeySetMonthlyLimitInput; +}>; + + +export type ApiKeySetMonthlyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetMonthlyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } }; + +export type ApiKeyRemoveMonthlyLimitMutationVariables = Exact<{ + input: ApiKeyRemoveLimitInput; +}>; + + +export type ApiKeyRemoveMonthlyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveMonthlyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } }; + +export type ApiKeySetAnnualLimitMutationVariables = Exact<{ + input: ApiKeySetAnnualLimitInput; +}>; + + +export type ApiKeySetAnnualLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetAnnualLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } }; + +export type ApiKeyRemoveAnnualLimitMutationVariables = Exact<{ + input: ApiKeyRemoveLimitInput; +}>; + + +export type ApiKeyRemoveAnnualLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveAnnualLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } }; + export type CallbackEndpointAddMutationVariables = Exact<{ input: CallbackEndpointAddInput; }>; @@ -2540,7 +2689,7 @@ export type UserTotpRegistrationValidateMutation = { readonly __typename: 'Mutat export type ApiKeysQueryVariables = Exact<{ [key: string]: never; }>; -export type ApiKeysQuery = { readonly __typename: 'Query', readonly me?: { readonly __typename: 'User', readonly apiKeys: ReadonlyArray<{ readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly readOnly: boolean, readonly scopes: ReadonlyArray }> } | null }; +export type ApiKeysQuery = { readonly __typename: 'Query', readonly me?: { readonly __typename: 'User', readonly apiKeys: ReadonlyArray<{ readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly readOnly: boolean, readonly scopes: ReadonlyArray, readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number }> } | null }; export type CallbackEndpointsQueryVariables = Exact<{ [key: string]: never; }>; @@ -2595,6 +2744,14 @@ export const ApiKeyCreateDocument = gql` lastUsedAt expiresAt scopes + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats } apiKeySecret } @@ -2668,6 +2825,358 @@ export function useApiKeyRevokeMutation(baseOptions?: Apollo.MutationHookOptions export type ApiKeyRevokeMutationHookResult = ReturnType; export type ApiKeyRevokeMutationResult = Apollo.MutationResult; export type ApiKeyRevokeMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeySetDailyLimitDocument = gql` + mutation ApiKeySetDailyLimit($input: ApiKeySetDailyLimitInput!) { + apiKeySetDailyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } +} + `; +export type ApiKeySetDailyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeySetDailyLimitMutation__ + * + * To run a mutation, you first call `useApiKeySetDailyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeySetDailyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeySetDailyLimitMutation, { data, loading, error }] = useApiKeySetDailyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeySetDailyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeySetDailyLimitDocument, options); + } +export type ApiKeySetDailyLimitMutationHookResult = ReturnType; +export type ApiKeySetDailyLimitMutationResult = Apollo.MutationResult; +export type ApiKeySetDailyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeyRemoveDailyLimitDocument = gql` + mutation ApiKeyRemoveDailyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveDailyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } +} + `; +export type ApiKeyRemoveDailyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeyRemoveDailyLimitMutation__ + * + * To run a mutation, you first call `useApiKeyRemoveDailyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeyRemoveDailyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeyRemoveDailyLimitMutation, { data, loading, error }] = useApiKeyRemoveDailyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeyRemoveDailyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeyRemoveDailyLimitDocument, options); + } +export type ApiKeyRemoveDailyLimitMutationHookResult = ReturnType; +export type ApiKeyRemoveDailyLimitMutationResult = Apollo.MutationResult; +export type ApiKeyRemoveDailyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeySetWeeklyLimitDocument = gql` + mutation ApiKeySetWeeklyLimit($input: ApiKeySetWeeklyLimitInput!) { + apiKeySetWeeklyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } +} + `; +export type ApiKeySetWeeklyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeySetWeeklyLimitMutation__ + * + * To run a mutation, you first call `useApiKeySetWeeklyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeySetWeeklyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeySetWeeklyLimitMutation, { data, loading, error }] = useApiKeySetWeeklyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeySetWeeklyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeySetWeeklyLimitDocument, options); + } +export type ApiKeySetWeeklyLimitMutationHookResult = ReturnType; +export type ApiKeySetWeeklyLimitMutationResult = Apollo.MutationResult; +export type ApiKeySetWeeklyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeyRemoveWeeklyLimitDocument = gql` + mutation ApiKeyRemoveWeeklyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveWeeklyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } +} + `; +export type ApiKeyRemoveWeeklyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeyRemoveWeeklyLimitMutation__ + * + * To run a mutation, you first call `useApiKeyRemoveWeeklyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeyRemoveWeeklyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeyRemoveWeeklyLimitMutation, { data, loading, error }] = useApiKeyRemoveWeeklyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeyRemoveWeeklyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeyRemoveWeeklyLimitDocument, options); + } +export type ApiKeyRemoveWeeklyLimitMutationHookResult = ReturnType; +export type ApiKeyRemoveWeeklyLimitMutationResult = Apollo.MutationResult; +export type ApiKeyRemoveWeeklyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeySetMonthlyLimitDocument = gql` + mutation ApiKeySetMonthlyLimit($input: ApiKeySetMonthlyLimitInput!) { + apiKeySetMonthlyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } +} + `; +export type ApiKeySetMonthlyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeySetMonthlyLimitMutation__ + * + * To run a mutation, you first call `useApiKeySetMonthlyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeySetMonthlyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeySetMonthlyLimitMutation, { data, loading, error }] = useApiKeySetMonthlyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeySetMonthlyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeySetMonthlyLimitDocument, options); + } +export type ApiKeySetMonthlyLimitMutationHookResult = ReturnType; +export type ApiKeySetMonthlyLimitMutationResult = Apollo.MutationResult; +export type ApiKeySetMonthlyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeyRemoveMonthlyLimitDocument = gql` + mutation ApiKeyRemoveMonthlyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveMonthlyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } +} + `; +export type ApiKeyRemoveMonthlyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeyRemoveMonthlyLimitMutation__ + * + * To run a mutation, you first call `useApiKeyRemoveMonthlyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeyRemoveMonthlyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeyRemoveMonthlyLimitMutation, { data, loading, error }] = useApiKeyRemoveMonthlyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeyRemoveMonthlyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeyRemoveMonthlyLimitDocument, options); + } +export type ApiKeyRemoveMonthlyLimitMutationHookResult = ReturnType; +export type ApiKeyRemoveMonthlyLimitMutationResult = Apollo.MutationResult; +export type ApiKeyRemoveMonthlyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeySetAnnualLimitDocument = gql` + mutation ApiKeySetAnnualLimit($input: ApiKeySetAnnualLimitInput!) { + apiKeySetAnnualLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } +} + `; +export type ApiKeySetAnnualLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeySetAnnualLimitMutation__ + * + * To run a mutation, you first call `useApiKeySetAnnualLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeySetAnnualLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeySetAnnualLimitMutation, { data, loading, error }] = useApiKeySetAnnualLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeySetAnnualLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeySetAnnualLimitDocument, options); + } +export type ApiKeySetAnnualLimitMutationHookResult = ReturnType; +export type ApiKeySetAnnualLimitMutationResult = Apollo.MutationResult; +export type ApiKeySetAnnualLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeyRemoveAnnualLimitDocument = gql` + mutation ApiKeyRemoveAnnualLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveAnnualLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } +} + `; +export type ApiKeyRemoveAnnualLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeyRemoveAnnualLimitMutation__ + * + * To run a mutation, you first call `useApiKeyRemoveAnnualLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeyRemoveAnnualLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeyRemoveAnnualLimitMutation, { data, loading, error }] = useApiKeyRemoveAnnualLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeyRemoveAnnualLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeyRemoveAnnualLimitDocument, options); + } +export type ApiKeyRemoveAnnualLimitMutationHookResult = ReturnType; +export type ApiKeyRemoveAnnualLimitMutationResult = Apollo.MutationResult; +export type ApiKeyRemoveAnnualLimitMutationOptions = Apollo.BaseMutationOptions; export const CallbackEndpointAddDocument = gql` mutation CallbackEndpointAdd($input: CallbackEndpointAddInput!) { callbackEndpointAdd(input: $input) { @@ -3051,6 +3560,14 @@ export const ApiKeysDocument = gql` expiresAt readOnly scopes + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats } } } @@ -3592,8 +4109,14 @@ export type ResolversTypes = { ApiKey: ResolverTypeWrapper; ApiKeyCreateInput: ApiKeyCreateInput; ApiKeyCreatePayload: ResolverTypeWrapper; + ApiKeyRemoveLimitInput: ApiKeyRemoveLimitInput; ApiKeyRevokeInput: ApiKeyRevokeInput; ApiKeyRevokePayload: ResolverTypeWrapper; + ApiKeySetAnnualLimitInput: ApiKeySetAnnualLimitInput; + ApiKeySetDailyLimitInput: ApiKeySetDailyLimitInput; + ApiKeySetLimitPayload: ResolverTypeWrapper; + ApiKeySetMonthlyLimitInput: ApiKeySetMonthlyLimitInput; + ApiKeySetWeeklyLimitInput: ApiKeySetWeeklyLimitInput; AuthToken: ResolverTypeWrapper; AuthTokenPayload: ResolverTypeWrapper & { errors: ReadonlyArray }>; Authorization: ResolverTypeWrapper; @@ -3831,8 +4354,14 @@ export type ResolversParentTypes = { ApiKey: ApiKey; ApiKeyCreateInput: ApiKeyCreateInput; ApiKeyCreatePayload: ApiKeyCreatePayload; + ApiKeyRemoveLimitInput: ApiKeyRemoveLimitInput; ApiKeyRevokeInput: ApiKeyRevokeInput; ApiKeyRevokePayload: ApiKeyRevokePayload; + ApiKeySetAnnualLimitInput: ApiKeySetAnnualLimitInput; + ApiKeySetDailyLimitInput: ApiKeySetDailyLimitInput; + ApiKeySetLimitPayload: ApiKeySetLimitPayload; + ApiKeySetMonthlyLimitInput: ApiKeySetMonthlyLimitInput; + ApiKeySetWeeklyLimitInput: ApiKeySetWeeklyLimitInput; AuthToken: Scalars['AuthToken']['output']; AuthTokenPayload: Omit & { errors: ReadonlyArray }; Authorization: Authorization; @@ -4147,15 +4676,23 @@ export type AccountUpdateNotificationSettingsPayloadResolvers = { + annualLimitSats?: Resolver, ParentType, ContextType>; createdAt?: Resolver; + dailyLimitSats?: Resolver, ParentType, ContextType>; expired?: Resolver; expiresAt?: Resolver, ParentType, ContextType>; id?: Resolver; lastUsedAt?: Resolver, ParentType, ContextType>; + monthlyLimitSats?: Resolver, ParentType, ContextType>; name?: Resolver; readOnly?: Resolver; revoked?: Resolver; scopes?: Resolver, ParentType, ContextType>; + spentLast7DSats?: Resolver; + spentLast24HSats?: Resolver; + spentLast30DSats?: Resolver; + spentLast365DSats?: Resolver; + weeklyLimitSats?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -4170,6 +4707,11 @@ export type ApiKeyRevokePayloadResolvers; }; +export type ApiKeySetLimitPayloadResolvers = { + apiKey?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export interface AuthTokenScalarConfig extends GraphQLScalarTypeConfig { name: 'AuthToken'; } @@ -4582,7 +5124,15 @@ export type MutationResolvers>; accountUpdateDisplayCurrency?: Resolver>; apiKeyCreate?: Resolver>; + apiKeyRemoveAnnualLimit?: Resolver>; + apiKeyRemoveDailyLimit?: Resolver>; + apiKeyRemoveMonthlyLimit?: Resolver>; + apiKeyRemoveWeeklyLimit?: Resolver>; apiKeyRevoke?: Resolver>; + apiKeySetAnnualLimit?: Resolver>; + apiKeySetDailyLimit?: Resolver>; + apiKeySetMonthlyLimit?: Resolver>; + apiKeySetWeeklyLimit?: Resolver>; callbackEndpointAdd?: Resolver>; callbackEndpointDelete?: Resolver>; captchaCreateChallenge?: Resolver; @@ -5190,6 +5740,7 @@ export type Resolvers = { ApiKey?: ApiKeyResolvers; ApiKeyCreatePayload?: ApiKeyCreatePayloadResolvers; ApiKeyRevokePayload?: ApiKeyRevokePayloadResolvers; + ApiKeySetLimitPayload?: ApiKeySetLimitPayloadResolvers; AuthToken?: GraphQLScalarType; AuthTokenPayload?: AuthTokenPayloadResolvers; Authorization?: AuthorizationResolvers; diff --git a/apps/dashboard/services/graphql/mutations/api-keys.ts b/apps/dashboard/services/graphql/mutations/api-keys.ts index a0a0a414d8..63ad967149 100644 --- a/apps/dashboard/services/graphql/mutations/api-keys.ts +++ b/apps/dashboard/services/graphql/mutations/api-keys.ts @@ -6,6 +6,22 @@ import { ApiKeyCreateMutation, ApiKeyRevokeDocument, ApiKeyRevokeMutation, + ApiKeySetDailyLimitDocument, + ApiKeySetDailyLimitMutation, + ApiKeySetWeeklyLimitDocument, + ApiKeySetWeeklyLimitMutation, + ApiKeySetMonthlyLimitDocument, + ApiKeySetMonthlyLimitMutation, + ApiKeySetAnnualLimitDocument, + ApiKeySetAnnualLimitMutation, + ApiKeyRemoveDailyLimitDocument, + ApiKeyRemoveDailyLimitMutation, + ApiKeyRemoveWeeklyLimitDocument, + ApiKeyRemoveWeeklyLimitMutation, + ApiKeyRemoveMonthlyLimitDocument, + ApiKeyRemoveMonthlyLimitMutation, + ApiKeyRemoveAnnualLimitDocument, + ApiKeyRemoveAnnualLimitMutation, Scope, } from "../generated" @@ -21,6 +37,14 @@ gql` lastUsedAt expiresAt scopes + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats } apiKeySecret } @@ -40,6 +64,142 @@ gql` } } } + + mutation ApiKeySetDailyLimit($input: ApiKeySetDailyLimitInput!) { + apiKeySetDailyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + + mutation ApiKeyRemoveDailyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveDailyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + + mutation ApiKeySetWeeklyLimit($input: ApiKeySetWeeklyLimitInput!) { + apiKeySetWeeklyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + + mutation ApiKeyRemoveWeeklyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveWeeklyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + + mutation ApiKeySetMonthlyLimit($input: ApiKeySetMonthlyLimitInput!) { + apiKeySetMonthlyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + + mutation ApiKeyRemoveMonthlyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveMonthlyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + + mutation ApiKeySetAnnualLimit($input: ApiKeySetAnnualLimitInput!) { + apiKeySetAnnualLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + + mutation ApiKeyRemoveAnnualLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveAnnualLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } ` export async function createApiKey({ @@ -77,3 +237,142 @@ export async function revokeApiKey({ id }: { id: string }) { throw new Error("Error in apiKeyRevoke") } } + +export async function setApiKeyDailyLimit({ + id, + dailyLimitSats, +}: { + id: string + dailyLimitSats: number +}) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeySetDailyLimitDocument, + variables: { input: { id, dailyLimitSats } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeySetDailyLimit ==> ", error) + throw new Error("Error in apiKeySetDailyLimit") + } +} + +// Keep old name for backward compatibility +export const setApiKeyLimit = setApiKeyDailyLimit + +export async function setApiKeyWeeklyLimit({ + id, + weeklyLimitSats, +}: { + id: string + weeklyLimitSats: number +}) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeySetWeeklyLimitDocument, + variables: { input: { id, weeklyLimitSats } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeySetWeeklyLimit ==> ", error) + throw new Error("Error in apiKeySetWeeklyLimit") + } +} + +export async function setApiKeyMonthlyLimit({ + id, + monthlyLimitSats, +}: { + id: string + monthlyLimitSats: number +}) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeySetMonthlyLimitDocument, + variables: { input: { id, monthlyLimitSats } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeySetMonthlyLimit ==> ", error) + throw new Error("Error in apiKeySetMonthlyLimit") + } +} + +export async function setApiKeyAnnualLimit({ + id, + annualLimitSats, +}: { + id: string + annualLimitSats: number +}) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeySetAnnualLimitDocument, + variables: { input: { id, annualLimitSats } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeySetAnnualLimit ==> ", error) + throw new Error("Error in apiKeySetAnnualLimit") + } +} + +export async function removeApiKeyLimit({ id }: { id: string }) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeyRemoveDailyLimitDocument, + variables: { input: { id } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeyRemoveDailyLimit ==> ", error) + throw new Error("Error in apiKeyRemoveDailyLimit") + } +} + +export async function removeApiKeyWeeklyLimit({ id }: { id: string }) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeyRemoveWeeklyLimitDocument, + variables: { input: { id } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeyRemoveWeeklyLimit ==> ", error) + throw new Error("Error in apiKeyRemoveWeeklyLimit") + } +} + +export async function removeApiKeyMonthlyLimit({ id }: { id: string }) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeyRemoveMonthlyLimitDocument, + variables: { input: { id } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeyRemoveMonthlyLimit ==> ", error) + throw new Error("Error in apiKeyRemoveMonthlyLimit") + } +} + +export async function removeApiKeyAnnualLimit({ id }: { id: string }) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeyRemoveAnnualLimitDocument, + variables: { input: { id } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeyRemoveAnnualLimit ==> ", error) + throw new Error("Error in apiKeyRemoveAnnualLimit") + } +} diff --git a/apps/dashboard/services/graphql/queries/api-keys.ts b/apps/dashboard/services/graphql/queries/api-keys.ts index 39c6af856f..6c1100e772 100644 --- a/apps/dashboard/services/graphql/queries/api-keys.ts +++ b/apps/dashboard/services/graphql/queries/api-keys.ts @@ -16,6 +16,14 @@ gql` expiresAt readOnly scopes + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats } } } From f510c2b9849841c966ce26e26513776402c3a6b5 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 14 Oct 2025 17:53:26 +0300 Subject: [PATCH 04/13] feat: update api-keys limits return type --- core/api-keys/src/limits/mod.rs | 144 ++++---------------------------- core/api-keys/src/server/mod.rs | 86 ++++++++++++++++--- 2 files changed, 90 insertions(+), 140 deletions(-) diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index e408a065b3..568c93ebbb 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -11,10 +11,10 @@ pub use error::*; #[derive(Debug, Clone)] pub struct LimitCheckResult { pub allowed: bool, - pub daily_limit_sats: Option, // None if no limit configured - pub weekly_limit_sats: Option, // None if no limit configured - pub monthly_limit_sats: Option, // None if no limit configured - pub annual_limit_sats: Option, // None if no limit configured + pub daily_limit_sats: Option, // None if no limit configured + pub weekly_limit_sats: Option, // None if no limit configured + pub monthly_limit_sats: Option, // None if no limit configured + pub annual_limit_sats: Option, // None if no limit configured pub spent_last_24h_sats: i64, pub spent_last_7d_sats: i64, pub spent_last_30d_sats: i64, @@ -23,18 +23,14 @@ pub struct LimitCheckResult { #[derive(Debug, Clone)] pub struct SpendingSummary { - pub daily_limit_sats: Option, // None if no limit configured - pub weekly_limit_sats: Option, // None if no limit configured - pub monthly_limit_sats: Option, // None if no limit configured - pub annual_limit_sats: Option, // None if no limit configured + pub daily_limit_sats: Option, // None if no limit configured + pub weekly_limit_sats: Option, // None if no limit configured + pub monthly_limit_sats: Option, // None if no limit configured + pub annual_limit_sats: Option, // None if no limit configured pub spent_last_24h_sats: i64, pub spent_last_7d_sats: i64, pub spent_last_30d_sats: i64, pub spent_last_365d_sats: i64, - pub transaction_count_24h: i32, - pub transaction_count_7d: i32, - pub transaction_count_30d: i32, - pub transaction_count_365d: i32, } #[derive(Debug, Clone)] @@ -174,11 +170,6 @@ impl Limits { let spent_30d = self.get_spending_last_30d(api_key_id).await?; let spent_365d = self.get_spending_last_365d(api_key_id).await?; - let transaction_count_24h = self.get_transaction_count_last_24h(api_key_id).await?; - let transaction_count_7d = self.get_transaction_count_last_7d(api_key_id).await?; - let transaction_count_30d = self.get_transaction_count_last_30d(api_key_id).await?; - let transaction_count_365d = self.get_transaction_count_last_365d(api_key_id).await?; - Ok(SpendingSummary { daily_limit_sats: limits.daily_limit_sats, weekly_limit_sats: limits.weekly_limit_sats, @@ -188,10 +179,6 @@ impl Limits { spent_last_7d_sats: spent_7d, spent_last_30d_sats: spent_30d, spent_last_365d_sats: spent_365d, - transaction_count_24h, - transaction_count_7d, - transaction_count_30d, - transaction_count_365d, }) } @@ -305,10 +292,7 @@ impl Limits { /// Remove a daily limit for an API key #[tracing::instrument(name = "limits.remove_daily_limit", skip(self))] - pub async fn remove_daily_limit( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result<(), LimitError> { + pub async fn remove_daily_limit(&self, api_key_id: IdentityApiKeyId) -> Result<(), LimitError> { sqlx::query( r#" UPDATE api_key_limits @@ -397,10 +381,7 @@ impl Limits { /// Remove all limits for an API key (reverts to unlimited) #[tracing::instrument(name = "limits.remove_all_limits", skip(self))] - pub async fn remove_all_limits( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result<(), LimitError> { + pub async fn remove_all_limits(&self, api_key_id: IdentityApiKeyId) -> Result<(), LimitError> { sqlx::query( r#" DELETE FROM api_key_limits @@ -433,10 +414,7 @@ impl Limits { // Private helper methods /// Get all configured limits for an API key - async fn get_all_limits( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result { + async fn get_all_limits(&self, api_key_id: IdentityApiKeyId) -> Result { let row = sqlx::query( r#" SELECT daily_limit_sats, weekly_limit_sats, monthly_limit_sats, annual_limit_sats @@ -466,10 +444,7 @@ impl Limits { } /// Delete limit row if all limits are NULL - async fn cleanup_empty_limits( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result<(), LimitError> { + async fn cleanup_empty_limits(&self, api_key_id: IdentityApiKeyId) -> Result<(), LimitError> { sqlx::query( r#" DELETE FROM api_key_limits @@ -488,10 +463,7 @@ impl Limits { } /// Calculate total spending in the last 24 hours (rolling window) - async fn get_spending_last_24h( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result { + async fn get_spending_last_24h(&self, api_key_id: IdentityApiKeyId) -> Result { let row = sqlx::query( r#" SELECT COALESCE(SUM(amount_sats), 0)::bigint as spent @@ -508,10 +480,7 @@ impl Limits { } /// Calculate total spending in the last 7 days (rolling window) - async fn get_spending_last_7d( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result { + async fn get_spending_last_7d(&self, api_key_id: IdentityApiKeyId) -> Result { let row = sqlx::query( r#" SELECT COALESCE(SUM(amount_sats), 0)::bigint as spent @@ -528,10 +497,7 @@ impl Limits { } /// Calculate total spending in the last 30 days (rolling window) - async fn get_spending_last_30d( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result { + async fn get_spending_last_30d(&self, api_key_id: IdentityApiKeyId) -> Result { let row = sqlx::query( r#" SELECT COALESCE(SUM(amount_sats), 0)::bigint as spent @@ -566,84 +532,4 @@ impl Limits { Ok(row.get("spent")) } - - /// Get transaction count in the last 24 hours - async fn get_transaction_count_last_24h( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result { - let row = sqlx::query( - r#" - SELECT COUNT(*)::int as count - FROM api_key_transactions - WHERE api_key_id = $1 - AND created_at > NOW() - INTERVAL '24 hours' - "#, - ) - .bind(api_key_id) - .fetch_one(&self.pool) - .await?; - - Ok(row.get("count")) - } - - /// Get transaction count in the last 7 days - async fn get_transaction_count_last_7d( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result { - let row = sqlx::query( - r#" - SELECT COUNT(*)::int as count - FROM api_key_transactions - WHERE api_key_id = $1 - AND created_at > NOW() - INTERVAL '7 days' - "#, - ) - .bind(api_key_id) - .fetch_one(&self.pool) - .await?; - - Ok(row.get("count")) - } - - /// Get transaction count in the last 30 days - async fn get_transaction_count_last_30d( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result { - let row = sqlx::query( - r#" - SELECT COUNT(*)::int as count - FROM api_key_transactions - WHERE api_key_id = $1 - AND created_at > NOW() - INTERVAL '30 days' - "#, - ) - .bind(api_key_id) - .fetch_one(&self.pool) - .await?; - - Ok(row.get("count")) - } - - /// Get transaction count in the last 365 days - async fn get_transaction_count_last_365d( - &self, - api_key_id: IdentityApiKeyId, - ) -> Result { - let row = sqlx::query( - r#" - SELECT COUNT(*)::int as count - FROM api_key_transactions - WHERE api_key_id = $1 - AND created_at > NOW() - INTERVAL '365 days' - "#, - ) - .bind(api_key_id) - .fetch_one(&self.pool) - .await?; - - Ok(row.get("count")) - } } diff --git a/core/api-keys/src/server/mod.rs b/core/api-keys/src/server/mod.rs index 3bc5910523..a5b3457e15 100644 --- a/core/api-keys/src/server/mod.rs +++ b/core/api-keys/src/server/mod.rs @@ -100,10 +100,25 @@ struct LimitsCheckQuery { struct LimitsCheckResponse { allowed: bool, #[serde(skip_serializing_if = "Option::is_none")] - remaining_sats: Option, - #[serde(skip_serializing_if = "Option::is_none")] daily_limit_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + weekly_limit_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + monthly_limit_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + annual_limit_sats: Option, spent_last_24h_sats: i64, + spent_last_7d_sats: i64, + spent_last_30d_sats: i64, + spent_last_365d_sats: i64, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_daily_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_weekly_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_monthly_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_annual_sats: Option, } #[derive(Debug, Deserialize)] @@ -115,10 +130,24 @@ struct LimitsRemainingQuery { struct LimitsRemainingResponse { #[serde(skip_serializing_if = "Option::is_none")] daily_limit_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + weekly_limit_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + monthly_limit_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + annual_limit_sats: Option, spent_last_24h_sats: i64, + spent_last_7d_sats: i64, + spent_last_30d_sats: i64, + spent_last_365d_sats: i64, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_daily_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_weekly_sats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + remaining_monthly_sats: Option, #[serde(skip_serializing_if = "Option::is_none")] - remaining_sats: Option, - transaction_count: i32, + remaining_annual_sats: Option, } #[derive(Debug, Deserialize)] @@ -205,16 +234,34 @@ async fn limits_check_handler( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Calculate remaining from limit and spent - let remaining_sats = result + // Calculate remaining for each time period + let remaining_daily_sats = result .daily_limit_sats .map(|limit| limit - result.spent_last_24h_sats); + let remaining_weekly_sats = result + .weekly_limit_sats + .map(|limit| limit - result.spent_last_7d_sats); + let remaining_monthly_sats = result + .monthly_limit_sats + .map(|limit| limit - result.spent_last_30d_sats); + let remaining_annual_sats = result + .annual_limit_sats + .map(|limit| limit - result.spent_last_365d_sats); Ok(Json(LimitsCheckResponse { allowed: result.allowed, - remaining_sats, daily_limit_sats: result.daily_limit_sats, + weekly_limit_sats: result.weekly_limit_sats, + monthly_limit_sats: result.monthly_limit_sats, + annual_limit_sats: result.annual_limit_sats, spent_last_24h_sats: result.spent_last_24h_sats, + spent_last_7d_sats: result.spent_last_7d_sats, + spent_last_30d_sats: result.spent_last_30d_sats, + spent_last_365d_sats: result.spent_last_365d_sats, + remaining_daily_sats, + remaining_weekly_sats, + remaining_monthly_sats, + remaining_annual_sats, })) } @@ -242,16 +289,33 @@ async fn limits_remaining_handler( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Calculate remaining from limit and spent - let remaining_sats = summary + // Calculate remaining for each time period + let remaining_daily_sats = summary .daily_limit_sats .map(|limit| limit - summary.spent_last_24h_sats); + let remaining_weekly_sats = summary + .weekly_limit_sats + .map(|limit| limit - summary.spent_last_7d_sats); + let remaining_monthly_sats = summary + .monthly_limit_sats + .map(|limit| limit - summary.spent_last_30d_sats); + let remaining_annual_sats = summary + .annual_limit_sats + .map(|limit| limit - summary.spent_last_365d_sats); Ok(Json(LimitsRemainingResponse { daily_limit_sats: summary.daily_limit_sats, + weekly_limit_sats: summary.weekly_limit_sats, + monthly_limit_sats: summary.monthly_limit_sats, + annual_limit_sats: summary.annual_limit_sats, spent_last_24h_sats: summary.spent_last_24h_sats, - remaining_sats, - transaction_count: summary.transaction_count_24h, + spent_last_7d_sats: summary.spent_last_7d_sats, + spent_last_30d_sats: summary.spent_last_30d_sats, + spent_last_365d_sats: summary.spent_last_365d_sats, + remaining_daily_sats, + remaining_weekly_sats, + remaining_monthly_sats, + remaining_annual_sats, })) } From 6e8b5581f8ba0d5af25a2f689a96fb52ca315b3e Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 14 Oct 2025 17:55:13 +0300 Subject: [PATCH 05/13] feat: add budget limit checks for all payment flows --- apps/dashboard/components/api-keys/limit.tsx | 1 + core/api/src/app/errors.ts | 2 + core/api/src/app/payments/send-intraledger.ts | 38 ++++++++++ core/api/src/app/payments/send-lightning.ts | 16 +++- core/api/src/app/payments/send-on-chain.ts | 73 +++++++++++++++++++ core/api/src/domain/api-keys/errors.ts | 25 +------ core/api/src/domain/errors.ts | 7 -- core/api/src/graphql/error-map.ts | 2 +- core/api/src/services/api-keys/client.ts | 21 ++++-- 9 files changed, 145 insertions(+), 40 deletions(-) diff --git a/apps/dashboard/components/api-keys/limit.tsx b/apps/dashboard/components/api-keys/limit.tsx index 5a9a40862e..7f95e84580 100644 --- a/apps/dashboard/components/api-keys/limit.tsx +++ b/apps/dashboard/components/api-keys/limit.tsx @@ -14,6 +14,7 @@ import { TabPanel, Box, } from "@mui/joy" + import { setDailyLimit, setWeeklyLimit, diff --git a/core/api/src/app/errors.ts b/core/api/src/app/errors.ts index 9eac8c0bf2..c8b7a4a51b 100644 --- a/core/api/src/app/errors.ts +++ b/core/api/src/app/errors.ts @@ -25,6 +25,7 @@ import * as WalletInvoiceErrors from "@/domain/wallet-invoices/errors" import * as SupportError from "@/domain/support/errors" import * as OathkeeperError from "@/domain/oathkeeper/errors" import * as KratosErrors from "@/domain/kratos/errors" +import * as ApiKeysErrors from "@/domain/api-keys/errors" import * as LedgerFacadeErrors from "@/services/ledger/domain/errors" import * as BriaEventErrors from "@/services/bria/errors" @@ -58,6 +59,7 @@ export const ApplicationErrors = { ...SupportError, ...OathkeeperError, ...KratosErrors, + ...ApiKeysErrors, ...LedgerFacadeErrors, ...BriaEventErrors, diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index af7a768dfa..ebc00aaa2b 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -44,6 +44,11 @@ import { WalletsRepository, } from "@/services/mongoose" import { NotificationsService } from "@/services/notifications" +import { + checkApiKeySpendingLimit, + recordApiKeySpending, +} from "@/services/api-keys/client" +import { ApiKeyLimitExceededError } from "@/domain/api-keys" const dealer = DealerPriceService() @@ -53,6 +58,7 @@ const intraledgerPaymentSendWalletId = async ({ amount: uncheckedAmount, memo, senderWalletId: uncheckedSenderWalletId, + apiKeyId, }: IntraLedgerPaymentSendWalletIdArgs): Promise => { const validatedPaymentInputs = await validateIntraledgerPaymentInputs({ uncheckedSenderWalletId, @@ -128,6 +134,7 @@ const intraledgerPaymentSendWalletId = async ({ recipientUser, senderUser, memo, + apiKeyId, }) if (paymentSendResult instanceof Error) return paymentSendResult @@ -231,6 +238,7 @@ const executePaymentViaIntraledger = async < recipientUser, senderUser, memo, + apiKeyId, }: { paymentFlow: PaymentFlow senderAccount: Account @@ -239,6 +247,7 @@ const executePaymentViaIntraledger = async < recipientUser: User senderUser: User memo: string | null + apiKeyId?: string }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -247,6 +256,24 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + // Check API key spending limit if authenticated via API key + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const apiKeyLimitCheck = await checkApiKeySpendingLimit({ + apiKeyId, + amountSats, + }) + if (apiKeyLimitCheck instanceof Error) return apiKeyLimitCheck + if (!apiKeyLimitCheck.allowed) { + return new ApiKeyLimitExceededError({ + daily: apiKeyLimitCheck.remaining_daily_sats ?? null, + weekly: apiKeyLimitCheck.remaining_weekly_sats ?? null, + monthly: apiKeyLimitCheck.remaining_monthly_sats ?? null, + annual: apiKeyLimitCheck.remaining_annual_sats ?? null, + }) + } + } + const checkLimits = senderAccount.id === recipientAccount.id ? checkTradeIntraAccountLimits @@ -318,6 +345,17 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) + // Record API key spending after successful payment + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const recordResult = await recordApiKeySpending({ + apiKeyId, + amountSats, + transactionId: journalId, + }) + if (recordResult instanceof Error) return recordResult + } + return { status: PaymentSendStatus.Success, transaction: senderWalletTransaction, diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index 81880ba624..a5fde040ac 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -58,7 +58,6 @@ import { checkApiKeySpendingLimit, recordApiKeySpending, } from "@/services/api-keys/client" -import { ApiKeyDailyLimitExceededError } from "@/domain/errors" import * as LedgerFacade from "@/services/ledger/facade" import { @@ -81,6 +80,7 @@ import { } from "@/app/wallets" import { ResourceExpiredLockServiceError } from "@/domain/lock" +import { ApiKeyLimitExceededError } from "@/domain/api-keys" const dealer = DealerPriceService() const paymentFlowRepo = PaymentFlowStateRepository(defaultTimeToExpiryInSeconds) @@ -467,7 +467,12 @@ const executePaymentViaIntraledger = async < }) if (apiKeyLimitCheck instanceof Error) return apiKeyLimitCheck if (!apiKeyLimitCheck.allowed) { - return new ApiKeyDailyLimitExceededError(apiKeyLimitCheck.remaining_sats) + return new ApiKeyLimitExceededError({ + daily: apiKeyLimitCheck.remaining_daily_sats ?? null, + weekly: apiKeyLimitCheck.remaining_weekly_sats ?? null, + monthly: apiKeyLimitCheck.remaining_monthly_sats ?? null, + annual: apiKeyLimitCheck.remaining_annual_sats ?? null, + }) } } @@ -788,7 +793,12 @@ const executePaymentViaLn = async ({ }) if (apiKeyLimitCheck instanceof Error) return apiKeyLimitCheck if (!apiKeyLimitCheck.allowed) { - return new ApiKeyDailyLimitExceededError(apiKeyLimitCheck.remaining_sats) + return new ApiKeyLimitExceededError({ + daily: apiKeyLimitCheck.remaining_daily_sats ?? null, + weekly: apiKeyLimitCheck.remaining_weekly_sats ?? null, + monthly: apiKeyLimitCheck.remaining_monthly_sats ?? null, + annual: apiKeyLimitCheck.remaining_annual_sats ?? null, + }) } } diff --git a/core/api/src/app/payments/send-on-chain.ts b/core/api/src/app/payments/send-on-chain.ts index a648dba63a..111d76936c 100644 --- a/core/api/src/app/payments/send-on-chain.ts +++ b/core/api/src/app/payments/send-on-chain.ts @@ -52,6 +52,11 @@ import { } from "@/services/mongoose" import { NotificationsService } from "@/services/notifications" import { addAttributesToCurrentSpan } from "@/services/tracing" +import { + checkApiKeySpendingLimit, + recordApiKeySpending, +} from "@/services/api-keys/client" +import { ApiKeyLimitExceededError } from "@/domain/api-keys" const { dustThreshold } = getOnChainWalletConfig() const dealer = DealerPriceService() @@ -65,6 +70,7 @@ const payOnChainByWalletId = async ({ speed, memo, sendAll, + apiKeyId, }: PayOnChainByWalletIdArgs): Promise => { const latestAccountState = await AccountsRepository().findById(senderAccount.id) if (latestAccountState instanceof Error) return latestAccountState @@ -178,6 +184,7 @@ const payOnChainByWalletId = async ({ senderAccount, memo, sendAll, + apiKeyId, }) } @@ -193,6 +200,7 @@ const payOnChainByWalletId = async ({ memo, sendAll, logger: onchainLogger, + apiKeyId, }) } @@ -248,11 +256,13 @@ const executePaymentViaIntraledger = async < senderAccount, memo, sendAll, + apiKeyId, }: { builder: OPFBWithConversion | OPFBWithError senderAccount: Account memo: string | null sendAll: boolean + apiKeyId?: string }): Promise => { const paymentFlow = await builder.withoutMinerFee() if (paymentFlow instanceof Error) return paymentFlow @@ -289,6 +299,24 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + // Check API key spending limit if authenticated via API key + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const apiKeyLimitCheck = await checkApiKeySpendingLimit({ + apiKeyId, + amountSats, + }) + if (apiKeyLimitCheck instanceof Error) return apiKeyLimitCheck + if (!apiKeyLimitCheck.allowed) { + return new ApiKeyLimitExceededError({ + daily: apiKeyLimitCheck.remaining_daily_sats ?? null, + weekly: apiKeyLimitCheck.remaining_weekly_sats ?? null, + monthly: apiKeyLimitCheck.remaining_monthly_sats ?? null, + annual: apiKeyLimitCheck.remaining_annual_sats ?? null, + }) + } + } + const checkLimits = senderAccount.id === recipientAccount.id ? checkTradeIntraAccountLimits @@ -357,6 +385,17 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) + // Record API key spending after successful payment + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const recordResult = await recordApiKeySpending({ + apiKeyId, + amountSats, + transactionId: journalId, + }) + if (recordResult instanceof Error) return recordResult + } + return { status: PaymentSendStatus.Success, transaction: senderWalletTransaction, @@ -523,6 +562,7 @@ const executePaymentViaOnChain = async < memo, sendAll, logger, + apiKeyId, }: { builder: OPFBWithConversion | OPFBWithError senderDisplayCurrency: DisplayCurrency @@ -530,6 +570,7 @@ const executePaymentViaOnChain = async < memo: string | null sendAll: boolean logger: Logger + apiKeyId?: string }): Promise => { const senderWalletDescriptor = await builder.senderWalletDescriptor() if (senderWalletDescriptor instanceof Error) return senderWalletDescriptor @@ -541,6 +582,24 @@ const executePaymentViaOnChain = async < const priceRatioForLimits = await getPriceRatioForLimits(proposedAmounts) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + // Check API key spending limit if authenticated via API key + if (apiKeyId) { + const amountSats = Number(proposedAmounts.btc.amount) + const apiKeyLimitCheck = await checkApiKeySpendingLimit({ + apiKeyId, + amountSats, + }) + if (apiKeyLimitCheck instanceof Error) return apiKeyLimitCheck + if (!apiKeyLimitCheck.allowed) { + return new ApiKeyLimitExceededError({ + daily: apiKeyLimitCheck.remaining_daily_sats ?? null, + weekly: apiKeyLimitCheck.remaining_weekly_sats ?? null, + monthly: apiKeyLimitCheck.remaining_monthly_sats ?? null, + annual: apiKeyLimitCheck.remaining_annual_sats ?? null, + }) + } + } + const limitCheck = await checkWithdrawalLimits({ amount: proposedAmounts.usd, accountId: senderWalletDescriptor.accountId, @@ -572,6 +631,20 @@ const executePaymentViaOnChain = async < }) if (walletTransaction instanceof Error) return walletTransaction + // Record API key spending after successful payment + if (apiKeyId) { + const paymentFlow = await builder.proposedAmounts() + if (!(paymentFlow instanceof Error)) { + const amountSats = Number(paymentFlow.btc.amount) + const recordResult = await recordApiKeySpending({ + apiKeyId, + amountSats, + transactionId: journalId, + }) + if (recordResult instanceof Error) return recordResult + } + } + return { status: PaymentSendStatus.Success, transaction: walletTransaction } } diff --git a/core/api/src/domain/api-keys/errors.ts b/core/api/src/domain/api-keys/errors.ts index dd28b5942f..acfe2656a0 100644 --- a/core/api/src/domain/api-keys/errors.ts +++ b/core/api/src/domain/api-keys/errors.ts @@ -1,32 +1,13 @@ import { DomainError, ErrorLevel } from "@/domain/shared" -export class ApiKeyDailyLimitExceededError extends DomainError { +export class ApiKeyLimitExceededError extends DomainError { level = ErrorLevel.Warn - - constructor(public readonly remainingSats: number | null) { - super( - remainingSats !== null - ? `API key daily spending limit exceeded. Remaining: ${remainingSats} sats` - : "API key daily spending limit exceeded", - ) - this.name = "ApiKeyDailyLimitExceededError" - } } export class ApiKeyLimitCheckError extends DomainError { - level = ErrorLevel.Warn - - constructor(message?: string) { - super(message || "Failed to check API key spending limit") - this.name = "ApiKeyLimitCheckError" - } + level = ErrorLevel.Critical } export class ApiKeySpendingRecordError extends DomainError { - level = ErrorLevel.Info // Lower severity - this doesn't block the payment - - constructor(message?: string) { - super(message || "Failed to record API key spending") - this.name = "ApiKeySpendingRecordError" - } + level = ErrorLevel.Critical } diff --git a/core/api/src/domain/errors.ts b/core/api/src/domain/errors.ts index 7974e0561d..bd1307394e 100644 --- a/core/api/src/domain/errors.ts +++ b/core/api/src/domain/errors.ts @@ -153,13 +153,6 @@ export class LimitsExceededError extends ValidationError {} export class WithdrawalLimitsExceededError extends LimitsExceededError {} export class IntraledgerLimitsExceededError extends LimitsExceededError {} export class TradeIntraAccountLimitsExceededError extends LimitsExceededError {} -export class ApiKeyDailyLimitExceededError extends LimitsExceededError {} -export class ApiKeyLimitCheckError extends DomainError { - level = ErrorLevel.Warn -} -export class ApiKeySpendingRecordError extends DomainError { - level = ErrorLevel.Critical -} export class LnRouteValidationError extends ValidationError {} export class BadAmountForRouteError extends LnRouteValidationError {} diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 0980a6cf7d..eec7881d06 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -63,7 +63,7 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { message = error.message return new TransactionRestrictedError({ message, logger: baseLogger }) - case "ApiKeyDailyLimitExceededError": + case "ApiKeyLimitExceededError": message = error.message return new TransactionRestrictedError({ message, logger: baseLogger }) diff --git a/core/api/src/services/api-keys/client.ts b/core/api/src/services/api-keys/client.ts index 2e0ce465c2..77f67b412e 100644 --- a/core/api/src/services/api-keys/client.ts +++ b/core/api/src/services/api-keys/client.ts @@ -1,24 +1,31 @@ import axios from "axios" import { baseLogger } from "@/services/logger" -import { - ApiKeyLimitCheckError, - ApiKeySpendingRecordError, -} from "@/domain/api-keys" +import { ApiKeyLimitCheckError, ApiKeySpendingRecordError } from "@/domain/api-keys" import { getApiKeysServiceUrl } from "@/config" const API_KEYS_SERVICE_URL = getApiKeysServiceUrl() export type LimitCheckResult = { allowed: boolean - remaining_sats: number | null daily_limit_sats: number | null + weekly_limit_sats: number | null + monthly_limit_sats: number | null + annual_limit_sats: number | null spent_last_24h_sats: number + spent_last_7d_sats: number + spent_last_30d_sats: number + spent_last_365d_sats: number + remaining_daily_sats: number | null + remaining_weekly_sats: number | null + remaining_monthly_sats: number | null + remaining_annual_sats: number | null } /** - * Check if a spending amount would exceed the API key's daily limit (rolling 24h window) - * Returns allowed=true if no limit is configured for the API key + * Check if a spending amount would exceed any of the API key's spending limits + * (daily, weekly, monthly, or annual - all using rolling time windows) + * Returns allowed=true if no limits are configured for the API key */ export const checkApiKeySpendingLimit = async ({ apiKeyId, From eb1e72f8a5116b2358644203fe41cb05c7c09383 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 14 Oct 2025 18:21:48 +0300 Subject: [PATCH 06/13] chore: add apiKeyIds to payment graphql resolves --- core/api/src/app/wallets/index.types.d.ts | 2 ++ .../graphql/public/root/mutation/onchain-payment-send-all.ts | 3 ++- .../src/graphql/public/root/mutation/onchain-payment-send.ts | 3 ++- .../public/root/mutation/onchain-usd-payment-send-as-sats.ts | 3 ++- .../graphql/public/root/mutation/onchain-usd-payment-send.ts | 3 ++- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index fa5327f84a..29d76e2590 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -128,6 +128,7 @@ type PayAllOnChainByWalletIdArgs = { address: string speed: PayoutSpeed memo: string | null + apiKeyId?: string } type PayOnChainByWalletIdWithoutCurrencyArgs = { @@ -137,6 +138,7 @@ type PayOnChainByWalletIdWithoutCurrencyArgs = { address: string speed: PayoutSpeed memo: string | null + apiKeyId?: string } type PayOnChainByWalletIdArgs = PayOnChainByWalletIdWithoutCurrencyArgs & { diff --git a/core/api/src/graphql/public/root/mutation/onchain-payment-send-all.ts b/core/api/src/graphql/public/root/mutation/onchain-payment-send-all.ts index 7382c84dc7..d392b0b4ff 100644 --- a/core/api/src/graphql/public/root/mutation/onchain-payment-send-all.ts +++ b/core/api/src/graphql/public/root/mutation/onchain-payment-send-all.ts @@ -42,7 +42,7 @@ const OnChainPaymentSendAllMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainPaymentSendAllInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, address, memo, speed } = args.input if (walletId instanceof Error) { @@ -67,6 +67,7 @@ const OnChainPaymentSendAllMutation = GT.Field< address, speed, memo, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/onchain-payment-send.ts b/core/api/src/graphql/public/root/mutation/onchain-payment-send.ts index 79c0476940..3efd51ed7e 100644 --- a/core/api/src/graphql/public/root/mutation/onchain-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/onchain-payment-send.ts @@ -45,7 +45,7 @@ const OnChainPaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainPaymentSendInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, address, amount, memo, speed } = args.input if (walletId instanceof Error) { @@ -75,6 +75,7 @@ const OnChainPaymentSendMutation = GT.Field< address, speed, memo, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send-as-sats.ts b/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send-as-sats.ts index 3e16c5ae50..7b4f0e69ba 100644 --- a/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send-as-sats.ts +++ b/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send-as-sats.ts @@ -45,7 +45,7 @@ const OnChainUsdPaymentSendAsBtcDenominatedMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainUsdPaymentSendAsBtcDenominatedInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, address, amount, memo, speed } = args.input if (walletId instanceof Error) { @@ -71,6 +71,7 @@ const OnChainUsdPaymentSendAsBtcDenominatedMutation = GT.Field< address, speed, memo, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send.ts b/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send.ts index 4f9b1a63e0..44e555d6b5 100644 --- a/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send.ts @@ -45,7 +45,7 @@ const OnChainUsdPaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainUsdPaymentSendInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, address, amount, memo, speed } = args.input if (walletId instanceof Error) { @@ -71,6 +71,7 @@ const OnChainUsdPaymentSendMutation = GT.Field< address, speed, memo, + apiKeyId, }) if (result instanceof Error) { From 5462753b8be46f1b27abdf247792cf1f83f92700 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 14 Oct 2025 22:37:45 +0300 Subject: [PATCH 07/13] feat: add limit inputs to create api key form --- apps/dashboard/app/api-keys/server-actions.ts | 42 ++++++++++ apps/dashboard/components/api-keys/form.tsx | 84 +++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/apps/dashboard/app/api-keys/server-actions.ts b/apps/dashboard/app/api-keys/server-actions.ts index 537c9ddd2a..638a53ed48 100644 --- a/apps/dashboard/app/api-keys/server-actions.ts +++ b/apps/dashboard/app/api-keys/server-actions.ts @@ -124,6 +124,48 @@ export const createApiKeyServerAction = async ( } } + // Set budget limits if provided + if (data?.apiKeyCreate.apiKey.id) { + const apiKeyId = data.apiKeyCreate.apiKey.id + try { + const dailyLimitSats = form.get("dailyLimitSats") + if (dailyLimitSats && dailyLimitSats !== "") { + const limit = parseInt(dailyLimitSats as string, 10) + if (limit > 0) { + await setApiKeyDailyLimit({ id: apiKeyId, dailyLimitSats: limit }) + } + } + + const weeklyLimitSats = form.get("weeklyLimitSats") + if (weeklyLimitSats && weeklyLimitSats !== "") { + const limit = parseInt(weeklyLimitSats as string, 10) + if (limit > 0) { + await setApiKeyWeeklyLimit({ id: apiKeyId, weeklyLimitSats: limit }) + } + } + + const monthlyLimitSats = form.get("monthlyLimitSats") + if (monthlyLimitSats && monthlyLimitSats !== "") { + const limit = parseInt(monthlyLimitSats as string, 10) + if (limit > 0) { + await setApiKeyMonthlyLimit({ id: apiKeyId, monthlyLimitSats: limit }) + } + } + + const annualLimitSats = form.get("annualLimitSats") + if (annualLimitSats && annualLimitSats !== "") { + const limit = parseInt(annualLimitSats as string, 10) + if (limit > 0) { + await setApiKeyAnnualLimit({ id: apiKeyId, annualLimitSats: limit }) + } + } + } catch (err) { + console.log("error in setting API key limits ", err) + // Don't fail the entire operation if limits fail to set + // The API key was created successfully + } + } + return { error: false, message: "API Key created successfully", diff --git a/apps/dashboard/components/api-keys/form.tsx b/apps/dashboard/components/api-keys/form.tsx index 9d71c925f4..ecdec3f881 100644 --- a/apps/dashboard/components/api-keys/form.tsx +++ b/apps/dashboard/components/api-keys/form.tsx @@ -23,6 +23,7 @@ type ApiKeyFormProps = { const ApiKeyForm = ({ state, formAction }: ApiKeyFormProps) => { const [enableCustomExpiresInDays, setEnableCustomExpiresInDays] = useState(false) const [expiresInDays, setExpiresInDays] = useState(null) + const [showSpendingLimits, setShowSpendingLimits] = useState(false) const handleSubmit = async (event: React.FormEvent) => { event.preventDefault() @@ -53,6 +54,11 @@ const ApiKeyForm = ({ state, formAction }: ApiKeyFormProps) => { )} {state.error && } + + {showSpendingLimits && } @@ -179,6 +185,84 @@ const ScopeCheckboxes = () => ( ) +const SpendingLimitsToggle = ({ + showSpendingLimits, + setShowSpendingLimits, +}: { + showSpendingLimits: boolean + setShowSpendingLimits: (value: boolean) => void +}) => ( + + setShowSpendingLimits(e.target.checked)} + label="Set budget limits" + /> + +) + +const SpendingLimitsInputs = () => ( + + + Limits (in satoshis) + + + Daily Limit + + Rolling 24-hour window + + + Weekly Limit + + Rolling 7-day window + + + Monthly Limit + + Rolling 30-day window + + + Annual Limit + + Rolling 365-day window + + +) + const SubmitButton = () => ( Date: Tue, 14 Oct 2025 22:45:56 +0300 Subject: [PATCH 08/13] chore: adjust api keys table column sizing --- apps/dashboard/components/api-keys/list.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/components/api-keys/list.tsx b/apps/dashboard/components/api-keys/list.tsx index 0c03d79e59..ff5792f59d 100644 --- a/apps/dashboard/components/api-keys/list.tsx +++ b/apps/dashboard/components/api-keys/list.tsx @@ -31,8 +31,8 @@ const ApiKeysList: React.FC = ({ Name API Key ID - Scope - Budget Limits + Scope + Budget Limits Expires At Last Used Actions From e9b278df1ab18ba9de22ec09bebcca9d7c02836a Mon Sep 17 00:00:00 2001 From: basarrcan Date: Wed, 15 Oct 2025 15:48:47 +0300 Subject: [PATCH 09/13] chore: add missing apiKeyId context to intraledger payment --- .../graphql/public/root/mutation/intraledger-payment-send.ts | 3 ++- .../public/root/mutation/intraledger-usd-payment-send.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/core/api/src/graphql/public/root/mutation/intraledger-payment-send.ts b/core/api/src/graphql/public/root/mutation/intraledger-payment-send.ts index 0744d46af0..191d418a99 100644 --- a/core/api/src/graphql/public/root/mutation/intraledger-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/intraledger-payment-send.ts @@ -30,7 +30,7 @@ const IntraLedgerPaymentSendMutation = GT.Field( args: { input: { type: GT.NonNull(IntraLedgerPaymentSendInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, recipientWalletId, amount, memo } = args.input for (const input of [walletId, recipientWalletId, amount, memo]) { if (input instanceof Error) { @@ -54,6 +54,7 @@ const IntraLedgerPaymentSendMutation = GT.Field( amount, senderWalletId: walletId, senderAccount: domainAccount, + apiKeyId, }) if (result instanceof Error) { return { status: "failed", errors: [mapAndParseErrorForGqlResponse(result)] } diff --git a/core/api/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts b/core/api/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts index 0aa7dd3a3d..14457d251d 100644 --- a/core/api/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts @@ -30,7 +30,7 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field { + resolve: async (_, args, { domainAccount, apiKeyId }: GraphQLPublicContextAuth) => { const { walletId, recipientWalletId, amount, memo } = args.input for (const input of [walletId, recipientWalletId, amount, memo]) { if (input instanceof Error) { @@ -54,6 +54,7 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field Date: Wed, 15 Oct 2025 15:49:50 +0300 Subject: [PATCH 10/13] chore: add internal secret protection to limit endpoints --- core/api-keys/src/server/config.rs | 7 ++ core/api-keys/src/server/mod.rs | 93 ++++++++++++++++++++---- core/api/src/config/env.ts | 2 + core/api/src/config/index.ts | 2 + core/api/src/services/api-keys/client.ts | 9 ++- 5 files changed, 99 insertions(+), 14 deletions(-) diff --git a/core/api-keys/src/server/config.rs b/core/api-keys/src/server/config.rs index 8af9edc016..9708bcbc0c 100644 --- a/core/api-keys/src/server/config.rs +++ b/core/api-keys/src/server/config.rs @@ -8,6 +8,8 @@ pub struct ServerConfig { pub api_key_auth_header: String, #[serde(default = "default_jwks_url")] pub jwks_url: String, + #[serde(default = "default_internal_auth_secret")] + pub internal_auth_secret: String, } impl Default for ServerConfig { @@ -16,6 +18,7 @@ impl Default for ServerConfig { port: default_port(), api_key_auth_header: default_api_key_auth_header(), jwks_url: default_jwks_url(), + internal_auth_secret: default_internal_auth_secret(), } } } @@ -31,3 +34,7 @@ fn default_api_key_auth_header() -> String { fn default_jwks_url() -> String { "http://localhost:4456/.well-known/jwks.json".to_string() } + +fn default_internal_auth_secret() -> String { + "dev-only-insecure-secret".to_string() +} diff --git a/core/api-keys/src/server/mod.rs b/core/api-keys/src/server/mod.rs index a5b3457e15..ccd57dee42 100644 --- a/core/api-keys/src/server/mod.rs +++ b/core/api-keys/src/server/mod.rs @@ -4,8 +4,8 @@ mod jwks; use async_graphql::*; use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::{ - extract::{Query, State}, - http::StatusCode, + extract::{FromRef, FromRequestParts, Query, State}, + http::{request::Parts, StatusCode}, routing::{get, post}, Extension, Json, Router, }; @@ -33,6 +33,59 @@ pub struct JwtClaims { scope: String, } +#[derive(Clone)] +struct InternalAuthSecret(String); + +#[derive(Clone)] +struct InternalState { + limits: Arc, + internal_auth_secret: InternalAuthSecret, +} + +impl axum::extract::FromRef for Arc { + fn from_ref(state: &InternalState) -> Self { + state.limits.clone() + } +} + +impl axum::extract::FromRef for InternalAuthSecret { + fn from_ref(state: &InternalState) -> Self { + state.internal_auth_secret.clone() + } +} + +struct InternalAuth; + +#[axum::async_trait] +impl FromRequestParts for InternalAuth +where + InternalAuthSecret: axum::extract::FromRef, + S: Send + Sync, +{ + type Rejection = (StatusCode, String); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let secret = InternalAuthSecret::from_ref(state); + let auth_header = parts + .headers + .get("X-Internal-Auth") + .and_then(|h| h.to_str().ok()) + .ok_or(( + StatusCode::UNAUTHORIZED, + "Missing X-Internal-Auth header".to_string(), + ))?; + + if auth_header != secret.0 { + return Err(( + StatusCode::UNAUTHORIZED, + "Invalid internal auth secret".to_string(), + )); + } + + Ok(InternalAuth) + } +} + pub async fn run_server(config: ServerConfig, api_keys_app: ApiKeysApp) -> anyhow::Result<()> { let schema = graphql::schema(Some(api_keys_app.clone())); let limits = Arc::new(Limits::new(api_keys_app.pool())); @@ -49,27 +102,38 @@ pub async fn run_server(config: ServerConfig, api_keys_app: ApiKeysApp) -> anyho cleanup_old_transactions_periodically(cleanup_limits).await; }); - let app = Router::new() + let internal_state = InternalState { + limits: limits.clone(), + internal_auth_secret: InternalAuthSecret(config.internal_auth_secret.clone()), + }; + + // Internal routes that require internal auth + let internal_routes = Router::new() .route( - "/graphql", - get(playground).post(axum::routing::post(graphql_handler)), + "/limits/check", + get(limits_check_handler), ) .route( - "/auth/check", - get(check_handler).with_state((config.api_key_auth_header, api_keys_app)), + "/limits/remaining", + get(limits_remaining_handler), ) .route( - "/limits/check", - get(limits_check_handler).with_state(limits.clone()), + "/spending/record", + post(spending_record_handler), ) + .with_state(internal_state); + + // Public routes + let app = Router::new() .route( - "/limits/remaining", - get(limits_remaining_handler).with_state(limits.clone()), + "/graphql", + get(playground).post(axum::routing::post(graphql_handler)), ) .route( - "/spending/record", - post(spending_record_handler).with_state(limits.clone()), + "/auth/check", + get(check_handler).with_state((config.api_key_auth_header, api_keys_app)), ) + .merge(internal_routes) .with_state(JwtDecoderState { decoder: jwks_decoder, }) @@ -215,6 +279,7 @@ async fn playground() -> impl axum::response::IntoResponse { fields(api_key_id, amount_sats) )] async fn limits_check_handler( + _auth: InternalAuth, State(limits): State>, Query(params): Query, ) -> Result, (StatusCode, String)> { @@ -271,6 +336,7 @@ async fn limits_check_handler( fields(api_key_id) )] async fn limits_remaining_handler( + _auth: InternalAuth, State(limits): State>, Query(params): Query, ) -> Result, (StatusCode, String)> { @@ -325,6 +391,7 @@ async fn limits_remaining_handler( fields(api_key_id, amount_sats) )] async fn spending_record_handler( + _auth: InternalAuth, State(limits): State>, Json(payload): Json, ) -> Result { diff --git a/core/api/src/config/env.ts b/core/api/src/config/env.ts index ad4e4aa989..476b7ea5cc 100644 --- a/core/api/src/config/env.ts +++ b/core/api/src/config/env.ts @@ -63,6 +63,7 @@ export const env = createEnv({ .default(6685), API_KEYS_SERVICE_URL: z.string().url().default("http://localhost:5397"), + API_KEYS_INTERNAL_AUTH_SECRET: z.string().min(1).default("dev-only-insecure-secret"), GEETEST_ID: z.string().min(1).optional(), GEETEST_KEY: z.string().min(1).optional(), @@ -196,6 +197,7 @@ export const env = createEnv({ NOTIFICATIONS_PORT: process.env.NOTIFICATIONS_PORT, API_KEYS_SERVICE_URL: process.env.API_KEYS_SERVICE_URL || "http://localhost:5397", + API_KEYS_INTERNAL_AUTH_SECRET: process.env.API_KEYS_INTERNAL_AUTH_SECRET, GEETEST_ID: process.env.GEETEST_ID, GEETEST_KEY: process.env.GEETEST_KEY, diff --git a/core/api/src/config/index.ts b/core/api/src/config/index.ts index 14bb0deac1..cf05a1e0e3 100644 --- a/core/api/src/config/index.ts +++ b/core/api/src/config/index.ts @@ -86,6 +86,8 @@ export const getBriaConfig = getBriaPartialConfigFromYaml export const getApiKeysServiceUrl = () => env.API_KEYS_SERVICE_URL +export const getApiKeysInternalAuthSecret = () => env.API_KEYS_INTERNAL_AUTH_SECRET + export const isTelegramPassportEnabled = () => !!env.TELEGRAM_BOT_API_TOKEN && !!env.TELEGRAM_PASSPORT_PRIVATE_KEY diff --git a/core/api/src/services/api-keys/client.ts b/core/api/src/services/api-keys/client.ts index 77f67b412e..1bec9a38fd 100644 --- a/core/api/src/services/api-keys/client.ts +++ b/core/api/src/services/api-keys/client.ts @@ -2,9 +2,10 @@ import axios from "axios" import { baseLogger } from "@/services/logger" import { ApiKeyLimitCheckError, ApiKeySpendingRecordError } from "@/domain/api-keys" -import { getApiKeysServiceUrl } from "@/config" +import { getApiKeysServiceUrl, getApiKeysInternalAuthSecret } from "@/config" const API_KEYS_SERVICE_URL = getApiKeysServiceUrl() +const INTERNAL_AUTH_SECRET = getApiKeysInternalAuthSecret() export type LimitCheckResult = { allowed: boolean @@ -42,6 +43,9 @@ export const checkApiKeySpendingLimit = async ({ api_key_id: apiKeyId, amount_sats: amountSats, }, + headers: { + "X-Internal-Auth": INTERNAL_AUTH_SECRET, + }, timeout: 5000, // 5 second timeout }, ) @@ -78,6 +82,9 @@ export const recordApiKeySpending = async ({ transaction_id: transactionId, }, { + headers: { + "X-Internal-Auth": INTERNAL_AUTH_SECRET, + }, timeout: 5000, // 5 second timeout }, ) From e69cfe6d4a6aa73d997f928b515114f809b0b8a0 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Wed, 15 Oct 2025 15:51:15 +0300 Subject: [PATCH 11/13] test: add api-key limits bats tests --- bats/core/api-keys/api-keys-limits.bats | 540 ++++++++++++++++++++++ bats/gql/api-key-remove-annual-limit.gql | 10 + bats/gql/api-key-remove-daily-limit.gql | 10 + bats/gql/api-key-remove-monthly-limit.gql | 10 + bats/gql/api-key-remove-weekly-limit.gql | 10 + bats/gql/api-key-set-annual-limit.gql | 10 + bats/gql/api-key-set-daily-limit.gql | 10 + bats/gql/api-key-set-monthly-limit.gql | 10 + bats/gql/api-key-set-weekly-limit.gql | 10 + bats/gql/api-keys.gql | 8 + 10 files changed, 628 insertions(+) create mode 100644 bats/core/api-keys/api-keys-limits.bats create mode 100644 bats/gql/api-key-remove-annual-limit.gql create mode 100644 bats/gql/api-key-remove-daily-limit.gql create mode 100644 bats/gql/api-key-remove-monthly-limit.gql create mode 100644 bats/gql/api-key-remove-weekly-limit.gql create mode 100644 bats/gql/api-key-set-annual-limit.gql create mode 100644 bats/gql/api-key-set-daily-limit.gql create mode 100644 bats/gql/api-key-set-monthly-limit.gql create mode 100644 bats/gql/api-key-set-weekly-limit.gql diff --git a/bats/core/api-keys/api-keys-limits.bats b/bats/core/api-keys/api-keys-limits.bats new file mode 100644 index 0000000000..cebd01ae65 --- /dev/null +++ b/bats/core/api-keys/api-keys-limits.bats @@ -0,0 +1,540 @@ +#!/usr/bin/env bats + +load "../../helpers/_common.bash" +load "../../helpers/cli.bash" +load "../../helpers/user.bash" +load "../../helpers/onchain.bash" +load "../../helpers/ln.bash" + +random_uuid() { + if [[ -e /proc/sys/kernel/random/uuid ]]; then + cat /proc/sys/kernel/random/uuid + else + uuidgen + fi +} + +new_key_name() { + random_uuid +} + +ALICE='alice' +BOB='bob' + +setup_file() { + clear_cache + + # Ensure LND has sufficient balance for lightning tests + lnd1_balance=$(lnd_cli channelbalance | jq -r '.balance // 0') + if [[ $lnd1_balance -lt "1000000" ]]; then + create_user 'lnd_funding' + fund_user_lightning 'lnd_funding' 'lnd_funding.btc_wallet_id' '5000000' + fi + + create_user "$ALICE" + fund_user_onchain "$ALICE" 'btc_wallet' + fund_user_onchain "$ALICE" 'usd_wallet' + + create_user "$BOB" +} + +@test "api-keys-limits: create key and set daily limit" { + key_name="$(new_key_name)" + cache_value 'limit_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + + exec_graphql 'alice' 'api-key-create' "$variables" + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + + cache_value "api-key-limit-secret" "$secret" + + name=$(echo "$key" | jq -r '.name') + [[ "${name}" = "${key_name}" ]] || exit 1 + + key_id=$(echo "$key" | jq -r '.id') + cache_value "limit-api-key-id" "$key_id" + + # Set daily limit to 10000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":10000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + daily_limit="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.dailyLimitSats')" + [[ "${daily_limit}" = "10000" ]] || exit 1 + + spent_24h="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.spentLast24HSats')" + [[ "${spent_24h}" = "0" ]] || exit 1 +} + +@test "api-keys-limits: can send payment within limit" { + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=5000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Check spending was recorded + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .spentLast24HSats')" + [[ "${spent_24h}" -ge "$amount" ]] || exit 1 +} + +@test "api-keys-limits: cannot exceed daily limit" { + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=6000 # Would exceed 10000 daily limit + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + + # Should fail due to limit + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + errors="$(graphql_output '.data.intraLedgerPaymentSend.errors | length')" + [[ "${errors}" -ge "1" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].message')" + [[ "${error_msg}" == *"daily"* ]] || exit 1 +} + +@test "api-keys-limits: set weekly limit" { + key_id=$(read_value "limit-api-key-id") + + # Set weekly limit to 50000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"weeklyLimitSats\":50000}}" + exec_graphql 'alice' 'api-key-set-weekly-limit' "$variables" + + weekly_limit="$(graphql_output '.data.apiKeySetWeeklyLimit.apiKey.weeklyLimitSats')" + [[ "${weekly_limit}" = "50000" ]] || exit 1 +} + +@test "api-keys-limits: remove daily limit" { + key_id=$(read_value "limit-api-key-id") + + variables="{\"input\":{\"id\":\"${key_id}\"}}" + exec_graphql 'alice' 'api-key-remove-daily-limit' "$variables" + + daily_limit="$(graphql_output '.data.apiKeyRemoveDailyLimit.apiKey.dailyLimitSats')" + [[ "${daily_limit}" = "null" ]] || exit 1 +} + +@test "api-keys-limits: can send after removing daily limit (but weekly still applies)" { + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=3000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Check total spending across all time periods + exec_graphql 'alice' 'api-keys' + spent_7d="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .spentLast7DSats')" + + # Should have accumulated spending from previous tests + [[ "${spent_7d}" -ge "8000" ]] || exit 1 +} + +@test "api-keys-limits: set monthly and annual limits" { + key_id=$(read_value "limit-api-key-id") + + # Set monthly limit to 100000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"monthlyLimitSats\":100000}}" + exec_graphql 'alice' 'api-key-set-monthly-limit' "$variables" + monthly_limit="$(graphql_output '.data.apiKeySetMonthlyLimit.apiKey.monthlyLimitSats')" + [[ "${monthly_limit}" = "100000" ]] || exit 1 + + spent_30d="$(graphql_output '.data.apiKeySetMonthlyLimit.apiKey.spentLast30DSats')" + [[ "${spent_30d}" -ge "8000" ]] || exit 1 + + # Set annual limit to 500000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"annualLimitSats\":500000}}" + exec_graphql 'alice' 'api-key-set-annual-limit' "$variables" + annual_limit="$(graphql_output '.data.apiKeySetAnnualLimit.apiKey.annualLimitSats')" + [[ "${annual_limit}" = "500000" ]] || exit 1 + + spent_365d="$(graphql_output '.data.apiKeySetAnnualLimit.apiKey.spentLast365DSats')" + [[ "${spent_365d}" -ge "8000" ]] || exit 1 +} + +@test "api-keys-limits: multiple limits active - respects most restrictive" { + # At this point we have: + # - No daily limit (removed) + # - Weekly: 50000 sats (spent: ~8000) + # - Monthly: 100000 sats (spent: ~8000) + # - Annual: 500000 sats (spent: ~8000) + + # Try to send 45000 sats - this would exceed weekly limit + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=45000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + + # Should fail due to weekly limit + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].message')" + [[ "${error_msg}" == *"weekly"* ]] || exit 1 +} + +@test "api-keys-limits: can send within all active limits" { + # Send 30000 sats - within weekly (50000 - 8000 = 42000 remaining) + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=30000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending updated across all time windows + exec_graphql 'alice' 'api-keys' + key_data="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'")')" + + spent_24h=$(echo "$key_data" | jq -r '.spentLast24HSats') + spent_7d=$(echo "$key_data" | jq -r '.spentLast7DSats') + spent_30d=$(echo "$key_data" | jq -r '.spentLast30DSats') + spent_365d=$(echo "$key_data" | jq -r '.spentLast365DSats') + + [[ "${spent_24h}" -ge "30000" ]] || exit 1 + [[ "${spent_7d}" -ge "38000" ]] || exit 1 + [[ "${spent_30d}" -ge "38000" ]] || exit 1 + [[ "${spent_365d}" -ge "38000" ]] || exit 1 +} + +@test "api-keys-limits: spending tracked consistently across time windows" { + exec_graphql 'alice' 'api-keys' + key_data="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'")')" + + # Verify all limits are still set + daily_limit=$(echo "$key_data" | jq -r '.dailyLimitSats') + weekly_limit=$(echo "$key_data" | jq -r '.weeklyLimitSats') + monthly_limit=$(echo "$key_data" | jq -r '.monthlyLimitSats') + annual_limit=$(echo "$key_data" | jq -r '.annualLimitSats') + + [[ "${daily_limit}" = "null" ]] || exit 1 + [[ "${weekly_limit}" = "50000" ]] || exit 1 + [[ "${monthly_limit}" = "100000" ]] || exit 1 + [[ "${annual_limit}" = "500000" ]] || exit 1 + + # Verify spending is consistent across all time windows (since all payments are within last 24h) + spent_24h=$(echo "$key_data" | jq -r '.spentLast24HSats') + spent_7d=$(echo "$key_data" | jq -r '.spentLast7DSats') + spent_30d=$(echo "$key_data" | jq -r '.spentLast30DSats') + spent_365d=$(echo "$key_data" | jq -r '.spentLast365DSats') + + [[ "${spent_24h}" = "${spent_7d}" ]] || exit 1 + [[ "${spent_7d}" = "${spent_30d}" ]] || exit 1 + [[ "${spent_30d}" = "${spent_365d}" ]] || exit 1 +} + +@test "api-keys-limits: update existing limit to lower value" { + key_id=$(read_value "limit-api-key-id") + + # Update weekly limit to 40000 (already spent ~38000) + variables="{\"input\":{\"id\":\"${key_id}\",\"weeklyLimitSats\":40000}}" + exec_graphql 'alice' 'api-key-set-weekly-limit' "$variables" + weekly_limit="$(graphql_output '.data.apiKeySetWeeklyLimit.apiKey.weeklyLimitSats')" + [[ "${weekly_limit}" = "40000" ]] || exit 1 + + # Try to send 3000 - should fail as it would exceed updated limit + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=3000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].message')" + [[ "${error_msg}" == *"weekly"* ]] || exit 1 +} + +@test "api-keys-limits: remove all limits" { + key_id=$(read_value "limit-api-key-id") + + # Remove weekly limit + variables="{\"input\":{\"id\":\"${key_id}\"}}" + exec_graphql 'alice' 'api-key-remove-weekly-limit' "$variables" + weekly_limit="$(graphql_output '.data.apiKeyRemoveWeeklyLimit.apiKey.weeklyLimitSats')" + [[ "${weekly_limit}" = "null" ]] || exit 1 + + # Remove monthly limit + exec_graphql 'alice' 'api-key-remove-monthly-limit' "$variables" + monthly_limit="$(graphql_output '.data.apiKeyRemoveMonthlyLimit.apiKey.monthlyLimitSats')" + [[ "${monthly_limit}" = "null" ]] || exit 1 + + # Remove annual limit + exec_graphql 'alice' 'api-key-remove-annual-limit' "$variables" + annual_limit="$(graphql_output '.data.apiKeyRemoveAnnualLimit.apiKey.annualLimitSats')" + [[ "${annual_limit}" = "null" ]] || exit 1 +} + +@test "api-keys-limits: can send large amount with no limits" { + # With all limits removed, should be able to send larger amounts + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=100000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Spending should still be tracked even without limits + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .spentLast24HSats')" + [[ "${spent_24h}" -ge "130000" ]] || exit 1 +} + +# ============================================================================ +# Tests for different payment flows (Lightning & Onchain) +# ============================================================================ + +@test "api-keys-limits: lightning payment respects limits" { + # Create new API key with daily limit for lightning tests + key_name="$(new_key_name)" + cache_value 'ln_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + exec_graphql 'alice' 'api-key-create' "$variables" + + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + key_id=$(echo "$key" | jq -r '.id') + + cache_value "api-key-ln-secret" "$secret" + cache_value "ln-api-key-id" "$key_id" + + # Set daily limit to 5000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":5000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + # Create invoice for 3000 sats + invoice_response="$(lnd_outside_cli addinvoice --amt 3000)" + payment_request="$(echo $invoice_response | jq -r '.payment_request')" + payment_hash=$(echo $invoice_response | jq -r '.r_hash') + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg payment_request "$payment_request" \ + '{input: {walletId: $wallet_id, paymentRequest: $payment_request}}' + ) + + # Send lightning payment with API key + exec_graphql 'api-key-ln-secret' 'ln-invoice-payment-send' "$variables" + send_status="$(graphql_output '.data.lnInvoicePaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending was recorded + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .spentLast24HSats')" + [[ "${spent_24h}" -ge "3000" ]] || exit 1 +} + +@test "api-keys-limits: lightning payment exceeding limit fails" { + # Try to send 3000 more sats (would exceed 5000 limit) + invoice_response="$(lnd_outside_cli addinvoice --amt 3000)" + payment_request="$(echo $invoice_response | jq -r '.payment_request')" + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg payment_request "$payment_request" \ + '{input: {walletId: $wallet_id, paymentRequest: $payment_request}}' + ) + + exec_graphql 'api-key-ln-secret' 'ln-invoice-payment-send' "$variables" + send_status="$(graphql_output '.data.lnInvoicePaymentSend.status')" + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.lnInvoicePaymentSend.errors[0].message')" + [[ "${error_msg}" == *"daily"* ]] || exit 1 +} + +@test "api-keys-limits: onchain payment respects limits" { + # Create new API key with daily limit for onchain tests + key_name="$(new_key_name)" + cache_value 'onchain_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + exec_graphql 'alice' 'api-key-create' "$variables" + + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + key_id=$(echo "$key" | jq -r '.id') + + cache_value "api-key-onchain-secret" "$secret" + cache_value "onchain-api-key-id" "$key_id" + + # Set daily limit to 10000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":10000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + # Create onchain address + onchain_address=$(bitcoin_cli getnewaddress) + + # Send onchain payment for 5000 sats with API key + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg address "$onchain_address" \ + --argjson amount "5000" \ + '{input: {walletId: $wallet_id, address: $address, amount: $amount}}' + ) + + exec_graphql 'api-key-onchain-secret' 'on-chain-payment-send' "$variables" + send_status="$(graphql_output '.data.onChainPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending was recorded + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .spentLast24HSats')" + [[ "${spent_24h}" -ge "5000" ]] || exit 1 +} + +@test "api-keys-limits: onchain payment exceeding limit fails" { + # Try to send 6000 more sats (would exceed 10000 limit) + onchain_address=$(bitcoin_cli getnewaddress) + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg address "$onchain_address" \ + --argjson amount "6000" \ + '{input: {walletId: $wallet_id, address: $address, amount: $amount}}' + ) + + exec_graphql 'api-key-onchain-secret' 'on-chain-payment-send' "$variables" + send_status="$(graphql_output '.data.onChainPaymentSend.status')" + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.onChainPaymentSend.errors[0].message')" + [[ "${error_msg}" == *"daily"* ]] || exit 1 +} + +@test "api-keys-limits: mixed payment flows tracked separately per key" { + # Verify that each API key tracks its own spending independently + + # Check intraledger key spending (original key from earlier tests) + exec_graphql 'alice' 'api-keys' + intraledger_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .spentLast24HSats')" + [[ "${intraledger_spent}" -ge "130000" ]] || exit 1 + + # Check lightning key spending (separate key) + ln_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .spentLast24HSats')" + [[ "${ln_spent}" -ge "3000" ]] || exit 1 + [[ "${ln_spent}" -lt "10000" ]] || exit 1 + + # Check onchain key spending (separate key) + onchain_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .spentLast24HSats')" + [[ "${onchain_spent}" -ge "5000" ]] || exit 1 + [[ "${onchain_spent}" -lt "10000" ]] || exit 1 + + # Each key should have independent spending totals + [[ "${intraledger_spent}" != "${ln_spent}" ]] || exit 1 + [[ "${intraledger_spent}" != "${onchain_spent}" ]] || exit 1 +} + +@test "api-keys-limits: USD wallet payments also respect limits" { + # Create API key with daily limit for USD wallet tests + key_name="$(new_key_name)" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + exec_graphql 'alice' 'api-key-create' "$variables" + + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + key_id=$(echo "$key" | jq -r '.id') + + cache_value "api-key-usd-secret" "$secret" + + # Set daily limit to 50000 sats (in satoshi equivalent) + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":50000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + # Send USD intraledger payment (amount in cents) + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.usd_wallet_id)" \ + --arg recipient_wallet_id "$(read_value $BOB.usd_wallet_id)" \ + --argjson amount "25" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-usd-secret' 'intraledger-usd-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerUsdPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending was recorded (converted to sats) + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$key_name'") | .spentLast24HSats')" + # USD amount converted to sats should be tracked + [[ "${spent_24h}" -gt "0" ]] || exit 1 +} diff --git a/bats/gql/api-key-remove-annual-limit.gql b/bats/gql/api-key-remove-annual-limit.gql new file mode 100644 index 0000000000..68784a58bd --- /dev/null +++ b/bats/gql/api-key-remove-annual-limit.gql @@ -0,0 +1,10 @@ +mutation apiKeyRemoveAnnualLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveAnnualLimit(input: $input) { + apiKey { + id + name + annualLimitSats + spentLast365DSats + } + } +} diff --git a/bats/gql/api-key-remove-daily-limit.gql b/bats/gql/api-key-remove-daily-limit.gql new file mode 100644 index 0000000000..3d02197780 --- /dev/null +++ b/bats/gql/api-key-remove-daily-limit.gql @@ -0,0 +1,10 @@ +mutation apiKeyRemoveDailyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveDailyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + spentLast24HSats + } + } +} diff --git a/bats/gql/api-key-remove-monthly-limit.gql b/bats/gql/api-key-remove-monthly-limit.gql new file mode 100644 index 0000000000..692ed2211b --- /dev/null +++ b/bats/gql/api-key-remove-monthly-limit.gql @@ -0,0 +1,10 @@ +mutation apiKeyRemoveMonthlyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveMonthlyLimit(input: $input) { + apiKey { + id + name + monthlyLimitSats + spentLast30DSats + } + } +} diff --git a/bats/gql/api-key-remove-weekly-limit.gql b/bats/gql/api-key-remove-weekly-limit.gql new file mode 100644 index 0000000000..65806230e6 --- /dev/null +++ b/bats/gql/api-key-remove-weekly-limit.gql @@ -0,0 +1,10 @@ +mutation apiKeyRemoveWeeklyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveWeeklyLimit(input: $input) { + apiKey { + id + name + weeklyLimitSats + spentLast7DSats + } + } +} diff --git a/bats/gql/api-key-set-annual-limit.gql b/bats/gql/api-key-set-annual-limit.gql new file mode 100644 index 0000000000..dea3c487d1 --- /dev/null +++ b/bats/gql/api-key-set-annual-limit.gql @@ -0,0 +1,10 @@ +mutation apiKeySetAnnualLimit($input: ApiKeySetAnnualLimitInput!) { + apiKeySetAnnualLimit(input: $input) { + apiKey { + id + name + annualLimitSats + spentLast365DSats + } + } +} diff --git a/bats/gql/api-key-set-daily-limit.gql b/bats/gql/api-key-set-daily-limit.gql new file mode 100644 index 0000000000..3059503eea --- /dev/null +++ b/bats/gql/api-key-set-daily-limit.gql @@ -0,0 +1,10 @@ +mutation apiKeySetDailyLimit($input: ApiKeySetDailyLimitInput!) { + apiKeySetDailyLimit(input: $input) { + apiKey { + id + name + dailyLimitSats + spentLast24HSats + } + } +} diff --git a/bats/gql/api-key-set-monthly-limit.gql b/bats/gql/api-key-set-monthly-limit.gql new file mode 100644 index 0000000000..6d28e3f68d --- /dev/null +++ b/bats/gql/api-key-set-monthly-limit.gql @@ -0,0 +1,10 @@ +mutation apiKeySetMonthlyLimit($input: ApiKeySetMonthlyLimitInput!) { + apiKeySetMonthlyLimit(input: $input) { + apiKey { + id + name + monthlyLimitSats + spentLast30DSats + } + } +} diff --git a/bats/gql/api-key-set-weekly-limit.gql b/bats/gql/api-key-set-weekly-limit.gql new file mode 100644 index 0000000000..4e5dd266f8 --- /dev/null +++ b/bats/gql/api-key-set-weekly-limit.gql @@ -0,0 +1,10 @@ +mutation apiKeySetWeeklyLimit($input: ApiKeySetWeeklyLimitInput!) { + apiKeySetWeeklyLimit(input: $input) { + apiKey { + id + name + weeklyLimitSats + spentLast7DSats + } + } +} diff --git a/bats/gql/api-keys.gql b/bats/gql/api-keys.gql index a14c2e4b67..9ee7715862 100644 --- a/bats/gql/api-keys.gql +++ b/bats/gql/api-keys.gql @@ -11,6 +11,14 @@ query apiKeys { expiresAt readOnly scopes + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats } } } From 5743ec6068b6876cfcec7166fd97083aaaaed21a Mon Sep 17 00:00:00 2001 From: basarrcan Date: Wed, 15 Oct 2025 16:07:52 +0300 Subject: [PATCH 12/13] fix: rust-lib format --- core/api-keys/src/server/mod.rs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/core/api-keys/src/server/mod.rs b/core/api-keys/src/server/mod.rs index ccd57dee42..8b17be3a28 100644 --- a/core/api-keys/src/server/mod.rs +++ b/core/api-keys/src/server/mod.rs @@ -109,18 +109,9 @@ pub async fn run_server(config: ServerConfig, api_keys_app: ApiKeysApp) -> anyho // Internal routes that require internal auth let internal_routes = Router::new() - .route( - "/limits/check", - get(limits_check_handler), - ) - .route( - "/limits/remaining", - get(limits_remaining_handler), - ) - .route( - "/spending/record", - post(spending_record_handler), - ) + .route("/limits/check", get(limits_check_handler)) + .route("/limits/remaining", get(limits_remaining_handler)) + .route("/spending/record", post(spending_record_handler)) .with_state(internal_state); // Public routes From e0e75836ad2ac089587a18abeb44647f0c45cef7 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Wed, 15 Oct 2025 16:37:21 +0300 Subject: [PATCH 13/13] test: fix api-keys bats test name --- bats/core/api-keys/api-keys.bats | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bats/core/api-keys/api-keys.bats b/bats/core/api-keys/api-keys.bats index 2cfa279594..64e4bec39d 100644 --- a/bats/core/api-keys/api-keys.bats +++ b/bats/core/api-keys/api-keys.bats @@ -113,7 +113,7 @@ new_key_name() { exec_graphql 'api-key-secret' 'api-keys' - name="$(graphql_output '.data.me.apiKeys[-1].name')" + name="$(graphql_output '.data.me.apiKeys[] | select(.name == "'${key_name}'") | .name')" [[ "${name}" = "${key_name}" ]] || exit 1 exec_graphql 'api-key-secret' 'authorization' @@ -206,7 +206,7 @@ new_key_name() { cache_value "api-key-id" "$key_id" exec_graphql 'api-key-secret' 'api-keys' - name="$(graphql_output '.data.me.apiKeys[-1].name')" + name="$(graphql_output '.data.me.apiKeys[] | select(.name == "'${key_name}'") | .name')" [[ "${name}" = "${key_name}" ]] || exit 1 exec_graphql 'api-key-secret' 'authorization' @@ -234,7 +234,7 @@ new_key_name() { exec_graphql 'api-key-secret' 'api-keys' - name="$(graphql_output '.data.me.apiKeys[-1].name')" + name="$(graphql_output '.data.me.apiKeys[] | select(.name == "'${key_name}'") | .name')" [[ "${name}" = "${key_name}" ]] || exit 1 exec_graphql 'api-key-secret' 'authorization'