diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 952c9373..f6b0c0f0 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -360,6 +360,42 @@ POST /v1/wallet/create } ``` +### Get Key Information + +Get current balance, consumption data, and child keys for an API key. + +```http +GET /v1/balance/info +Authorization: Bearer sk-... +``` + +**Response:** + +```json +{ + "api_key": "sk-abc...", + "balance": 8500000, + "reserved": 0, + "is_child": false, + "parent_key": null, + "total_requests": 42, + "total_spent": 1500000, + "balance_limit": null, + "balance_limit_reset": null, + "validity_date": null, + "child_keys": [ + { + "api_key": "sk-child1...", + "total_requests": 10, + "total_spent": 500000, + "balance_limit": 1000000, + "balance_limit_reset": "daily", + "validity_date": 1738000000 + } + ] +} +``` + ### Check Balance Get current wallet balance. diff --git a/routstr/balance.py b/routstr/balance.py index 537a5324..84ea01d9 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -6,8 +6,9 @@ from typing import Annotated, NoReturn from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel +from sqlmodel import select -from .auth import validate_bearer_key +from .auth import get_billing_key, validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session from .core.logging import get_logger from .core.settings import settings @@ -34,10 +35,8 @@ async def get_key_from_header( async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict: - from .auth import get_billing_key - billing_key = await get_billing_key(key, session) - return { + info = { "api_key": "sk-" + key.hashed_key, "balance": billing_key.balance, "reserved": billing_key.reserved_balance, @@ -50,6 +49,26 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict: "validity_date": key.validity_date, } + if not key.parent_key_hash: + # Fetch child keys if this is a parent key + statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key) + results = await session.exec(statement) + child_keys = results.all() + if child_keys: + info["child_keys"] = [ + { + "api_key": "sk-" + ck.hashed_key, + "total_requests": ck.total_requests, + "total_spent": ck.total_spent, + "balance_limit": ck.balance_limit, + "balance_limit_reset": ck.balance_limit_reset, + "validity_date": ck.validity_date, + } + for ck in child_keys + ] + + return info + # TODO: remove this endpoint when frontend is updated @router.get("/", include_in_schema=False) @@ -117,8 +136,6 @@ async def topup_wallet_endpoint( key: ApiKey = Depends(get_key_from_header), session: AsyncSession = Depends(get_session), ) -> dict[str, int]: - from .auth import get_billing_key - billing_key = await get_billing_key(key, session) if topup_request is not None: diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c0ccbe93..3d123718 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -456,18 +456,18 @@ async def batch_override_provider_models( logger.info( f"BATCH_OVERRIDE called: provider_id={provider_id}, count={len(payload.models)}" ) - + async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") - + overridden_count = 0 - + for model_data in payload.models: # Try to get existing model regardless of whether it's enabled or not existing_row = await session.get(ModelRow, (model_data.id, provider_id)) - + if existing_row: # Update existing existing_row.name = model_data.name @@ -483,7 +483,9 @@ async def batch_override_provider_models( else None ) existing_row.top_provider = ( - json.dumps(model_data.top_provider) if model_data.top_provider else None + json.dumps(model_data.top_provider) + if model_data.top_provider + else None ) existing_row.canonical_slug = model_data.canonical_slug existing_row.alias_ids = ( @@ -508,23 +510,32 @@ async def batch_override_provider_models( else None ), top_provider=( - json.dumps(model_data.top_provider) if model_data.top_provider else None + json.dumps(model_data.top_provider) + if model_data.top_provider + else None ), canonical_slug=model_data.canonical_slug, alias_ids=( - json.dumps(model_data.alias_ids) if model_data.alias_ids else None + json.dumps(model_data.alias_ids) + if model_data.alias_ids + else None ), upstream_provider_id=provider_id, enabled=model_data.enabled, ) session.add(row) - + overridden_count += 1 - + await session.commit() await refresh_model_maps() - return {"ok": True, "count": overridden_count, "message": f"Successfully batch overridden {overridden_count} models"} + return { + "ok": True, + "count": overridden_count, + "message": f"Successfully batch overridden {overridden_count} models", + } + class UpstreamProviderCreate(BaseModel): provider_type: str diff --git a/tests/integration/test_child_keys_api.py b/tests/integration/test_child_keys_api.py new file mode 100644 index 00000000..7bfaf2a8 --- /dev/null +++ b/tests/integration/test_child_keys_api.py @@ -0,0 +1,97 @@ +from typing import Any + +import pytest +from httpx import AsyncClient + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_wallet_info_returns_child_keys( + integration_client: AsyncClient, + authenticated_client: AsyncClient, + integration_session: Any, +) -> None: + """Test that GET /v1/wallet/info returns child keys for a parent key""" + + # 1. Get parent info to find its hashed_key + response = await authenticated_client.get("/v1/wallet/info") + assert response.status_code == 200 + parent_data = response.json() + parent_data["api_key"] + + # 2. Create child keys for this parent + # We need to use the parent's authentication for this + child_payload = {"count": 2, "balance_limit": 1000, "balance_limit_reset": "daily"} + create_response = await authenticated_client.post( + "/v1/wallet/child-key", json=child_payload + ) + assert create_response.status_code == 200 + create_data = create_response.json() + child_keys = create_data["api_keys"] + assert len(child_keys) == 2 + + # 3. Call /info again and check for child_keys + info_response = await authenticated_client.get("/v1/wallet/info") + assert info_response.status_code == 200 + info_data = info_response.json() + + assert "child_keys" in info_data + assert len(info_data["child_keys"]) == 2 + + # Verify child key details + for ck in info_data["child_keys"]: + assert ck["api_key"] in child_keys + assert ck["balance_limit"] == 1000 + assert ck["balance_limit_reset"] == "daily" + assert "total_spent" in ck + assert "total_requests" in ck + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_wallet_info_child_key_no_child_keys( + integration_client: AsyncClient, + authenticated_client: AsyncClient, + integration_session: Any, +) -> None: + """Test that GET /v1/wallet/info for a child key does NOT return child_keys""" + + # 1. Create a child key + child_payload = {"count": 1} + create_response = await authenticated_client.post( + "/v1/wallet/child-key", json=child_payload + ) + assert create_response.status_code == 200 + child_key = create_response.json()["api_keys"][0] + + # 2. Use the child key to get its info + integration_client.headers["Authorization"] = f"Bearer {child_key}" + info_response = await integration_client.get("/v1/wallet/info") + assert info_response.status_code == 200 + info_data = info_response.json() + + assert info_data["is_child"] is True + assert "child_keys" not in info_data + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_account_info_root_returns_child_keys( + authenticated_client: AsyncClient, +) -> None: + """Test that GET / returns child keys for a parent key (root endpoint)""" + + # 1. Create a child key + child_payload = {"count": 1} + await authenticated_client.post("/v1/wallet/child-key", json=child_payload) + + # 2. Call root endpoint /v1/balance/ + # Note: routstr/balance.py defines router = APIRouter() + # and it is included in balance_router with prefix /v1/balance + # The endpoint is @router.get("/") + response = await authenticated_client.get("/v1/balance/") + assert response.status_code == 200 + data = response.json() + + assert "child_keys" in data + assert len(data["child_keys"]) >= 1 diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index e37791e2..97ddae80 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -251,7 +251,7 @@ export function ChildKeyCreator({ )}
- {configs.map((config, index) => ( + {configs.map((config) => (
- {nodeInfo.mints.length ? ( + {nodeInfo.mints?.length ? ( nodeInfo.mints.map((mint) => ( - - Cashu Payments - Lightning Payments + + Cashu + Lightning Manage Keys + Key Details Child Keys @@ -426,6 +422,16 @@ export function CheatSheet(): JSX.Element { )} + + + + void; + onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void; +} + +export function KeyInfoDetails({ + baseUrl, + apiKey = '', + walletInfo = null, + onApiKeyChanged, + onWalletInfoUpdated, +}: KeyInfoDetailsProps): JSX.Element { + const [apiKeyInput, setApiKeyInput] = useState(apiKey); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isResetting, setIsResetting] = useState(null); + + // Sync internal state with props if they change + useEffect(() => { + setApiKeyInput(apiKey); + }, [apiKey]); + + const fetchDetails = useCallback( + async (keyToFetch: string) => { + setIsRefreshing(true); + try { + const response = await fetch(`${baseUrl}/v1/balance/info`, { + headers: { Authorization: `Bearer ${keyToFetch}` }, + }); + if (!response.ok) { + throw new Error('Failed to fetch key info'); + } + const payload = await response.json(); + const snapshot: WalletSnapshot = { + apiKey: payload.api_key || keyToFetch, + balanceMsats: payload.balance ?? 0, + reservedMsats: payload.reserved ?? 0, + isChild: payload.is_child, + parentKey: payload.parent_key, + totalRequests: payload.total_requests, + totalSpent: payload.total_spent, + balanceLimit: payload.balance_limit, + balanceLimitReset: payload.balance_limit_reset, + validityDate: payload.validity_date, + childKeys: payload.child_keys, + }; + onWalletInfoUpdated?.(snapshot); + toast.success('Key details synced'); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to fetch details' + ); + } finally { + setIsRefreshing(false); + } + }, + [baseUrl, onWalletInfoUpdated] + ); + + const handleRefresh = async () => { + if (!apiKeyInput) return; + await fetchDetails(apiKeyInput); + }; + + const handleKeyChange = (newKey: string) => { + setApiKeyInput(newKey); + onApiKeyChanged?.(newKey); + // Optionally clear info when key changes + if (newKey !== apiKey) { + onWalletInfoUpdated?.(null); + } + }; + + const handleCopy = (value: string) => { + navigator.clipboard.writeText(value); + toast.success('Copied to clipboard'); + }; + + const handleResetSpent = async (childKey: string) => { + if (!walletInfo || walletInfo.isChild) return; + + setIsResetting(childKey); + try { + await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey); + toast.success('Child key spent reset'); + await fetchDetails(apiKeyInput); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to reset child key' + ); + } finally { + setIsResetting(null); + } + }; + + const formatSats = (msats: number) => + new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); + const formatMsats = (msats: number) => + new Intl.NumberFormat('en-US').format(msats); + const formatDate = (timestamp: number | null) => + timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never'; + + return ( +
+ + + + + Key Information + + + Enter an API key to view its balance, consumption, and child keys. + + + +
+ handleKeyChange(e.target.value)} + placeholder='sk-...' + className='font-mono text-sm' + /> +
+ + +
+
+
+
+ + {walletInfo && ( + <> +
+ + + + + Status & Identity + + + +
+ Type + + {walletInfo.isChild ? 'Child Key' : 'Parent Key'} + +
+ {walletInfo.parentKey && ( +
+ + Parent Key + +
+ + {walletInfo.parentKey} + + +
+
+ )} +
+ + Validity + + + {formatDate(walletInfo.validityDate)} + +
+
+ + Spendable Balance + + + {formatSats(walletInfo.balanceMsats)} sats + +
+
+
+ + + + + + Consumption + + + +
+ + Total Requests + + + {walletInfo.totalRequests} + +
+
+ + Total Spent + +
+

+ {formatSats(walletInfo.totalSpent)} sats +

+

+ {formatMsats(walletInfo.totalSpent)} msats +

+
+
+ {walletInfo.balanceLimit !== null && ( +
+
+ + Spend Limit + + + {formatSats(walletInfo.balanceLimit)} sats + +
+ {walletInfo.balanceLimitReset && ( +
+ + Reset Policy + + + {walletInfo.balanceLimitReset} + +
+ )} +
+ )} +
+
+
+ + {!walletInfo.isChild && + walletInfo.childKeys && + walletInfo.childKeys.length > 0 && ( + + + + + Child Keys ({walletInfo.childKeys.length}) + + + Secondary keys using this account's balance + + + +
+ {walletInfo.childKeys.map((ck) => ( +
+
+ + {ck.api_key} + +
+ + +
+
+
+
+

+ Requests +

+

+ {ck.total_requests} +

+
+
+

+ Spent +

+

+ {formatSats(ck.total_spent)} sats +

+
+
+

+ Limit +

+

+ {ck.balance_limit + ? `${formatSats(ck.balance_limit)} sats` + : 'None'} +

+
+
+

+ Policy +

+

+ {ck.balance_limit_reset || 'None'} +

+
+
+

+ Expires +

+

+ {formatDate(ck.validity_date)} +

+
+
+
+ ))} +
+
+
+ )} + +
+ +
+ + )} +
+ ); +} diff --git a/ui/components/landing/lightning-payment-workflow.tsx b/ui/components/landing/lightning-payment-workflow.tsx index 91eeb9d8..8b9f9b0c 100644 --- a/ui/components/landing/lightning-payment-workflow.tsx +++ b/ui/components/landing/lightning-payment-workflow.tsx @@ -11,12 +11,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Separator } from '@/components/ui/separator'; import { KeyOptions } from '@/components/key-options'; - -type WalletSnapshot = { - apiKey: string; - balanceMsats: number; - reservedMsats: number; -}; +import type { WalletSnapshot } from './key-info-details'; type LightningInvoice = { invoice_id: string; @@ -215,6 +210,13 @@ export function LightningPaymentWorkflow({ apiKey: status.api_key, balanceMsats: status.amount_sats * 1000, reservedMsats: 0, + isChild: false, + parentKey: null, + totalRequests: 0, + totalSpent: 0, + balanceLimit: null, + balanceLimitReset: null, + validityDate: null, }; onApiKeyCreated?.(status.api_key, walletInfo); setCreatedApiKey(status.api_key); @@ -289,6 +291,13 @@ export function LightningPaymentWorkflow({ apiKey: status.api_key, balanceMsats: status.amount_sats * 1000, reservedMsats: 0, + isChild: false, + parentKey: null, + totalRequests: 0, + totalSpent: 0, + balanceLimit: null, + balanceLimitReset: null, + validityDate: null, }; onApiKeyCreated?.(status.api_key, walletInfo); setTopupApiKeyResult(status.api_key); @@ -343,6 +352,13 @@ export function LightningPaymentWorkflow({ apiKey: status.api_key, balanceMsats: status.amount_sats * 1000, reservedMsats: 0, + isChild: false, + parentKey: null, + totalRequests: 0, + totalSpent: 0, + balanceLimit: null, + balanceLimitReset: null, + validityDate: null, }; onApiKeyCreated?.(status.api_key, walletInfo); setRecoveredApiKey(status.api_key); diff --git a/ui/lib/api/services/auth.ts b/ui/lib/api/services/auth.ts index ba77ef2b..ad9a7724 100644 --- a/ui/lib/api/services/auth.ts +++ b/ui/lib/api/services/auth.ts @@ -1,5 +1,4 @@ import { z } from 'zod'; -import { apiClient } from '../client'; import { ConfigurationService } from './configuration'; import axios from 'axios';