From 5d69cfa0ea5308e2d2f5daa7a930193e39360449 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 15 Feb 2026 16:51:26 +0100 Subject: [PATCH] add child keys details to view --- routstr/balance.py | 29 +- tests/integration/test_child_keys_api.py | 98 ++++ ui/components/landing/api-key-manager.tsx | 30 +- .../landing/cashu-payment-workflow.tsx | 37 +- ui/components/landing/cheat-sheet.tsx | 39 +- ui/components/landing/key-info-details.tsx | 431 ++++++++++++++++++ .../landing/lightning-payment-workflow.tsx | 28 +- 7 files changed, 641 insertions(+), 51 deletions(-) create mode 100644 tests/integration/test_child_keys_api.py create mode 100644 ui/components/landing/key-info-details.tsx 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/tests/integration/test_child_keys_api.py b/tests/integration/test_child_keys_api.py new file mode 100644 index 00000000..1f01c0ab --- /dev/null +++ b/tests/integration/test_child_keys_api.py @@ -0,0 +1,98 @@ +import pytest +from httpx import AsyncClient +from typing import Any +from sqlmodel import select +from routstr.core.db import ApiKey + + +@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_api_key = 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/landing/api-key-manager.tsx b/ui/components/landing/api-key-manager.tsx index b1d765f1..e5192a80 100644 --- a/ui/components/landing/api-key-manager.tsx +++ b/ui/components/landing/api-key-manager.tsx @@ -7,19 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; - -type WalletSnapshot = { - apiKey: string; - balanceMsats: number; - reservedMsats: number; -}; - -type RefundReceipt = { - token?: string; - recipient?: string; - sats?: string; - msats?: string; -}; +import type { WalletSnapshot, ChildKeyInfo } from './key-info-details'; interface ApiKeyManagerProps { baseUrl: string; @@ -51,12 +39,28 @@ async function fetchWalletInfo( api_key: string; balance: number; reserved?: number; + is_child: boolean; + parent_key: string | null; + total_requests: number; + total_spent: number; + balance_limit: number | null; + balance_limit_reset: string | null; + validity_date: number | null; + child_keys?: ChildKeyInfo[]; }; return { apiKey: payload.api_key || apiKey, 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, }; } diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index 8e09aa73..1c9d3ea1 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -9,12 +9,7 @@ import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; 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 RefundReceipt = { token?: string; @@ -54,12 +49,28 @@ async function fetchWalletInfo( api_key: string; balance: number; reserved?: number; + is_child: boolean; + parent_key: string | null; + total_requests: number; + total_spent: number; + balance_limit: number | null; + balance_limit_reset: string | null; + validity_date: number | null; + child_keys?: any[]; }; return { apiKey: payload.api_key || apiKey, 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, }; } @@ -147,11 +158,25 @@ export function CashuPaymentWorkflow({ const payload = (await response.json()) as { api_key: string; balance: number; + is_child: boolean; + parent_key: string | null; + total_requests: number; + total_spent: number; + balance_limit: number | null; + balance_limit_reset: string | null; + validity_date: number | null; }; const snapshot: WalletSnapshot = { apiKey: payload.api_key, balanceMsats: payload.balance ?? 0, reservedMsats: 0, + isChild: payload.is_child ?? false, + parentKey: payload.parent_key ?? null, + totalRequests: payload.total_requests ?? 0, + totalSpent: payload.total_spent ?? 0, + balanceLimit: payload.balance_limit ?? null, + balanceLimitReset: payload.balance_limit_reset ?? null, + validityDate: payload.validity_date ?? null, }; setApiKeyInput(snapshot.apiKey); diff --git a/ui/components/landing/cheat-sheet.tsx b/ui/components/landing/cheat-sheet.tsx index a946a4a4..56baf72b 100644 --- a/ui/components/landing/cheat-sheet.tsx +++ b/ui/components/landing/cheat-sheet.tsx @@ -14,26 +14,14 @@ import { ConfigurationService } from '@/lib/api/services/configuration'; import { CashuPaymentWorkflow } from './cashu-payment-workflow'; import { LightningPaymentWorkflow } from './lightning-payment-workflow'; import { ApiKeyManager } from './api-key-manager'; +import { + KeyInfoDetails, + type WalletSnapshot, + type ChildKeyInfo, +} from './key-info-details'; import { ChildKeyCreator } from '@/components/child-key-creator'; type NodeInfo = { - name: string; - description: string; - version: string; - npub?: string | null; - mints: string[]; - http_url?: string | null; - onion_url?: string | null; - child_key_cost_msats?: number; -}; - -type WalletSnapshot = { - apiKey: string; - balanceMsats: number; - reservedMsats: number; -}; - -type RefundReceipt = { token?: string; recipient?: string; sats?: string; @@ -364,10 +352,11 @@ export function CheatSheet(): JSX.Element { - - Cashu Payments - Lightning Payments + + Cashu + Lightning Manage Keys + Key Details Child Keys @@ -426,6 +415,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(() => { + if (apiKey !== apiKeyInput) { + 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);