diff --git a/apps/dashboard/app/api-keys/server-actions.ts b/apps/dashboard/app/api-keys/server-actions.ts index f27072ceea..638a53ed48 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) => { @@ -113,9 +124,262 @@ 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", 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/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 = () => ( = ({ 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..ff5792f59d 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 } } } 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/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' 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 } } } 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..568c93ebbb --- /dev/null +++ b/core/api-keys/src/limits/mod.rs @@ -0,0 +1,535 @@ +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, +} + +#[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?; + + 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, + }) + } + + /// 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")) + } +} 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 5eec283916..8b17be3a28 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::{FromRef, FromRequestParts, Query, State}, + http::{request::Parts, 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::*; @@ -26,8 +33,62 @@ 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())); let jwks_decoder = Arc::new(RemoteJwksDecoder::new(config.jwks_url.clone())); let decoder = jwks_decoder.clone(); @@ -35,6 +96,25 @@ 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 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("/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 let app = Router::new() .route( "/graphql", @@ -44,6 +124,7 @@ 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)), ) + .merge(internal_routes) .with_state(JwtDecoderState { decoder: jwks_decoder, }) @@ -61,6 +142,74 @@ 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")] + 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)] +struct LimitsRemainingQuery { + api_key_id: String, +} + +#[derive(Debug, Serialize)] +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_annual_sats: Option, +} + +#[derive(Debug, Deserialize)] +struct SpendingRecordRequest { + api_key_id: String, + amount_sats: i64, + transaction_id: Option, } #[instrument( @@ -86,7 +235,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 +263,171 @@ 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( + _auth: InternalAuth, + 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 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, + 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, + })) +} + +#[instrument( + name = "api-keys.server.limits_remaining", + skip_all, + fields(api_key_id) +)] +async fn limits_remaining_handler( + _auth: InternalAuth, + 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 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, + 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, + })) +} + +#[instrument( + name = "api-keys.server.spending_record", + skip_all, + fields(api_key_id, amount_sats) +)] +async fn spending_record_handler( + _auth: InternalAuth, + 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/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 57dea4ad31..a5fde040ac 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -54,6 +54,10 @@ 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 * as LedgerFacade from "@/services/ledger/facade" import { @@ -76,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) @@ -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,23 @@ 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 paymentHash = paymentFlow.paymentHashForFlow() if (paymentHash instanceof Error) return paymentHash @@ -546,6 +576,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 +768,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 +784,23 @@ 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 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: paymentFlow.usdPaymentAmount, @@ -813,6 +873,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/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/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index 147498f45f..29d76e2590 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 & { @@ -127,6 +128,7 @@ type PayAllOnChainByWalletIdArgs = { address: string speed: PayoutSpeed memo: string | null + apiKeyId?: string } type PayOnChainByWalletIdWithoutCurrencyArgs = { @@ -136,6 +138,7 @@ type PayOnChainByWalletIdWithoutCurrencyArgs = { address: string speed: PayoutSpeed memo: string | null + apiKeyId?: string } type PayOnChainByWalletIdArgs = PayOnChainByWalletIdWithoutCurrencyArgs & { diff --git a/core/api/src/config/env.ts b/core/api/src/config/env.ts index 18f66ee917..476b7ea5cc 100644 --- a/core/api/src/config/env.ts +++ b/core/api/src/config/env.ts @@ -62,6 +62,9 @@ export const env = createEnv({ .pipe(z.coerce.number()) .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(), @@ -193,6 +196,9 @@ 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", + 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 9c54a7b838..cf05a1e0e3 100644 --- a/core/api/src/config/index.ts +++ b/core/api/src/config/index.ts @@ -84,6 +84,10 @@ export const getCallbackServiceConfig = (): SvixConfig => { 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/domain/api-keys/errors.ts b/core/api/src/domain/api-keys/errors.ts new file mode 100644 index 0000000000..acfe2656a0 --- /dev/null +++ b/core/api/src/domain/api-keys/errors.ts @@ -0,0 +1,13 @@ +import { DomainError, ErrorLevel } from "@/domain/shared" + +export class ApiKeyLimitExceededError extends DomainError { + level = ErrorLevel.Warn +} + +export class ApiKeyLimitCheckError extends DomainError { + level = ErrorLevel.Critical +} + +export class ApiKeySpendingRecordError extends DomainError { + level = ErrorLevel.Critical +} 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/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 6c9efdd21b..eec7881d06 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 "ApiKeyLimitExceededError": + 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/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 { + 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/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) { 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..1bec9a38fd --- /dev/null +++ b/core/api/src/services/api-keys/client.ts @@ -0,0 +1,101 @@ +import axios from "axios" + +import { baseLogger } from "@/services/logger" +import { ApiKeyLimitCheckError, ApiKeySpendingRecordError } from "@/domain/api-keys" +import { getApiKeysServiceUrl, getApiKeysInternalAuthSecret } from "@/config" + +const API_KEYS_SERVICE_URL = getApiKeysServiceUrl() +const INTERNAL_AUTH_SECRET = getApiKeysInternalAuthSecret() + +export type LimitCheckResult = { + allowed: boolean + 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 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, + 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, + }, + headers: { + "X-Internal-Auth": INTERNAL_AUTH_SECRET, + }, + 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, + }, + { + headers: { + "X-Internal-Auth": INTERNAL_AUTH_SECRET, + }, + 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/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) 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: