diff --git a/auth.py b/auth.py deleted file mode 100644 index b729347..0000000 --- a/auth.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -Centralized Authorization Module for Castle Extension. - -Provides consistent, secure authorization patterns across all endpoints. - -Key concepts: -- AuthContext: Captures all authorization state for a request -- Dependencies: FastAPI dependencies for endpoint protection -- Permission checks: Consistent resource-level access control - -Usage: - from .auth import require_super_user, require_authenticated, AuthContext - - @router.get("/api/v1/admin-endpoint") - async def admin_endpoint(auth: AuthContext = Depends(require_super_user)): - # Only super users can access - pass - - @router.get("/api/v1/user-data") - async def user_data(auth: AuthContext = Depends(require_authenticated)): - # Any authenticated user - user_id = auth.user_id - pass -""" - -from dataclasses import dataclass -from functools import wraps -from http import HTTPStatus -from typing import Optional - -from fastapi import Depends, HTTPException -from lnbits.core.models import WalletTypeInfo -from lnbits.decorators import require_admin_key, require_invoice_key -from lnbits.settings import settings as lnbits_settings -from loguru import logger - -from .crud import get_account, get_user_permissions -from .models import PermissionType - - -@dataclass -class AuthContext: - """ - Authorization context for a request. - - Contains all information needed to make authorization decisions. - Use this instead of directly accessing wallet/user properties scattered - throughout endpoint code. - """ - user_id: str - wallet_id: str - is_super_user: bool - wallet: WalletTypeInfo - - @property - def is_admin(self) -> bool: - """ - Check if user is a Castle admin (super user). - - Note: In Castle, admin = super_user. There's no separate admin concept. - """ - return self.is_super_user - - def require_super_user(self) -> None: - """Raise HTTPException if not super user.""" - if not self.is_super_user: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Super user access required" - ) - - def require_self_or_super_user(self, target_user_id: str) -> None: - """ - Require that user is accessing their own data or is super user. - - Args: - target_user_id: The user ID being accessed - - Raises: - HTTPException: If user is neither the target nor super user - """ - if not self.is_super_user and self.user_id != target_user_id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Access denied: you can only access your own data" - ) - - -def _build_auth_context(wallet: WalletTypeInfo) -> AuthContext: - """Build AuthContext from wallet info.""" - user_id = wallet.wallet.user - return AuthContext( - user_id=user_id, - wallet_id=wallet.wallet.id, - is_super_user=user_id == lnbits_settings.super_user, - wallet=wallet, - ) - - -# ===== FastAPI Dependencies ===== - -async def require_authenticated( - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> AuthContext: - """ - Require authentication (invoice key minimum). - - Returns AuthContext with user information. - Use for read-only access to user's own data. - """ - return _build_auth_context(wallet) - - -async def require_authenticated_write( - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> AuthContext: - """ - Require authentication with write permissions (admin key). - - Returns AuthContext with user information. - Use for write operations on user's own data. - """ - return _build_auth_context(wallet) - - -async def require_super_user( - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> AuthContext: - """ - Require super user access. - - Raises HTTPException 403 if not super user. - Use for Castle admin operations. - """ - auth = _build_auth_context(wallet) - if not auth.is_super_user: - logger.warning( - f"Super user access denied for user {auth.user_id[:8]} " - f"attempting admin operation" - ) - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Super user access required" - ) - return auth - - -# ===== Resource Access Checks ===== - -async def can_access_account( - auth: AuthContext, - account_id: str, - permission_type: PermissionType = PermissionType.READ, -) -> bool: - """ - Check if user can access an account. - - Access is granted if: - 1. User is super user (full access) - 2. User owns the account (user-specific accounts like Assets:Receivable:User-abc123) - 3. User has explicit permission for the account - - Args: - auth: The authorization context - account_id: The account ID to check - permission_type: The type of access needed (READ, SUBMIT_EXPENSE, MANAGE) - - Returns: - True if access is allowed, False otherwise - """ - # Super users have full access - if auth.is_super_user: - return True - - # Check if this is the user's own account - account = await get_account(account_id) - if account: - user_short = auth.user_id[:8] - if f"User-{user_short}" in account.name: - return True - - # Check explicit permissions - permissions = await get_user_permissions(auth.user_id) - for perm in permissions: - if perm.account_id == account_id: - # Check if permission type is sufficient - if perm.permission_type == PermissionType.MANAGE: - return True # MANAGE grants all access - if perm.permission_type == permission_type: - return True - if ( - permission_type == PermissionType.READ - and perm.permission_type in [PermissionType.SUBMIT_EXPENSE, PermissionType.MANAGE] - ): - return True # Higher permissions include READ - - return False - - -async def require_account_access( - auth: AuthContext, - account_id: str, - permission_type: PermissionType = PermissionType.READ, -) -> None: - """ - Require access to an account, raising HTTPException if denied. - - Args: - auth: The authorization context - account_id: The account ID to check - permission_type: The type of access needed - - Raises: - HTTPException: If access is denied - """ - if not await can_access_account(auth, account_id, permission_type): - logger.warning( - f"Account access denied: user {auth.user_id[:8]} " - f"attempted {permission_type.value} on account {account_id}" - ) - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=f"Access denied to account {account_id}" - ) - - -async def can_access_user_data(auth: AuthContext, target_user_id: str) -> bool: - """ - Check if user can access another user's data. - - Access is granted if: - 1. User is super user - 2. User is accessing their own data - - Args: - auth: The authorization context - target_user_id: The user ID whose data is being accessed - - Returns: - True if access is allowed - """ - if auth.is_super_user: - return True - - # Users can access their own data - compare full ID or short ID - if auth.user_id == target_user_id: - return True - - # Also allow if short IDs match (8 char prefix) - if auth.user_id[:8] == target_user_id[:8]: - return True - - return False - - -async def require_user_data_access( - auth: AuthContext, - target_user_id: str, -) -> None: - """ - Require access to a user's data, raising HTTPException if denied. - - Args: - auth: The authorization context - target_user_id: The user ID whose data is being accessed - - Raises: - HTTPException: If access is denied - """ - if not await can_access_user_data(auth, target_user_id): - logger.warning( - f"User data access denied: user {auth.user_id[:8]} " - f"attempted to access data for user {target_user_id[:8]}" - ) - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Access denied: you can only access your own data" - ) - - -# ===== Utility Functions ===== - -def get_user_id_from_wallet(wallet: WalletTypeInfo) -> str: - """ - Get user ID from wallet info. - - IMPORTANT: Always use wallet.wallet.user (not wallet.wallet.id). - - wallet.wallet.user = the user's ID - - wallet.wallet.id = the wallet's ID (NOT the same!) - - Args: - wallet: The wallet type info from LNbits - - Returns: - The user ID - """ - return wallet.wallet.user - - -def is_super_user(user_id: str) -> bool: - """ - Check if a user ID is the super user. - - Args: - user_id: The user ID to check - - Returns: - True if this is the super user - """ - return user_id == lnbits_settings.super_user diff --git a/crud.py b/crud.py index cd610b1..0976e72 100644 --- a/crud.py +++ b/crud.py @@ -424,26 +424,6 @@ async def get_user_wallet_settings(user_id: str) -> Optional[UserWalletSettings] ) -async def get_user_wallet_settings_by_prefix( - user_id_prefix: str, -) -> Optional[StoredUserWalletSettings]: - """ - Get user wallet settings by user ID prefix (for truncated 8-char IDs from Beancount). - - Beancount accounts use truncated user IDs (first 8 chars), but the database - stores full UUIDs. This function looks up by prefix to bridge the gap. - """ - return await db.fetchone( - """ - SELECT * FROM user_wallet_settings - WHERE id LIKE :prefix || '%' - LIMIT 1 - """, - {"prefix": user_id_prefix}, - StoredUserWalletSettings, - ) - - async def update_user_wallet_settings( user_id: str, data: UserWalletSettings ) -> UserWalletSettings: diff --git a/fava_client.py b/fava_client.py index fea8c64..6880d11 100644 --- a/fava_client.py +++ b/fava_client.py @@ -734,23 +734,17 @@ class FavaClient: async def get_user_balance_bql(self, user_id: str) -> Dict[str, Any]: """ - Get user balance using BQL with currency-grouped aggregation. + Get user balance using BQL with price notation (efficient server-side aggregation). - Groups by account AND currency to correctly handle mixed entry formats: - - Expense entries use EUR @@ SATS (position=EUR, weight=SATS) - - Payment entries use plain SATS (position=SATS, weight=SATS) - - Without currency grouping, sum(number) would mix EUR and SATS face values. - The net SATS balance is computed from sum(weight) which normalizes to SATS - across both formats. Fiat is taken only from EUR rows and scaled by the - fraction of SATS debt still outstanding. + Uses sum(weight) to aggregate SATS from @@ price notation. + This provides 5-10x performance improvement over manual aggregation. Args: user_id: User ID Returns: { - "balance": int (net sats owed), + "balance": int (sats from weight column), "fiat_balances": {"EUR": Decimal("100.50"), ...}, "accounts": [{"account": "...", "sats": 150000, "eur": Decimal("100.50")}, ...] } @@ -763,61 +757,48 @@ class FavaClient: user_id_prefix = user_id[:8] - # GROUP BY currency prevents mixing EUR and SATS face values in sum(number). - # sum(weight) gives SATS for both EUR @@ SATS entries and plain SATS entries. - # sum(number) on EUR rows gives the fiat amount; on SATS rows gives sats paid. + # BQL query using sum(weight) for SATS aggregation + # weight column returns the @@ price value (SATS) from price notation query = f""" - SELECT account, currency, sum(number), sum(weight) + SELECT account, sum(number), sum(weight) WHERE account ~ ':User-{user_id_prefix}' AND (account ~ 'Payable' OR account ~ 'Receivable') AND flag = '*' - GROUP BY account, currency + GROUP BY account """ result = await self.query_bql(query) - # First pass: collect EUR fiat totals and SATS weights per account - total_eur_sats = 0 # SATS equivalent of EUR entries (from weight) - total_sats_paid = 0 # SATS from payment entries + total_sats = 0 fiat_balances = {} accounts = [] for row in result["rows"]: - account_name, currency, number_sum, weight_sum = row + account_name, fiat_sum, weight_sum = row - # Parse SATS from weight column (always SATS for both entry formats) - sats_weight = 0 + # Parse fiat amount (sum of EUR/USD amounts) + fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0) + + # Parse SATS from weight column + # weight_sum is an Inventory dict like {"SATS": -10442635.00} + sats_amount = 0 if isinstance(weight_sum, dict) and "SATS" in weight_sum: - sats_weight = int(Decimal(str(weight_sum["SATS"]))) + sats_value = weight_sum["SATS"] + sats_amount = int(Decimal(str(sats_value))) - if currency == "SATS": - # Payment entry: SATS position, track separately - total_sats_paid += int(Decimal(str(number_sum))) if number_sum else 0 - else: - # EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent - fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0) - total_eur_sats += sats_weight + total_sats += sats_amount - if fiat_amount != 0: - if currency not in fiat_balances: - fiat_balances[currency] = Decimal(0) - fiat_balances[currency] += fiat_amount + # Aggregate fiat (assume EUR for now, could be extended) + if fiat_amount != 0: + if "EUR" not in fiat_balances: + fiat_balances["EUR"] = Decimal(0) + fiat_balances["EUR"] += fiat_amount - accounts.append({ - "account": account_name, - "sats": sats_weight, - "eur": fiat_amount - }) - - # Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid) - total_sats = total_eur_sats + total_sats_paid - - # Scale fiat proportionally if partially settled - # e.g., if 80% of SATS debt paid, reduce fiat owed by 80% - if total_eur_sats != 0 and total_sats_paid != 0: - remaining_fraction = Decimal(str(total_sats)) / Decimal(str(total_eur_sats)) - for currency in fiat_balances: - fiat_balances[currency] = (fiat_balances[currency] * remaining_fraction).quantize(Decimal("0.01")) + accounts.append({ + "account": account_name, + "sats": sats_amount, + "eur": fiat_amount + }) logger.info(f"User {user_id[:8]} balance (BQL): {total_sats} sats, fiat: {dict(fiat_balances)}") @@ -829,14 +810,10 @@ class FavaClient: async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]: """ - Get balances for all users using BQL with currency-grouped aggregation. + Get balances for all users using BQL with price notation (efficient admin view). - Groups by account AND currency to correctly handle mixed entry formats: - - Expense entries use EUR @@ SATS (position=EUR, weight=SATS) - - Payment entries use plain SATS (position=SATS, weight=SATS) - - Without currency grouping, sum(number) would mix EUR and SATS face values, - causing wildly inflated fiat amounts for users with payment entries. + Uses sum(weight) to aggregate SATS from @@ price notation in a single query. + This provides significant performance benefits for admin views. Returns: [ @@ -856,22 +833,23 @@ class FavaClient: """ from decimal import Decimal - # GROUP BY currency prevents mixing EUR and SATS face values in sum(number) + # BQL query using sum(weight) for SATS aggregation query = """ - SELECT account, currency, sum(number), sum(weight) + SELECT account, sum(number), sum(weight) WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-') AND flag = '*' - GROUP BY account, currency + GROUP BY account """ result = await self.query_bql(query) - # First pass: collect per-user EUR fiat totals and SATS amounts separately + # Group by user_id user_data = {} for row in result["rows"]: - account_name, currency, number_sum, weight_sum = row + account_name, fiat_sum, weight_sum = row + # Extract user_id from account name if ":User-" not in account_name: continue @@ -883,48 +861,31 @@ class FavaClient: "user_id": user_id, "balance": 0, "fiat_balances": {}, - "accounts": [], - "_eur_sats": 0, # SATS equivalent of EUR entries (from weight) - "_sats_paid": 0, # SATS from payment entries + "accounts": [] } - # Parse SATS from weight column (always SATS for both entry formats) - sats_weight = 0 + # Parse fiat amount + fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0) + + # Parse SATS from weight column + sats_amount = 0 if isinstance(weight_sum, dict) and "SATS" in weight_sum: - sats_weight = int(Decimal(str(weight_sum["SATS"]))) + sats_value = weight_sum["SATS"] + sats_amount = int(Decimal(str(sats_value))) - if currency == "SATS": - # Payment entry: SATS position, track separately - user_data[user_id]["_sats_paid"] += int(Decimal(str(number_sum))) if number_sum else 0 - else: - # EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent - fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0) - user_data[user_id]["_eur_sats"] += sats_weight + user_data[user_id]["balance"] += sats_amount - if fiat_amount != 0: - if currency not in user_data[user_id]["fiat_balances"]: - user_data[user_id]["fiat_balances"][currency] = Decimal(0) - user_data[user_id]["fiat_balances"][currency] += fiat_amount + # Aggregate fiat + if fiat_amount != 0: + if "EUR" not in user_data[user_id]["fiat_balances"]: + user_data[user_id]["fiat_balances"]["EUR"] = Decimal(0) + user_data[user_id]["fiat_balances"]["EUR"] += fiat_amount - user_data[user_id]["accounts"].append({ - "account": account_name, - "sats": sats_weight, - "eur": fiat_amount - }) - - # Second pass: compute net balances and scale fiat for partial settlements - for user_id, data in user_data.items(): - eur_sats = data.pop("_eur_sats") - sats_paid = data.pop("_sats_paid") - - # Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid) - data["balance"] = eur_sats + sats_paid - - # Scale fiat proportionally if partially settled - if eur_sats != 0 and sats_paid != 0: - remaining_fraction = Decimal(str(data["balance"])) / Decimal(str(eur_sats)) - for currency in data["fiat_balances"]: - data["fiat_balances"][currency] = (data["fiat_balances"][currency] * remaining_fraction).quantize(Decimal("0.01")) + user_data[user_id]["accounts"].append({ + "account": account_name, + "sats": sats_amount, + "eur": fiat_amount + }) logger.info(f"Fetched balances for {len(user_data)} users (BQL)") diff --git a/services.py b/services.py index 51c4bd8..1f9d826 100644 --- a/services.py +++ b/services.py @@ -18,29 +18,12 @@ async def get_settings(user_id: str) -> CastleSettings: async def update_settings(user_id: str, data: CastleSettings) -> CastleSettings: - from loguru import logger - - from .fava_client import init_fava_client - settings = await get_castle_settings(user_id) if not settings: settings = await create_castle_settings(user_id, data) else: settings = await update_castle_settings(user_id, data) - # Reinitialize Fava client with new settings - try: - init_fava_client( - fava_url=settings.fava_url, - ledger_slug=settings.fava_ledger_slug, - timeout=settings.fava_timeout, - ) - logger.info( - f"Fava client reinitialized: {settings.fava_url}/{settings.fava_ledger_slug}" - ) - except Exception as e: - logger.error(f"Failed to reinitialize Fava client: {e}") - return settings diff --git a/static/js/index.js b/static/js/index.js index bd52e39..3fc736e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -31,7 +31,6 @@ window.app = Vue.createApp({ userInfo: null, // User information including equity eligibility isAdmin: false, isSuperUser: false, - settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons castleWalletConfigured: false, userWalletConfigured: false, currentExchangeRate: null, // BTC/EUR rate (sats per EUR) @@ -58,9 +57,6 @@ window.app = Vue.createApp({ settingsDialog: { show: false, castleWalletId: '', - favaUrl: 'http://localhost:3333', - favaLedgerSlug: 'castle-ledger', - favaTimeout: 10.0, loading: false }, userWalletDialog: { @@ -521,9 +517,6 @@ window.app = Vue.createApp({ } catch (error) { // Settings not available this.castleWalletConfigured = false - } finally { - // Mark settings as loaded to enable toolbar buttons - this.settingsLoaded = true } }, async loadUserWallet() { @@ -541,9 +534,6 @@ window.app = Vue.createApp({ }, showSettingsDialog() { this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || '' - this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333' - this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'castle-ledger' - this.settingsDialog.favaTimeout = this.settings?.fava_timeout || 10.0 this.settingsDialog.show = true }, showUserWalletDialog() { @@ -559,14 +549,6 @@ window.app = Vue.createApp({ return } - if (!this.settingsDialog.favaUrl) { - this.$q.notify({ - type: 'warning', - message: 'Fava URL is required' - }) - return - } - this.settingsDialog.loading = true try { await LNbits.api.request( @@ -574,10 +556,7 @@ window.app = Vue.createApp({ '/castle/api/v1/settings', this.g.user.wallets[0].adminkey, { - castle_wallet_id: this.settingsDialog.castleWalletId, - fava_url: this.settingsDialog.favaUrl, - fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'castle-ledger', - fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0 + castle_wallet_id: this.settingsDialog.castleWalletId } ) this.$q.notify({ @@ -1424,7 +1403,7 @@ window.app = Vue.createApp({ maxAmount: maxAmountSats, // Positive sats amount castle owes maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive) fiatCurrency: fiatCurrency, - amount: maxAmountSats, // Default to sats since lightning is the default payment method + amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available payment_method: 'lightning', // Default to lightning for paying description: '', reference: '', @@ -1456,9 +1435,8 @@ window.app = Vue.createApp({ memo: `Payment from Castle to ${this.payUserDialog.username}` } ) - console.log(invoiceResponse) - const paymentRequest = invoiceResponse.data.bolt11 + const paymentRequest = invoiceResponse.data.payment_request // Pay the invoice from Castle's wallet const paymentResponse = await LNbits.api.request( diff --git a/static/js/permissions.js b/static/js/permissions.js index 4cc54f0..0de3569 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -1118,3 +1118,5 @@ window.app = Vue.createApp({ } } }) + +window.app.mount('#vue') diff --git a/templates/castle/index.html b/templates/castle/index.html index 2a1e665..6648e6c 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -17,14 +17,13 @@
Track expenses, receivables, and balances for the collective