From 795fff61e007139f86987114d005a6089d294ebf Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 30 Jan 2026 21:55:57 +0100 Subject: [PATCH] child-key-expiration --- routstr/auth.py | 60 +++++++ routstr/balance.py | 40 +++++ routstr/core/admin.py | 39 +++++ routstr/core/db.py | 16 +- ui/components/child-key-creator.tsx | 232 +++++++++++++++++++++++++--- ui/lib/api/services/wallet.ts | 52 ++++++- 6 files changed, 412 insertions(+), 27 deletions(-) diff --git a/routstr/auth.py b/routstr/auth.py index 1f869886..596e2087 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -349,6 +349,66 @@ async def pay_for_request( }, ) + # Check balance limit for child keys (or any key with a limit) + if ( + key.balance_limit is not None + and key.total_spent + cost_per_request > key.balance_limit + ): + logger.warning( + "Balance limit exceeded", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "total_spent": key.total_spent, + "balance_limit": key.balance_limit, + "required": cost_per_request, + }, + ) + raise HTTPException( + status_code=402, + detail={ + "error": { + "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", + "type": "insufficient_quota", + "code": "balance_limit_exceeded", + } + }, + ) + + # Check validity date + if key.validity_date is not None: + import time + + if time.time() > key.validity_date: + logger.warning( + "Key validity date expired", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "validity_date": key.validity_date, + "current_time": time.time(), + }, + ) + raise HTTPException( + status_code=403, + detail={ + "error": { + "message": "API key has expired (validity date reached).", + "type": "invalid_request_error", + "code": "key_expired", + } + }, + ) + + raise HTTPException( + status_code=402, + detail={ + "error": { + "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", + "type": "insufficient_quota", + "code": "balance_limit_exceeded", + } + }, + ) + logger.debug( "Charging base cost for request", extra={ diff --git a/routstr/balance.py b/routstr/balance.py index 1a1e5850..4f1ab7be 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -44,6 +44,9 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict: "parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None, "total_requests": key.total_requests, "total_spent": key.total_spent, + "balance_limit": key.balance_limit, + "balance_limit_reset": key.balance_limit_reset, + "validity_date": key.validity_date, } @@ -253,6 +256,9 @@ async def donate(token: str, ref: str | None = None) -> str: class ChildKeyRequest(BaseModel): count: int + balance_limit: int | None = None + balance_limit_reset: str | None = None + validity_date: int | None = None @router.post("/child-key") @@ -302,6 +308,9 @@ async def create_child_key( hashed_key=new_key_hash, balance=0, parent_key_hash=key.hashed_key, + balance_limit=payload.balance_limit, + balance_limit_reset=payload.balance_limit_reset, + validity_date=payload.validity_date, ) session.add(child_key) new_keys.append("sk-" + new_key_hash) @@ -320,6 +329,37 @@ async def create_child_key( return response_data +class ChildKeyResetRequest(BaseModel): + child_key: str + + +@router.post("/child-key/reset") +async def reset_child_key_spent( + payload: ChildKeyResetRequest, + key: ApiKey = Depends(get_key_from_header), + session: AsyncSession = Depends(get_session), +) -> dict: + """Resets the total_spent of a child key. Must be called by the parent.""" + child_key_raw = payload.child_key + if child_key_raw.startswith("sk-"): + child_key_raw = child_key_raw[3:] + + child_key = await session.get(ApiKey, child_key_raw) + if not child_key: + raise HTTPException(status_code=404, detail="Child key not found.") + + if child_key.parent_key_hash != key.hashed_key: + raise HTTPException( + status_code=403, detail="Unauthorized. You are not the parent of this key." + ) + + child_key.total_spent = 0 + session.add(child_key) + await session.commit() + + return {"success": True, "message": "Child key balance reset successfully."} + + @router.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE"], diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 783474a8..dfd15e21 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -159,11 +159,50 @@ async def get_temporary_balances_api(request: Request) -> list[dict[str, object] "refund_address": key.refund_address, "key_expiry_time": key.key_expiry_time, "parent_key_hash": key.parent_key_hash, + "balance_limit": key.balance_limit, + "balance_limit_reset": key.balance_limit_reset, + "validity_date": key.validity_date, } for key in api_keys ] +class ApiKeyUpdate(BaseModel): + balance_limit: int | None = None + balance_limit_reset: str | None = None + validity_date: int | None = None + + +@admin_router.patch( + "/api/apikeys/{hashed_key}", dependencies=[Depends(require_admin_api)] +) +async def update_apikey( + request: Request, hashed_key: str, update: ApiKeyUpdate +) -> dict: + async with create_session() as session: + key = await session.get(ApiKey, hashed_key) + if not key: + raise HTTPException(status_code=404, detail="API key not found") + + if update.balance_limit is not None: + key.balance_limit = update.balance_limit + if update.balance_limit_reset is not None: + key.balance_limit_reset = update.balance_limit_reset + if update.validity_date is not None: + key.validity_date = update.validity_date + + session.add(key) + await session.commit() + await session.refresh(key) + + return { + "hashed_key": key.hashed_key, + "balance_limit": key.balance_limit, + "balance_limit_reset": key.balance_limit_reset, + "validity_date": key.validity_date, + } + + @admin_router.get("/api/balances", dependencies=[Depends(require_admin_api)]) async def get_balances_api(request: Request) -> list[dict[str, object]]: balance_details, _tw, _tu, _ow = await fetch_all_balances() diff --git a/routstr/core/db.py b/routstr/core/db.py index bbcfc6fe..d2a3491d 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -51,6 +51,18 @@ class ApiKey(SQLModel, table=True): # type: ignore parent_key_hash: str | None = Field( default=None, foreign_key="api_keys.hashed_key", index=True ) + balance_limit: int | None = Field( + default=None, + description="Max spendable balance in msats for this key (mostly for child keys)", + ) + balance_limit_reset: str | None = Field( + default=None, + description="Reset policy for balance limit (manual, daily, monthly, etc.)", + ) + validity_date: int | None = Field( + default=None, + description="Unix timestamp after which the key is no longer valid", + ) @property def total_balance(self) -> int: @@ -113,7 +125,9 @@ class LightningInvoice(SQLModel, table=True): # type: ignore class UpstreamProviderRow(SQLModel, table=True): # type: ignore __tablename__ = "upstream_providers" __table_args__ = ( - UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"), + UniqueConstraint( + "base_url", "api_key", name="uq_upstream_providers_base_url_api_key" + ), ) id: int | None = Field(default=None, primary_key=True) provider_type: str = Field( diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index 65861731..56fa5b4b 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/card'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Input } from '@/components/ui/input'; -import { Key, Copy, Check, Loader2 } from 'lucide-react'; +import { Key, Copy, Check, Loader2, RotateCcw } from 'lucide-react'; import { toast } from 'sonner'; interface ChildKeyCreatorProps { @@ -31,6 +31,18 @@ export function ChildKeyCreator({ const [internalApiKey, setInternalApiKey] = useState(''); const [loading, setLoading] = useState(false); const [count, setCount] = useState(1); + const [balanceLimit, setBalanceLimit] = useState(''); + const [balanceLimitReset, setBalanceLimitReset] = useState(''); + const [validityDate, setValidityDate] = useState(''); + const [childKeyToCheck, setChildKeyToCheck] = useState(''); + const [checking, setChecking] = useState(false); + const [keyStatus, setKeyStatus] = useState<{ + total_spent: number; + balance_limit: number | null; + validity_date: number | null; + is_expired: boolean; + is_drained: boolean; + } | null>(null); const [newKeys, setNewKeys] = useState([]); const [resultInfo, setResultInfo] = useState<{ cost_msats: number; @@ -58,7 +70,12 @@ export function ChildKeyCreator({ const result = await WalletService.createChildKey( baseUrl, activeApiKey, - requestedCount + requestedCount, + balanceLimit ? parseInt(balanceLimit) : undefined, + balanceLimitReset || undefined, + validityDate + ? Math.floor(new Date(validityDate + 'T23:59:59').getTime() / 1000) + : undefined ); console.log('Created child keys:', result); @@ -89,6 +106,47 @@ export function ChildKeyCreator({ } }; + const handleCheckKey = async () => { + if (!childKeyToCheck) { + toast.error('Please provide a Child API key to check'); + return; + } + + setChecking(true); + setKeyStatus(null); + try { + const baseUrlToUse = baseUrl || ''; + const response = await fetch(`${baseUrlToUse}/v1/balance/info`, { + headers: { + Authorization: `Bearer ${childKeyToCheck}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch key info'); + } + + const info = await response.json(); + const now = Math.floor(Date.now() / 1000); + + setKeyStatus({ + total_spent: info.total_spent, + balance_limit: info.balance_limit, + validity_date: info.validity_date, + is_expired: info.validity_date ? now > info.validity_date : false, + is_drained: info.balance_limit + ? info.total_spent >= info.balance_limit + : false, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to check child key' + ); + } finally { + setChecking(false); + } + }; + const copyToClipboard = (key: string) => { navigator.clipboard.writeText(key); setCopiedKey(key); @@ -152,27 +210,70 @@ export function ChildKeyCreator({ )} - { - const val = parseInt(e.target.value); - if (!isNaN(val)) { - setCount(Math.max(1, Math.min(50, val))); - } else { - setCount(1); - } - }} - className='w-full sm:w-24' - /> - - + + {keyStatus && ( +
+
+ Total Spent: + + {keyStatus.total_spent} mSats + +
+ {keyStatus.balance_limit !== null && ( +
+ Limit: + + {keyStatus.balance_limit} mSats + +
+ )} + {keyStatus.validity_date !== null && ( +
+ Expires: + + {new Date( + keyStatus.validity_date * 1000 + ).toLocaleDateString()} + +
+ )} +
+ {keyStatus.is_drained && ( + Drained + )} + {keyStatus.is_expired && ( + Expired + )} + {!keyStatus.is_drained && !keyStatus.is_expired && ( + + Active + + )} +
+
+ )} + + + ); } diff --git a/ui/lib/api/services/wallet.ts b/ui/lib/api/services/wallet.ts index 8e06a9b9..d16da3ac 100644 --- a/ui/lib/api/services/wallet.ts +++ b/ui/lib/api/services/wallet.ts @@ -134,7 +134,10 @@ export class WalletService { static async createChildKey( baseUrl?: string, apiKey?: string, - count: number = 1 + count: number = 1, + balanceLimit?: number, + balanceLimitReset?: string, + validityDate?: number ): Promise { try { if (baseUrl && apiKey) { @@ -144,7 +147,12 @@ export class WalletService { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ count }), + body: JSON.stringify({ + count, + balance_limit: balanceLimit, + balance_limit_reset: balanceLimitReset, + validity_date: validityDate, + }), }); if (!response.ok) { @@ -157,11 +165,49 @@ export class WalletService { return await apiClient.post( '/v1/balance/child-key', - { count } + { + count, + balance_limit: balanceLimit, + balance_limit_reset: balanceLimitReset, + validity_date: validityDate, + } ); } catch (error) { console.error('Error creating child key:', error); throw error; } } + + static async resetChildKeySpent( + baseUrl: string | undefined, + parentKey: string, + childKey: string + ): Promise<{ success: boolean; message: string }> { + try { + const url = baseUrl + ? `${baseUrl}/v1/balance/child-key/reset` + : '/v1/balance/child-key/reset'; + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${parentKey}`, + }; + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ child_key: childKey }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to reset child key'); + } + + return await response.json(); + } catch (error) { + console.error('Error resetting child key:', error); + throw error; + } + } }