Compare commits

..
Author SHA1 Message Date
Tiago Vasconcelos 02d5aec56f chore: bundle 2026-07-16 09:52:08 +01:00
Tiago Vasconcelos 993f84022e add to i18n 2026-07-16 09:35:04 +01:00
Tiago Vasconcelos 8c068e1b27 initial change 2026-07-16 09:25:52 +01:00
blackcoffeexbtandGitHub 09c6e7239d fix: Infinite invoice listener loop with VoidWallet (#4061) 2026-07-15 21:33:48 +03:00
blackcoffeexbtandGitHub 53a1ae5e53 fix: UX disable "Send" button after clicking when paying LUD16 (#4034) 2026-07-15 17:41:31 +01:00
61ed636df0 feat: Extensions for all (#4021)
Co-authored-by: alan <alan@lnbits.com>
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2026-07-15 13:50:19 +03:00
43900dd6da feat: add emailConfigured to PublicSettings (#4047)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-13 15:28:28 +02:00
Riccardo BalboandGitHub 06c553219a Merge commit from fork
* fix: daily withdrawal limit

* feat: unit tests for cumulative daily withdrawal
2026-07-13 11:59:49 +03:00
fc73d83bd9 feat: optimise Uvicorn Settings for heavy web socket use (#4028)
Co-authored-by: dadofsambonzuki <dadofsambonzuki@users.noreply.github.com>
Co-authored-by: dni <office@dnilabs.com>
2026-07-13 10:28:34 +02:00
Riccardo BalboandGitHub 4cb743d952 Merge commit from fork 2026-07-13 11:25:08 +03:00
dni ⚡andGitHub 3b6e87b060 fix: docker image dont rebuild after restart (#4057) 2026-07-13 11:17:28 +03:00
50 changed files with 4601 additions and 238 deletions
+1 -1
View File
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+1 -1
View File
@@ -531,7 +531,7 @@ def register_async_tasks() -> None:
async def dispatch_extension_invoice_paid(payment) -> None:
await dispatch_wasm_invoice_paid(payment)
core_app_extra.dispatch_extension_invoice_paid = dispatch_extension_invoice_paid
task_manager.register_invoice_listener(dispatch_extension_invoice_paid, "core_wasm")
# server logs for websocket
if settings.lnbits_admin_ui:
+13 -9
View File
@@ -253,13 +253,14 @@ async def get_wasm_invocation_stats(
since: datetime | None = None,
conn: Connection | None = None,
) -> WasmInvocationStats:
database = conn or db
where: list[str] = []
values: dict = {}
if extension_id:
where.append("extension_id = :extension_id")
values["extension_id"] = extension_id
if since:
where.append("started_at >= :since")
where.append(f"started_at >= {database.timestamp_placeholder('since')}")
values["since"] = since
query = """
@@ -314,11 +315,13 @@ async def delete_old_wasm_invocations(
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
result = await (conn or db).execute(
"""
database = conn or db
result = await database.execute(
f"""
DELETE FROM wasm_invocations
WHERE status != 'running' AND started_at < :cutoff
""",
WHERE status != 'running'
AND started_at < {database.timestamp_placeholder("cutoff")}
""", # noqa: S608
{"cutoff": cutoff},
)
return int(result.rowcount or 0)
@@ -327,13 +330,14 @@ async def delete_old_wasm_invocations(
async def mark_stale_wasm_invocations(
conn: Connection | None = None,
) -> None:
await (conn or db).execute(
"""
database = conn or db
await database.execute(
f"""
UPDATE wasm_invocations
SET status = 'abandoned',
finished_at = :finished_at,
finished_at = {database.timestamp_placeholder("finished_at")},
stop_reason = 'Server restarted before invocation finished.'
WHERE status = 'running'
""",
""", # noqa: S608
{"finished_at": datetime.now(timezone.utc)},
)
+7
View File
@@ -883,3 +883,10 @@ async def m048_add_wasm_runtime_limits_to_installed_extensions(db: Connection):
"ALTER TABLE installed_extensions "
"ADD COLUMN wasm_runtime_limits TEXT DEFAULT '{}'"
)
async def m049_add_permissions_to_user_extensions(db: Connection):
"""
Adds user-level extension permission grants.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'")
+74
View File
@@ -8,8 +8,10 @@ import shutil
import zipfile
from collections.abc import Mapping
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Any
from uuid import uuid4
import httpx
from loguru import logger
@@ -139,11 +141,83 @@ class UserExtensionInfo(BaseModel):
payment_hash_to_enable: str | None = None
class ExtensionBackgroundPaymentDestinationPolicy(str, Enum):
OWN_WALLETS_ONLY = "own_wallets_only"
EXTERNAL_ALLOWED = "external_allowed"
class ExtensionBackgroundPaymentGrant(BaseModel):
id: StrictStr = Field(..., min_length=1, max_length=128)
wallet_id: str = Field(..., min_length=1, max_length=128)
enabled: bool = True
max_amount: int = Field(..., gt=0)
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
class ExtensionBackgroundPaymentGrantRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
max_amount: int = Field(..., gt=0)
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
def to_grant(self, grant_id: str | None = None) -> ExtensionBackgroundPaymentGrant:
return ExtensionBackgroundPaymentGrant(
id=grant_id or str(uuid4()),
wallet_id=self.wallet_id,
enabled=True,
max_amount=self.max_amount,
destination_policy=self.destination_policy,
)
class ExtensionWalletPaymentsWatchGrant(BaseModel):
id: StrictStr = Field(..., min_length=1, max_length=128)
wallet_id: str = Field(..., min_length=1, max_length=128)
enabled: bool = True
class ExtensionWalletPaymentsWatchGrantRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
def to_grant(
self, grant_id: str | None = None
) -> ExtensionWalletPaymentsWatchGrant:
return ExtensionWalletPaymentsWatchGrant(
id=grant_id or str(uuid4()),
wallet_id=self.wallet_id,
enabled=True,
)
class ExtensionPermissionCheckItem(BaseModel):
id: StrictStr
grant: dict[str, Any] = Field(default_factory=dict)
class ExtensionPermissionCheckRequest(BaseModel):
permissions: list[ExtensionPermissionCheckItem] = Field(default_factory=list)
class ExtensionPermissionCheckResult(BaseModel):
id: StrictStr
approved: bool
grant: dict[str, Any] = Field(default_factory=dict)
class ExtensionPermissionCheckResponse(BaseModel):
permissions: list[ExtensionPermissionCheckResult] = Field(default_factory=list)
class ExtensionPermissionsResponse(BaseModel):
extension_permissions: list[ExtensionPermission] = Field(default_factory=list)
user_permissions: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: UserExtensionInfo | None = None
permissions: dict = Field(default_factory=dict)
@property
def is_paid(self) -> bool:
+1 -8
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -11,17 +11,10 @@ def _do_nothing(*_):
pass
async def _do_nothing_async(_: Any) -> None:
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_wasm_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
dispatch_extension_invoice_paid: Callable[[Any], Awaitable[None]] = (
_do_nothing_async
)
def __init__(self) -> None:
self.wasm_extension_registry = WasmExtensionRegistry()
-2
View File
@@ -18,7 +18,6 @@ from lnbits.core.crud import (
)
from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.db import core_app_extra
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
@@ -245,7 +244,6 @@ async def dispatch_payment_notification(payment: Payment) -> None:
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
await core_app_extra.dispatch_extension_invoice_paid(payment)
async def dispatch_webhook(payment: Payment):
+2 -3
View File
@@ -17,7 +17,7 @@ from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.helpers import check_callback_url, daystart_timestamp
from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
@@ -557,10 +557,9 @@ async def check_wallet_daily_withdraw_limit(
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
since=daystart_timestamp(),
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
+396 -1
View File
@@ -1,3 +1,4 @@
import json
import sys
import traceback
from http import HTTPStatus
@@ -9,7 +10,7 @@ from fastapi.requests import Request
from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids
from lnbits.core.crud.wallets import get_wallet, get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import (
SimpleStatus,
@@ -18,12 +19,21 @@ from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionBackgroundPaymentDestinationPolicy,
ExtensionBackgroundPaymentGrant,
ExtensionBackgroundPaymentGrantRequest,
ExtensionConfig,
ExtensionMeta,
ExtensionPermissionCheckRequest,
ExtensionPermissionCheckResponse,
ExtensionPermissionCheckResult,
ExtensionPermissionsResponse,
ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
ExtensionWalletPaymentsWatchGrant,
ExtensionWalletPaymentsWatchGrantRequest,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
@@ -79,6 +89,9 @@ extension_router = APIRouter(
prefix="/api/v1/extension",
)
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
@extension_router.post("", dependencies=[Depends(check_admin)])
async def api_install_extension(data: CreateExtension):
@@ -366,6 +379,213 @@ async def api_disable_extension(
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.post("/{ext_id}/permissions/background-payment")
async def api_grant_background_payment_permission(
ext_id: str,
data: ExtensionBackgroundPaymentGrantRequest,
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
installed_permission_ids = {
permission.id for permission in installed_ext.permissions or []
}
if WALLET_PAY_INVOICE_BACKGROUND_PERMISSION not in installed_permission_ids:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' cannot request background payments.",
)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' is not enabled for this user.",
)
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
if wallet.is_lightning_shared_wallet:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Background payments are not allowed from shared wallets.",
)
if not wallet.can_send_payments:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"This wallet cannot send payments.",
)
permissions = user_ext.permissions or {}
grant = data.to_grant(
_user_permission_grant_id_for_wallet(
permissions,
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
data.wallet_id,
)
)
background_grants = [
existing
for existing in permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, [])
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
]
background_grants.append(json.loads(grant.json()))
permissions[WALLET_PAY_INVOICE_BACKGROUND_PERMISSION] = background_grants
user_ext.permissions = permissions
await update_user_extension(user_ext)
return {"permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "grant": grant}
@extension_router.post("/{ext_id}/permissions/wallet-payments-watch")
async def api_grant_wallet_payments_watch_permission(
ext_id: str,
data: ExtensionWalletPaymentsWatchGrantRequest,
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
installed_permission_ids = {
permission.id for permission in installed_ext.permissions or []
}
if WALLET_PAYMENTS_WATCH_PERMISSION not in installed_permission_ids:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' cannot request wallet payment watch access.",
)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' is not enabled for this user.",
)
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
permissions = user_ext.permissions or {}
grant = data.to_grant(
_user_permission_grant_id_for_wallet(
permissions,
WALLET_PAYMENTS_WATCH_PERMISSION,
data.wallet_id,
)
)
watch_grants = [
existing
for existing in permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION, [])
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
]
watch_grants.append(json.loads(grant.json()))
permissions[WALLET_PAYMENTS_WATCH_PERMISSION] = watch_grants
user_ext.permissions = permissions
await update_user_extension(user_ext)
return {"permission": WALLET_PAYMENTS_WATCH_PERMISSION, "grant": grant}
@extension_router.get("/{ext_id}/permissions")
async def api_get_extension_permissions(
ext_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> ExtensionPermissionsResponse:
installed_ext = await _require_active_wasm_extension(ext_id)
extension_permissions = validate_extension_permissions(
installed_ext.id, installed_ext.permissions, strict=False
)
user_ext = await get_user_extension(account_id.id, ext_id)
return ExtensionPermissionsResponse(
extension_permissions=extension_permissions,
user_permissions=_safe_user_extension_permissions(
user_ext.permissions if user_ext else {}
),
)
@extension_router.delete("/{ext_id}/permissions/user/{grant_id}")
async def api_delete_user_extension_permission(
ext_id: str,
grant_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> SimpleStatus:
await _require_active_wasm_extension(ext_id)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
return SimpleStatus(success=True, message="Permission grant removed.")
user_ext.permissions = _remove_user_permission_grant(
user_ext.permissions or {}, grant_id
)
await update_user_extension(user_ext)
return SimpleStatus(success=True, message="Permission grant removed.")
@extension_router.post("/{ext_id}/permissions/check")
async def api_check_extension_permissions(
ext_id: str,
data: ExtensionPermissionCheckRequest,
account_id: AccountId = Depends(check_account_id_exists),
) -> ExtensionPermissionCheckResponse:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
installed_permission_ids = {
permission.id for permission in installed_ext.permissions or []
}
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' is not enabled for this user.",
)
results: list[ExtensionPermissionCheckResult] = []
for permission in data.permissions:
if permission.id not in installed_permission_ids:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' cannot request '{permission.id}'.",
)
if permission.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION:
results.append(
await _check_background_payment_permission(
account_id.id,
user_ext.permissions or {},
permission.grant,
)
)
continue
if permission.id == WALLET_PAYMENTS_WATCH_PERMISSION:
results.append(
await _check_wallet_payments_watch_permission(
account_id.id,
user_ext.permissions or {},
permission.grant,
)
)
continue
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Unsupported permission check '{permission.id}'.",
)
return ExtensionPermissionCheckResponse(permissions=results)
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
@@ -783,3 +1003,178 @@ async def create_extension_review(
resp.raise_for_status()
payment_request = resp.json()
return ExtensionReviewPaymentRequest(**payment_request)
async def _require_active_wasm_extension(ext_id: str) -> InstallableExtension:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
if not installed_ext.is_wasm:
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' is not a WASM extension."
)
return installed_ext
def _safe_user_extension_permissions(permissions: dict | None) -> dict:
safe_permissions: dict[str, list[dict]] = {}
for permission_id, grants in (permissions or {}).items():
if not isinstance(permission_id, str) or not isinstance(grants, list):
continue
safe_grants = [
grant
for grant in grants
if isinstance(grant, dict) and isinstance(grant.get("id"), str)
]
if safe_grants:
safe_permissions[permission_id] = safe_grants
return safe_permissions
def _user_permission_grant_id_for_wallet(
permissions: dict, permission_id: str, wallet_id: str
) -> str | None:
grants = permissions.get(permission_id)
if not isinstance(grants, list):
return None
for grant in grants:
if not isinstance(grant, dict) or grant.get("wallet_id") != wallet_id:
continue
grant_id = grant.get("id")
return grant_id if isinstance(grant_id, str) and grant_id else None
return None
def _remove_user_permission_grant(permissions: dict, grant_id: str) -> dict:
updated_permissions = dict(permissions or {})
for permission_id, grants in list(updated_permissions.items()):
if not isinstance(grants, list):
continue
remaining_grants = [
grant
for grant in grants
if not isinstance(grant, dict) or grant.get("id") != grant_id
]
if remaining_grants:
updated_permissions[permission_id] = remaining_grants
else:
updated_permissions.pop(permission_id, None)
return updated_permissions
async def _check_background_payment_permission(
account_id: str,
permissions: dict,
grant_data: dict,
) -> ExtensionPermissionCheckResult:
try:
data = ExtensionBackgroundPaymentGrantRequest.parse_obj(grant_data)
except ValueError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
if wallet.is_lightning_shared_wallet:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Background payments are not allowed from shared wallets.",
)
if not wallet.can_send_payments:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"This wallet cannot send payments.",
)
requested_grant = data.to_grant()
existing_grant = _find_background_payment_grant(
permissions, requested_grant.wallet_id
)
covered = (
existing_grant is not None
and existing_grant.enabled
and existing_grant.max_amount >= requested_grant.max_amount
and _background_destination_policy_covers(
existing_grant.destination_policy, requested_grant.destination_policy
)
)
grant = existing_grant if covered and existing_grant else requested_grant
return ExtensionPermissionCheckResult(
id=WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
approved=covered,
grant=json.loads(grant.json()),
)
async def _check_wallet_payments_watch_permission(
account_id: str,
permissions: dict,
grant_data: dict,
) -> ExtensionPermissionCheckResult:
try:
data = ExtensionWalletPaymentsWatchGrantRequest.parse_obj(grant_data)
except ValueError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
existing_grant = _find_wallet_payments_watch_grant(permissions, data.wallet_id)
covered = bool(existing_grant and existing_grant.enabled)
grant = existing_grant if covered and existing_grant else data.to_grant()
return ExtensionPermissionCheckResult(
id=WALLET_PAYMENTS_WATCH_PERMISSION,
approved=covered,
grant=json.loads(grant.json()),
)
def _find_background_payment_grant(
permissions: dict, wallet_id: str
) -> ExtensionBackgroundPaymentGrant | None:
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
if not isinstance(grants, list):
return None
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.wallet_id == wallet_id:
return grant
return None
def _find_wallet_payments_watch_grant(
permissions: dict, wallet_id: str
) -> ExtensionWalletPaymentsWatchGrant | None:
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
if not isinstance(grants, list):
return None
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.wallet_id == wallet_id:
return grant
return None
def _background_destination_policy_covers(
existing: ExtensionBackgroundPaymentDestinationPolicy,
requested: ExtensionBackgroundPaymentDestinationPolicy,
) -> bool:
return (
existing == requested
or existing == ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
)
+5 -4
View File
@@ -158,10 +158,6 @@ async def api_update_user(
async def api_users_delete_user(
user_id: str, account: Account = Depends(check_admin)
) -> SimpleStatus:
wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets:
await delete_wallet_by_id(wallet.id)
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -173,6 +169,11 @@ async def api_users_delete_user(
status_code=HTTPStatus.BAD_REQUEST,
detail="Only super_user can delete admin user.",
)
wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets:
await delete_wallet_by_id(wallet.id)
await delete_account(user_id)
return SimpleStatus(success=True, message="User deleted.")
@@ -0,0 +1,124 @@
from __future__ import annotations
from typing import Any, NoReturn
from bolt11 import decode as bolt11_decode
from loguru import logger
from lnbits.core.crud.extensions import get_user_extension
from lnbits.core.crud.payments import check_internal
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.extensions import (
ExtensionBackgroundPaymentDestinationPolicy,
ExtensionBackgroundPaymentGrant,
)
from lnbits.core.models.wallets import Wallet
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
async def background_payment_extra(
*,
extension_id: str,
wallet: Wallet,
payment_request: str,
amount_msat: int,
) -> dict[str, Any]:
grant = await _background_payment_grant(extension_id, wallet, amount_msat)
await _check_destination_policy(extension_id, wallet, grant, payment_request)
return {
"tag": extension_id,
"extension": extension_id,
"background_payment": True,
"background_permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
"background_wallet_id": wallet.source_wallet_id,
"background_destination_policy": grant.destination_policy.value,
}
def invoice_amount_msat(payment_request: str) -> int:
invoice = bolt11_decode(payment_request)
amount_msat = int(invoice.amount_msat or 0)
if amount_msat <= 0:
raise PermissionError("Background payments require an invoice amount.")
return amount_msat
async def _background_payment_grant(
extension_id: str,
wallet: Wallet,
amount_msat: int,
) -> ExtensionBackgroundPaymentGrant:
if wallet.is_lightning_shared_wallet:
_deny(extension_id, wallet, amount_msat, "shared wallet")
if not wallet.can_send_payments:
_deny(extension_id, wallet, amount_msat, "wallet cannot send payments")
user_extension = await get_user_extension(wallet.user, extension_id)
if not user_extension or not user_extension.active:
_deny(extension_id, wallet, amount_msat, "extension disabled for user")
permissions = user_extension.permissions or {}
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
if not isinstance(grants, list):
_deny(extension_id, wallet, amount_msat, "missing background payment grant")
grant = _find_wallet_grant(grants, wallet.id)
if not grant:
_deny(extension_id, wallet, amount_msat, "missing wallet background grant")
if not grant.enabled:
_deny(extension_id, wallet, amount_msat, "background grant disabled")
if amount_msat > grant.max_amount * 1000:
_deny(extension_id, wallet, amount_msat, "payment exceeds max amount")
return grant
def _find_wallet_grant(
grants: list[Any], wallet_id: str
) -> ExtensionBackgroundPaymentGrant | None:
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.wallet_id == wallet_id:
return grant
return None
async def _check_destination_policy(
extension_id: str,
wallet: Wallet,
grant: ExtensionBackgroundPaymentGrant,
payment_request: str,
) -> None:
if (
grant.destination_policy
== ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
):
return
payment_hash = bolt11_decode(payment_request).payment_hash
internal_payment = await check_internal(payment_hash)
if not internal_payment:
_deny(extension_id, wallet, 0, "external destination not allowed")
destination_wallet = await get_wallet(internal_payment.wallet_id)
if not destination_wallet or destination_wallet.user != wallet.user:
_deny(extension_id, wallet, 0, "destination wallet is not owned by user")
def _deny(extension_id: str, wallet: Wallet, amount_msat: int, reason: str) -> NoReturn:
logger.warning(
"WASM extension '{}' denied background payment from wallet '{}', "
"user '{}', amount_msat '{}': {}.",
extension_id,
wallet.id,
wallet.user,
amount_msat,
reason,
)
raise PermissionError(reason)
+143 -49
View File
@@ -17,6 +17,11 @@ from ..storage.crud import (
storage_get_row,
storage_set_row,
)
from .background_payments import (
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
background_payment_extra,
invoice_amount_msat,
)
from .models import (
CreateInvoicePublicRequest,
CreateInvoiceRequest,
@@ -31,6 +36,7 @@ from .models import (
NowResponse,
PayInvoiceRequest,
PayInvoiceResponse,
PayLnurlRequest,
RandomIdRequest,
RandomIdResponse,
StorageDeleteRequest,
@@ -50,11 +56,6 @@ from .registry import extension_api_method
logger = logging.getLogger("lnbits.extensions")
def _looks_like_lnurl_pay_target(payment_request: str) -> bool:
normalized = payment_request.strip().lower()
return normalized.startswith(("lnurl", "lightning:lnurl")) or "@" in normalized
class ExtensionHostAPI:
def __init__(
self,
@@ -65,7 +66,6 @@ class ExtensionHostAPI:
access_token: str | None = None,
context: str = "user",
owner_id: str | None = None,
wallet_id: str | None = None,
invocation_id: str | None = None,
runtime_limits: dict[str, int] | None = None,
) -> None:
@@ -75,12 +75,15 @@ class ExtensionHostAPI:
self.access_token = access_token
self.context = context
self.owner_id = sha256s(user_id) if user_id else owner_id
self.wallet_id = wallet_id
self.invocation_id = invocation_id
self.runtime_limits = runtime_limits or {}
from .utils import ExtensionAPIUtils
self.utils = ExtensionAPIUtils(self.extension_id, self.permissions)
self.utils = ExtensionAPIUtils(
self.extension_id,
self.permissions,
authenticated=self.has_authenticated_context(),
)
@extension_api_method(
method_id="storage.get",
@@ -369,66 +372,144 @@ class ExtensionHostAPI:
host_name="pay_invoice",
sdk_name="payInvoice",
description="Pay a Lightning invoice from a wallet available to the user.",
required_permission="wallet.pay_invoice",
)
async def wallet_pay_invoice(
self, request: PayInvoiceRequest
) -> PayInvoiceResponse:
from lnurl import LnurlResponseException
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.services.lnurl import get_pr_from_lnurl
from lnbits.core.services.payments import pay_invoice
from lnbits.exceptions import PaymentError
wallet = await get_wallet(request.wallet_id)
if wallet is None:
raise PermissionError("Paying invoices from this wallet is not allowed.")
if self.user_id:
if wallet.user != self.user_id:
raise PermissionError(
"Paying invoices from this wallet is not allowed."
)
elif not (self.context == "event" and request.wallet_id == self.wallet_id):
raise PermissionError(
"Paying an invoice requires an authenticated user context."
)
try:
payment_request = request.payment_request
if _looks_like_lnurl_pay_target(payment_request):
if request.max_sat is None:
return PayInvoiceResponse(
ok=False,
error="max_sat is required for LNURL payments.",
if self.user_id:
self.require_permission("wallet.pay_invoice")
if wallet.user != self.user_id:
raise PermissionError(
"Paying invoices from this wallet is not allowed."
)
payment_request = await get_pr_from_lnurl(
payment_request,
request.max_sat * 1000,
request.description or None,
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=request.payment_request,
max_sat=request.max_sat,
extra={"tag": self.extension_id, **request.extra},
description=request.description,
tag=self.extension_id,
)
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=payment_request,
max_sat=request.max_sat,
extra={"tag": self.extension_id, **request.extra},
description=request.description,
tag=self.extension_id,
)
except (PaymentError, ValueError, LnurlResponseException) as exc:
else:
self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
amount_msat = invoice_amount_msat(request.payment_request)
extra = await background_payment_extra(
extension_id=self.extension_id,
wallet=wallet,
payment_request=request.payment_request,
amount_msat=amount_msat,
)
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=request.payment_request,
max_sat=request.max_sat,
extra={**request.extra, **extra},
description=request.description,
tag=self.extension_id,
)
except (PaymentError, PermissionError, ValueError) as exc:
return PayInvoiceResponse(ok=False, error=str(exc))
return PayInvoiceResponse(
ok=True,
checking_id=payment.checking_id,
payment_hash=payment.payment_hash,
status=payment.status,
amount_msat=abs(payment.amount),
fee_msat=abs(payment.fee),
pending=payment.pending,
success=payment.success,
return _pay_invoice_response(payment)
@extension_api_method(
method_id="wallet.pay_lnurl",
namespace="wallet",
name="Pay LNURL",
host_name="pay_lnurl",
sdk_name="payLnurl",
description="Pay a Lightning Address or LNURL-pay request from a wallet.",
)
async def wallet_pay_lnurl(self, request: PayLnurlRequest) -> PayInvoiceResponse:
from lnurl import LnAddressError, LnurlResponseException
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.lnurl import CreateLnurlPayment
from lnbits.core.services.lnurl import fetch_lnurl_pay_request
from lnbits.core.services.payments import pay_invoice
from lnbits.exceptions import PaymentError
from .lnurl import (
lnurl_for_core,
lnurl_pay_response_text,
lnurl_payment_amount_for_core,
lnurl_payment_unit_for_core,
)
wallet = await get_wallet(request.wallet_id)
if wallet is None:
raise PermissionError("Paying from this wallet is not allowed.")
try:
if self.user_id:
self.require_permission("wallet.pay_invoice")
if wallet.user != self.user_id:
raise PermissionError("Paying from this wallet is not allowed.")
else:
self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
unit = lnurl_payment_unit_for_core(request.currency)
res, action = await fetch_lnurl_pay_request(
data=CreateLnurlPayment(
lnurl=lnurl_for_core(request.lnurl),
amount=lnurl_payment_amount_for_core(request.amount),
unit=unit,
comment=request.comment,
internal_memo=request.description or None,
),
wallet=None,
)
extra = {"tag": self.extension_id, **request.extra}
if action.successAction:
extra["success_action"] = action.successAction.json()
if request.comment:
extra["comment"] = request.comment
if unit != "sat":
extra["fiat_currency"] = unit
extra["fiat_amount"] = str(request.amount)
if not self.user_id:
amount_msat = invoice_amount_msat(str(action.pr))
extra = {
**extra,
**(
await background_payment_extra(
extension_id=self.extension_id,
wallet=wallet,
payment_request=str(action.pr),
amount_msat=amount_msat,
)
),
}
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=str(action.pr),
max_sat=request.max_sat,
extra=extra,
description=request.description or lnurl_pay_response_text(res),
tag=self.extension_id,
)
except (
LnAddressError,
LnurlResponseException,
PaymentError,
PermissionError,
ValueError,
) as exc:
return PayInvoiceResponse(ok=False, error=str(exc))
return _pay_invoice_response(payment)
@extension_api_method(
method_id="http.request",
namespace="http",
@@ -622,3 +703,16 @@ class ExtensionHostAPI:
f"owner_id={self.owner_id!r}"
")"
)
def _pay_invoice_response(payment: Any) -> PayInvoiceResponse:
return PayInvoiceResponse(
ok=True,
checking_id=payment.checking_id,
payment_hash=payment.payment_hash,
status=payment.status,
amount_msat=abs(payment.amount),
fee_msat=abs(payment.fee),
pending=payment.pending,
success=payment.success,
)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import json
from typing import Any
from lnurl import LnAddress, Lnurl
def normalize_lnurl(value: str) -> str:
normalized = value.strip()
if normalized.lower().startswith("lightning:"):
normalized = normalized[len("lightning:") :]
if "@" in normalized:
normalized = normalized.lower()
if not normalized:
raise ValueError("LNURL is required.")
return normalized
def lnurl_for_core(value: str) -> Lnurl | LnAddress:
normalized = normalize_lnurl(value)
if "@" in normalized:
return LnAddress(normalized)
return Lnurl(normalized)
def lnurl_payment_amount_for_core(amount: float) -> int:
if amount <= 0:
raise ValueError("Amount must be greater than zero.")
return round(amount * 1000)
def lnurl_payment_unit_for_core(currency: str) -> str:
unit = currency.strip().lower()
if not unit:
raise ValueError("Currency is required.")
if unit in {"sat", "sats"}:
return "sat"
return unit.upper()
def lnurl_pay_response_metadata_json(response: Any) -> str:
metadata = getattr(response, "metadata", None)
if metadata is None:
return "[]"
metadata_list = getattr(metadata, "list", None)
try:
if callable(metadata_list):
return json.dumps(metadata_list())
return json.dumps(metadata)
except TypeError:
return json.dumps(str(metadata))
def lnurl_pay_response_text(response: Any) -> str:
description = getattr(response, "description", None)
if description is not None:
return str(description)
metadata = getattr(response, "metadata", None)
text = getattr(metadata, "text", None)
return str(text) if text is not None else ""
def lnurl_pay_response_int(response: Any, snake_name: str, camel_name: str) -> int:
value = getattr(response, snake_name, None)
if value is None:
value = getattr(response, camel_name, 0)
return int(value or 0)
+27
View File
@@ -181,6 +181,17 @@ class PayInvoiceRequest(BaseModel):
extra: dict[str, str] = Field(default_factory=dict)
class PayLnurlRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
lnurl: str = Field(..., min_length=1, max_length=2048)
amount: float = Field(..., gt=0)
currency: str = Field("sat", min_length=1, max_length=8)
comment: str | None = Field(None, max_length=512)
description: str = Field("", max_length=512)
max_sat: int | None = Field(None, gt=0)
extra: dict[str, str] = Field(default_factory=dict)
class PayInvoiceResponse(BaseModel):
ok: bool = True
error: str | None = None
@@ -282,6 +293,22 @@ class SatsToFiatResponse(BaseModel):
amount: float
class LnurlResolveRequest(BaseModel):
lnurl: str = Field(..., min_length=1, max_length=2048)
class LnurlResolveResponse(BaseModel):
lnurl: str
domain: str | None = None
description: str = ""
min_sendable_msat: int
max_sendable_msat: int
comment_allowed: int = 0
fixed: bool = False
image: str | None = None
metadata_json: str = "[]"
class ServerHealthResponse(BaseModel):
server_time: int
up_time: str
+6 -1
View File
@@ -10,7 +10,12 @@ from pydantic import BaseModel
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
_EXTENSION_RUNTIME_PERMISSION_IDS = {"ui.camera.scan_qr"}
_EXTENSION_RUNTIME_PERMISSION_IDS = {
"ui.camera.scan_qr",
"wallet.pay_invoice",
"wallet.pay_invoice_background",
"wallet.payments.watch",
}
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
+94 -6
View File
@@ -5,6 +5,9 @@ from collections.abc import Iterable
from datetime import datetime
from typing import Any
from lnurl import LnurlErrorResponse, LnurlPayResponse, LnurlResponseException
from lnurl import handle as lnurl_handle
from lnbits import bolt11
from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash, verify_preimage
@@ -15,6 +18,12 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat,
)
from .lnurl import (
lnurl_pay_response_int,
lnurl_pay_response_metadata_json,
lnurl_pay_response_text,
normalize_lnurl,
)
from .models import (
Bolt11Request,
CurrencyConvertRequest,
@@ -30,6 +39,8 @@ from .models import (
InvoiceExpiryResponse,
InvoiceMemoResponse,
InvoicePaymentHashResponse,
LnurlResolveRequest,
LnurlResolveResponse,
RandomSecretAndHashRequest,
RandomSecretAndHashResponse,
SatsToFiatRequest,
@@ -43,17 +54,39 @@ from .registry import extension_api_method
class ExtensionAPIUtils:
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
def __init__(
self,
extension_id: str,
permissions: Iterable[str],
*,
authenticated: bool = False,
) -> None:
permission_set = set(permissions)
self.currencies = ExtensionCurrencyUtils(extension_id, permission_set)
self.server = ExtensionServerUtils(extension_id, permission_set)
self.lightning = ExtensionLightningUtils(extension_id, permission_set)
self.currencies = ExtensionCurrencyUtils(
extension_id, permission_set, authenticated=authenticated
)
self.server = ExtensionServerUtils(
extension_id, permission_set, authenticated=authenticated
)
self.lightning = ExtensionLightningUtils(
extension_id, permission_set, authenticated=authenticated
)
self.lnurl = ExtensionLnurlUtils(
extension_id, permission_set, authenticated=authenticated
)
class _ExtensionAPIUtilsGroup:
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
def __init__(
self,
extension_id: str,
permissions: Iterable[str],
*,
authenticated: bool = False,
) -> None:
self.extension_id = extension_id
self.permissions = set(permissions)
self.authenticated = authenticated
def require_permission(self, permission: str | None) -> None:
if permission and permission not in self.permissions:
@@ -62,7 +95,7 @@ class _ExtensionAPIUtilsGroup:
)
def has_authenticated_context(self) -> bool:
return False
return self.authenticated
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
@@ -193,6 +226,60 @@ class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
)
class ExtensionLnurlUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.lnurl.resolve",
namespace="utils.lnurl",
name="Resolve LNURL-pay",
host_interface="utils-lnurl",
host_name="resolve",
sdk_name="resolve",
description="Resolve a Lightning Address or LNURL-pay request.",
required_permission="wallet.pay_invoice",
require_auth=True,
)
async def resolve(self, request: LnurlResolveRequest) -> LnurlResolveResponse:
normalized_lnurl = normalize_lnurl(request.lnurl)
response = await lnurl_handle(
normalized_lnurl,
user_agent=settings.user_agent,
timeout=5,
)
if isinstance(response, LnurlErrorResponse):
raise LnurlResponseException(response.reason)
if not isinstance(response, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
min_sendable_msat = lnurl_pay_response_int(
response, "min_sendable", "minSendable"
)
max_sendable_msat = lnurl_pay_response_int(
response, "max_sendable", "maxSendable"
)
image = getattr(response, "image", None)
return LnurlResolveResponse(
lnurl=normalized_lnurl,
domain=getattr(response, "domain", None),
description=lnurl_pay_response_text(response),
min_sendable_msat=min_sendable_msat,
max_sendable_msat=max_sendable_msat,
comment_allowed=lnurl_pay_response_int(
response, "comment_allowed", "commentAllowed"
),
fixed=bool(
getattr(
response,
"fixed",
min_sendable_msat == max_sendable_msat,
)
),
image=str(image) if image is not None else None,
metadata_json=lnurl_pay_response_metadata_json(response),
)
class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.lightning.decode_invoice",
@@ -335,6 +422,7 @@ def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGro
return {
"utils.currencies": ExtensionCurrencyUtils,
"utils.server": ExtensionServerUtils,
"utils.lnurl": ExtensionLnurlUtils,
"utils.lightning": ExtensionLightningUtils,
}
+30 -9
View File
@@ -12,7 +12,7 @@ from lnbits.core.crud import update_migration_version
from lnbits.core.db import db as core_db
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import POSTGRES, SQLITE, Connection, Database
from lnbits.db import POSTGRES, SQLITE, Compat, Connection, Database
from lnbits.settings import settings
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
@@ -76,9 +76,16 @@ async def storage_set_row(
) -> None:
table_schema = _load_table_schema(ext_id, table)
clean_data = _data_to_db(table_schema, data, require_id=True)
clean_data[OWNER_ID_FIELD] = owner_id
columns = list(clean_data.keys())
placeholders = [f":{column}" for column in columns]
database = Database(f"ext_{ext_id}")
fields = _fields_by_name(table_schema)
placeholders = [
_value_placeholder(database, fields[column], column) for column in columns
]
clean_data[OWNER_ID_FIELD] = owner_id
columns.append(OWNER_ID_FIELD)
placeholders.append(f":{OWNER_ID_FIELD}")
updates = [
f"{column} = excluded.{column}"
for column in columns
@@ -87,19 +94,19 @@ async def storage_set_row(
conflict_sql = (
"DO UPDATE SET "
+ ", ".join(updates)
+ f" WHERE {OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
+ f" WHERE storage_row.{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
if updates
else "DO NOTHING"
)
query = f"""
INSERT INTO {_table_ref_for_schema(ext_id, table)}
INSERT INTO {_table_ref_for_schema(ext_id, table)} AS storage_row
({", ".join(columns)})
VALUES
({", ".join(placeholders)})
ON CONFLICT (id) {conflict_sql}
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
async with database.connect() as conn:
await conn.execute(query, clean_data)
@@ -117,7 +124,10 @@ async def storage_get_paginated_rows(
offset: int,
) -> dict[str, Any]:
table_schema = _load_table_schema(ext_id, table)
where_sql, values = _where_sql(table_schema, filters, search, search_fields)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, table_schema, filters, search, search_fields
)
where_sql = _append_owner_where_sql(where_sql)
values[OWNER_ID_FIELD] = owner_id
order_sql = _order_sql(table_schema, sort_by, descending)
@@ -137,7 +147,7 @@ async def storage_get_paginated_rows(
{where_sql}
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
async with database.connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
@@ -403,14 +413,25 @@ def _filters_to_db(
}
def _value_placeholder(db: Compat, field: dict[str, Any], key: str) -> str:
if field.get("type") == "datetime" and not field.get("list"):
return db.timestamp_placeholder(key)
return f":{key}"
def _where_sql(
db: Compat,
table_schema: dict[str, Any],
filters: dict[str, Any],
search: str | None,
search_fields: list[str],
) -> tuple[str, dict[str, Any]]:
clean_filters = _filters_to_db(table_schema, filters)
clauses = [f"{field} = :filter_{field}" for field in clean_filters]
fields = _fields_by_name(table_schema)
clauses = [
f"{field} = {_value_placeholder(db, fields[field], f'filter_{field}')}"
for field in clean_filters
]
values = {f"filter_{field}": value for field, value in clean_filters.items()}
clean_search = search.strip().lower() if search else ""
+118 -7
View File
@@ -4,21 +4,53 @@ from typing import Any
from loguru import logger
from lnbits.core.crud.extensions import get_installed_extension
from lnbits.core.crud.extensions import get_installed_extension, get_user_extensions
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.db import core_app_extra
from lnbits.core.models.extensions import ExtensionWalletPaymentsWatchGrant
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
from lnbits.helpers import sha256s
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
async def dispatch_wasm_invoice_paid(payment: Any) -> None:
targets: dict[str, tuple[Any, str | None]] = {}
extension_id = _payment_extension_id(payment)
if not extension_id:
return
if extension_id:
extension = core_app_extra.wasm_extension_registry.get(extension_id)
if extension:
targets[extension_id] = (
extension,
await _wasm_invoice_paid_owner_id(extension, payment),
)
extension = core_app_extra.wasm_extension_registry.get(extension_id)
if not extension:
return
wallet = await _payment_wallet(payment)
if wallet:
wallet_owner_id = sha256s(wallet.user)
for watch_extension_id in await _wallet_watch_extension_ids(
wallet.user, wallet.id
):
extension = core_app_extra.wasm_extension_registry.get(watch_extension_id)
if not extension:
continue
if watch_extension_id in targets:
existing_extension, existing_owner_id = targets[watch_extension_id]
targets[watch_extension_id] = (
existing_extension,
existing_owner_id or wallet_owner_id,
)
continue
targets[watch_extension_id] = (extension, wallet_owner_id)
for extension, owner_id in targets.values():
await _dispatch_wasm_invoice_paid_to_extension(extension, payment, owner_id)
async def _dispatch_wasm_invoice_paid_to_extension(
extension: Any, payment: Any, owner_id: str | None
) -> None:
export_name = _wasm_invoice_paid_export(extension.config)
if not export_name:
return
@@ -31,7 +63,6 @@ async def dispatch_wasm_invoice_paid(payment: Any) -> None:
return
try:
owner_id = await _wasm_invoice_paid_owner_id(extension, payment)
await invoke_wasm_extension_export(
extension.id,
export_name,
@@ -51,6 +82,86 @@ async def dispatch_wasm_invoice_paid(payment: Any) -> None:
)
async def _payment_wallet(payment: Any) -> Any | None:
wallet_id = getattr(payment, "wallet_id", None)
if not isinstance(wallet_id, str) or not wallet_id:
return None
try:
return await get_wallet(wallet_id)
except Exception as exc:
logger.warning(f"Could not fetch wallet '{wallet_id}' for WASM event: {exc!s}")
return None
async def _wallet_watch_extension_ids(user_id: str, wallet_id: str) -> list[str]:
try:
user_extensions = await get_user_extensions(user_id)
except Exception as exc:
logger.warning(
f"Could not fetch extensions for wallet payment watch user "
f"'{user_id}': {exc!s}"
)
return []
extension_ids: list[str] = []
for user_extension in user_extensions:
if not user_extension.active:
continue
if not _has_wallet_watch_grant(user_extension, wallet_id):
continue
if not core_app_extra.wasm_extension_registry.get(user_extension.extension):
continue
try:
installed_extension = await get_installed_extension(
user_extension.extension
)
except Exception as exc:
logger.warning(
f"Could not fetch installed extension '{user_extension.extension}' "
f"for wallet payment watch: {exc!s}"
)
continue
if not installed_extension or not installed_extension.active:
continue
if not _extension_has_permission(
installed_extension, WALLET_PAYMENTS_WATCH_PERMISSION
):
continue
extension_ids.append(user_extension.extension)
return extension_ids
def _has_wallet_watch_grant(user_extension: Any, wallet_id: str) -> bool:
permissions = user_extension.permissions or {}
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
if not isinstance(grants, list):
return False
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.enabled and grant.wallet_id == wallet_id:
return True
return False
def _extension_has_permission(extension: Any, permission_id: str) -> bool:
return any(
(
permission.get("id")
if isinstance(permission, dict)
else getattr(permission, "id", None)
)
== permission_id
for permission in (extension.permissions or [])
)
def _payment_extension_id(payment: Any) -> str | None:
if isinstance(payment.extension, str) and payment.extension:
return payment.extension
-1
View File
@@ -83,7 +83,6 @@ async def invoke_wasm_extension_export(
access_token=access_token,
context=context,
owner_id=owner_id,
wallet_id=wallet_id,
invocation_id=invocation.id,
runtime_limits=limits,
)
+11 -4
View File
@@ -725,20 +725,20 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if get_origin(outertype_) is list:
_items = _safe_load_json(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
dict_to_submodel(type_, v) if _is_subclass(type_, BaseModel) else v
for v in _items
]
continue
if issubclass(type_, bool):
if _is_subclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, datetime):
if _is_subclass(type_, datetime):
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value.replace(tzinfo=timezone.utc)
continue
if issubclass(type_, BaseModel):
if _is_subclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
@@ -763,6 +763,13 @@ def _safe_load_json(value: str) -> dict:
return {}
def _is_subclass(type_: Any, class_or_tuple: type | tuple[type, ...]) -> bool:
try:
return issubclass(type_, class_or_tuple)
except TypeError:
return False
def _valid_sql_name(name: str) -> bool:
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
return (
+10
View File
@@ -372,3 +372,13 @@ def sha256s(value: str) -> str:
Returns the hex as a string.
"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def daystart_timestamp(dt: datetime | None = None) -> int:
"""
Returns the timestamp of the start of the day for the given
datetime (or now in UTC if not provided).
"""
dt = dt or datetime.now(timezone.utc)
day_start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
return int(day_start.timestamp())
+6
View File
@@ -27,6 +27,8 @@ from lnbits.settings import set_cli_settings, settings
@click.option(
"--reload", is_flag=True, default=False, help="Enable auto-reload for development"
)
@click.option("--ws-max-queue", default=128, help="Websocket max queue size")
@click.option("--ws-ping-timeout", default=60.0, help="Websocket ping timeout")
def main(
port: int,
host: str,
@@ -34,6 +36,8 @@ def main(
ssl_keyfile: str,
ssl_certfile: str,
reload: bool,
ws_max_queue: int,
ws_ping_timeout: float,
):
"""Launched with `uv run lnbits` at root level"""
@@ -58,6 +62,8 @@ def main(
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
reload=reload or False,
ws_ping_timeout=ws_ping_timeout,
ws_max_queue=ws_max_queue,
)
server = uvicorn.Server(config=config)
+7
View File
@@ -524,6 +524,11 @@ class NotificationsSettings(LNbitsSettings):
and self.lnbits_telegram_notifications_access_token is not None
)
def is_email_notifications_configured(self) -> bool:
return self.lnbits_email_notifications_enabled and bool(
self.lnbits_email_notifications_email
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
@@ -1324,6 +1329,7 @@ class PublicSettings(BaseModel):
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
ext_builder: bool = Field(alias="extBuilder")
nostr_configured: bool = Field(alias="nostrConfigured")
email_configured: bool = Field(alias="emailConfigured")
telegram_configured: bool = Field(alias="telegramConfigured")
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
@@ -1388,6 +1394,7 @@ class PublicSettings(BaseModel):
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
nostrConfigured=settings.is_nostr_notifications_configured(),
emailConfigured=settings.is_email_notifications_configured(),
telegramConfigured=settings.is_telegram_notifications_configured(),
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+20 -1
View File
@@ -98,6 +98,10 @@ window.localisation.en = {
view_swagger_docs: 'View LNbits Swagger API docs',
api_docs: 'API docs',
api_keys_api_docs: 'Node URL, API keys and API docs',
api_keys_warning:
'These keys should be kept safe, sharing them could risk losing funds.',
admin_key_warning:
'Your admin key grants full access to your wallet, including the ability to send payments. Never share it unless you fully trust the recipient.',
lnbits_version: 'LNbits version',
runs_on: 'Runs on',
paste: 'Paste',
@@ -116,6 +120,7 @@ window.localisation.en = {
read: 'Read',
write: 'Write',
pay: 'Pay',
sending: 'Sending',
memo: 'Memo',
date: 'Date',
path: 'Path',
@@ -520,6 +525,14 @@ window.localisation.en = {
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
extension_permissions_title: 'Grant extension permissions',
extension_permissions_tab: 'Extension Permissions',
user_permissions_tab: 'My Grants',
extension_permissions_none: 'This extension has no install-time permissions.',
user_permissions_none:
'You have not granted any permissions for this extension.',
user_permissions_max_amount: 'Max payment amount',
user_permissions_destination_policy: 'Allowed destinations',
user_permissions_no_editable_settings: 'This grant has no editable settings.',
extension_permissions_grant_install: 'Grant and install',
extension_permissions_high_risk_warning:
'This extension requests permissions that can move funds.',
@@ -528,6 +541,10 @@ window.localisation.en = {
extension_permission_risk_high: 'High risk',
extension_permission_warning_wallet_pay_invoice:
'Can spend funds from wallets available to your account.',
extension_permission_warning_wallet_pay_invoice_background:
'Can spend funds later from approved wallets without an active click.',
extension_permission_warning_wallet_payments_watch:
'Can read payment metadata for approved wallets.',
extension_permission_warning_extension_api_request_write:
'Can write data or trigger actions in approved extensions.',
extension_permission_ext_storage_read: 'Read extension storage',
@@ -542,13 +559,15 @@ window.localisation.en = {
extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_utils_basic: 'Use basic LNbits utilities',
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
extension_permission_payments_watch: 'Watch payments',
extension_permission_wallet_payments_watch: 'Watch wallet payments',
extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public:
'Create Lightning invoices from public pages',
extension_permission_wallet_balance_read: 'View wallet balances',
extension_permission_wallet_list: 'List wallets',
extension_permission_wallet_pay_invoice: 'Pay invoices',
extension_permission_wallet_pay_invoice_background:
'Make background payments',
create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
@@ -28,12 +28,12 @@
}
}
function mediumRisk(translateFn) {
function mediumRisk(translateFn, warningKey) {
return {
level: 'medium',
color: 'warning',
label: translate(translateFn, 'extension_permission_risk_medium'),
warning: ''
warning: warningKey ? translate(translateFn, warningKey) : ''
}
}
@@ -77,10 +77,16 @@
}
function permissionRiskForPermission(permission, extensions, translateFn) {
if (permission.id === 'wallet.pay_invoice') {
if (
['wallet.pay_invoice', 'wallet.pay_invoice_background'].includes(
permission.id
)
) {
return highRisk(
translateFn,
'extension_permission_warning_wallet_pay_invoice'
permission.id === 'wallet.pay_invoice_background'
? 'extension_permission_warning_wallet_pay_invoice_background'
: 'extension_permission_warning_wallet_pay_invoice'
)
}
if (permission.id === 'extension.api.request') {
@@ -98,13 +104,18 @@
if (permission.id === 'http.request') {
return mediumRisk(translateFn)
}
if (permission.id === 'wallet.payments.watch') {
return mediumRisk(
translateFn,
'extension_permission_warning_wallet_payments_watch'
)
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.read_public',
'payments.watch'
'ext.storage.read_public'
].includes(permission.id)
) {
return mediumRisk(translateFn)
@@ -124,6 +135,8 @@
function permissionOrderIndex(permissionId) {
const order = [
'wallet.pay_invoice',
'wallet.pay_invoice_background',
'wallet.payments.watch',
'wallet.list',
'wallet.balance.read',
'extension.api.request',
@@ -1,6 +1,11 @@
window.app.component('lnbits-wallet-api-docs', {
template: '#lnbits-wallet-api-docs',
methods: {
copyAdminKey() {
LNbits.utils
.confirmDialog(this.$t('admin_key_warning'))
.onOk(() => LNbits.utils.copyText(this.g.wallet.adminkey))
},
resetKeys() {
LNbits.utils
.confirmDialog('Are you sure you want to reset your API keys?')
+238 -1
View File
@@ -29,6 +29,23 @@ window.PageExtensions = {
permissions: [],
resolve: null
},
managedExtensionPermissions: {
loading: false,
extensionPermissions: [],
userPermissions: {},
savingKey: '',
deletingKey: ''
},
backgroundPaymentDestinationOptions: [
{
label: 'Only transfers to my wallets',
value: 'own_wallets_only'
},
{
label: 'Allow external payments',
value: 'external_allowed'
}
],
uninstallAndDropDb: false,
maxStars: 5,
paylinkWebsocket: null,
@@ -98,6 +115,32 @@ window.PageExtensions = {
this.filterExtensions(this.searchTerm, val)
}
},
computed: {
managedUserPermissionRows() {
const rows = []
const userPermissions =
this.managedExtensionPermissions.userPermissions || {}
Object.entries(userPermissions).forEach(([permissionId, grants]) => {
if (!Array.isArray(grants)) return
grants.forEach(grant => {
if (!grant || typeof grant !== 'object') return
const grantId = String(grant.id || '')
const walletId = String(grant.wallet_id || '')
if (!grantId || !walletId) return
rows.push({
key: grantId,
permissionId,
label: this.permissionLabelById(permissionId),
grantId,
walletId,
walletName: this.walletName(walletId),
grant
})
})
})
return rows
}
},
methods: {
filterExtensions(term, tab) {
// Filter the extensions list
@@ -357,8 +400,18 @@ window.PageExtensions = {
this.selectedExtension = extension
this.selectedRelease = null
this.selectedExtensionRepos = null
this.manageExtensionTab = 'releases'
this.resetManagedExtensionPermissions()
this.manageExtensionTab = this.g.user.admin
? 'releases'
: 'extension-permissions'
this.showManageExtensionDialog = true
if (this.canManageExtensionPermissions(extension)) {
this.loadManagedExtensionPermissions(extension)
}
if (!this.g.user.admin) {
return
}
try {
const {data} = await LNbits.api.request(
@@ -396,6 +449,61 @@ window.PageExtensions = {
extension.inProgress = false
}
},
canShowManageExtensionButton(extension) {
return (
this.g.user.admin ||
(extension?.isWasm === true && extension?.isInstalled === true)
)
},
canManageExtensionPermissions(extension = this.selectedExtension) {
return extension?.isWasm === true && extension?.isInstalled === true
},
canShowAdminManageTabs() {
return this.g.user.admin === true
},
resetManagedExtensionPermissions() {
this.managedExtensionPermissions = {
loading: false,
extensionPermissions: [],
userPermissions: {},
savingKey: '',
deletingKey: ''
}
},
async loadManagedExtensionPermissions(extension = this.selectedExtension) {
if (!this.canManageExtensionPermissions(extension)) return
this.managedExtensionPermissions.loading = true
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extension.id}/permissions`
)
this.managedExtensionPermissions.extensionPermissions =
data.extension_permissions || []
this.managedExtensionPermissions.userPermissions =
this.cloneUserPermissions(data.user_permissions || {})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.managedExtensionPermissions.loading = false
}
},
cloneUserPermissions(userPermissions) {
const permissions = {}
Object.entries(userPermissions || {}).forEach(
([permissionId, grants]) => {
if (!Array.isArray(grants)) return
permissions[permissionId] = grants
.filter(grant => grant && typeof grant === 'object')
.map(grant => ({
...grant,
_original: {...grant}
}))
}
)
return permissions
},
async showExtensionDetails(extId, detailsLink) {
if (!detailsLink) {
@@ -642,6 +750,135 @@ window.PageExtensions = {
extensionOpenUrl(extension) {
return extension.isWasm ? `/ext/${extension.id}` : `/${extension.id}`
},
permissionLabelById(permissionId) {
const key = `extension_permission_${String(permissionId).replace(
/[^A-Za-z0-9]/g,
'_'
)}`
const label = this.$t(key)
return label === key ? permissionId : label
},
walletName(walletId) {
const wallet = (this.g.user.wallets || []).find(
wallet => wallet.id === walletId
)
return wallet ? wallet.name || wallet.id : walletId
},
userPermissionRowCaption(row) {
return `${row.walletName} (${row.walletId.slice(0, 8)}...)`
},
isBackgroundPaymentPermission(row) {
return row.permissionId === 'wallet.pay_invoice_background'
},
userPermissionGrantPayload(row) {
return {
wallet_id: row.walletId,
max_amount: this.positiveInteger(row.grant.max_amount, 0),
destination_policy: this.backgroundPaymentDestinationPolicy(
row.grant.destination_policy
)
}
},
positiveInteger(value, fallback) {
const number = Number(value)
if (!Number.isFinite(number) || number <= 0) return fallback
return Math.floor(number)
},
backgroundPaymentDestinationPolicy(value) {
return value === 'external_allowed'
? 'external_allowed'
: 'own_wallets_only'
},
backgroundPaymentGrantIncreased(row, payload) {
const original = row.grant._original || {}
const originalAmount = this.positiveInteger(original.max_amount, 0)
const originalPolicy = this.backgroundPaymentDestinationPolicy(
original.destination_policy
)
return (
payload.max_amount > originalAmount ||
(originalPolicy === 'own_wallets_only' &&
payload.destination_policy === 'external_allowed')
)
},
confirmUserPermissionIncrease() {
return new Promise(resolve => {
let resolved = false
const finish = value => {
if (resolved) return
resolved = true
resolve(value)
}
LNbits.utils
.confirmDialog(
'This increases what the extension can do with this wallet. Continue?'
)
.onOk(() => finish(true))
.onCancel(() => finish(false))
.onDismiss(() => finish(false))
})
},
async saveUserPermissionGrant(row) {
if (!this.isBackgroundPaymentPermission(row)) return
const payload = this.userPermissionGrantPayload(row)
if (!payload.max_amount) {
Quasar.Notify.create({
type: 'negative',
message: 'Max payment amount must be greater than zero.'
})
return
}
if (
this.backgroundPaymentGrantIncreased(row, payload) &&
!(await this.confirmUserPermissionIncrease())
) {
return
}
this.managedExtensionPermissions.savingKey = row.key
try {
await LNbits.api.request(
'POST',
`/api/v1/extension/${this.selectedExtension.id}/permissions/background-payment`,
null,
payload
)
Quasar.Notify.create({
type: 'positive',
message: 'Permission updated.'
})
await this.loadManagedExtensionPermissions()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.managedExtensionPermissions.savingKey = ''
}
},
deleteUserPermissionGrant(row) {
LNbits.utils
.confirmDialog('Remove this permission grant?')
.onOk(async () => {
this.managedExtensionPermissions.deletingKey = row.key
try {
const grantId = encodeURIComponent(row.grantId)
await LNbits.api.request(
'DELETE',
`/api/v1/extension/${this.selectedExtension.id}/permissions/user/${grantId}`
)
Quasar.Notify.create({
type: 'positive',
message: 'Permission removed.'
})
await this.loadManagedExtensionPermissions()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.managedExtensionPermissions.deletingKey = ''
}
})
},
async getGitHubReleaseDetails(release) {
if (!release.is_github_release || release.loaded) {
return
+15 -2
View File
@@ -7,6 +7,7 @@ window.PageWallet = {
invoice: null,
lnurlpay: null,
lnurlauth: null,
sending: false,
data: {
request: '',
amount: 0,
@@ -131,6 +132,7 @@ window.PageWallet = {
this.parse.data.request = ''
this.parse.data.comment = ''
this.parse.data.internalMemo = null
this.parse.sending = false
this.parse.data.paymentChecker = null
this.parse.camera.show = false
},
@@ -362,6 +364,9 @@ window.PageWallet = {
this.parse.invoice = Object.freeze(cleanInvoice)
},
payInvoice() {
if (this.parse.sending) return
this.parse.sending = true
const dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
message: this.$t('payment_processing')
@@ -374,6 +379,7 @@ window.PageWallet = {
this.parse.data.internalMemo
)
.then(response => {
this.parse.sending = false
dismissPaymentMsg()
this.g.updatePayments = !this.g.updatePayments
this.parse.show = false
@@ -391,13 +397,16 @@ window.PageWallet = {
}
})
.catch(err => {
this.parse.sending = false
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
this.g.updatePayments = !this.g.updatePayments
this.parse.show = false
})
},
payLnurl() {
if (this.parse.sending) return
this.parse.sending = true
LNbits.api
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
res: this.parse.lnurlpay,
@@ -408,6 +417,7 @@ window.PageWallet = {
internalMemo: this.parse.data.internalMemo
})
.then(response => {
this.parse.sending = false
this.parse.show = false
if (response.data.extra.success_action) {
const action = JSON.parse(response.data.extra.success_action)
@@ -464,7 +474,10 @@ window.PageWallet = {
}
}
})
.catch(LNbits.utils.notifyApiError)
.catch(err => {
this.parse.sending = false
LNbits.utils.notifyApiError(err)
})
},
authLnurl() {
const dismissAuthMsg = Quasar.Notify.create({
@@ -48,6 +48,85 @@ window.WasmExtensionComponent = {
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="backgroundPaymentPrompt.show" persistent>
<q-card style="width: min(560px, calc(100vw - 32px)); max-width: 560px">
<q-card-section>
<div class="text-h6">Background payments</div>
</q-card-section>
<q-card-section class="q-pt-none q-gutter-md">
<div>
{{ backgroundPaymentPrompt.extensionName }} wants permission to make
background payments from
<strong>{{ backgroundPaymentPrompt.walletName }}</strong>.
</div>
<q-banner dense rounded class="bg-warning text-dark">
This permission can move funds later without an active click.
</q-banner>
<q-input
v-model.number="backgroundPaymentPrompt.form.maxAmount"
type="number"
label="Max payment amount (sats)"
min="1"
dense
outlined
></q-input>
<q-select
v-model="backgroundPaymentPrompt.form.destinationPolicy"
:options="backgroundPaymentDestinationOptions"
emit-value
map-options
label="Allowed destinations"
dense
outlined
></q-select>
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
color="negative"
label="Deny"
@click="resolveBackgroundPaymentPrompt(false)"
></q-btn>
<q-btn
unelevated
color="primary"
label="Allow"
@click="resolveBackgroundPaymentPrompt(true)"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="walletPaymentWatchPrompt.show" persistent>
<q-card style="width: min(520px, calc(100vw - 32px)); max-width: 520px">
<q-card-section>
<div class="text-h6">Watch wallet payments</div>
</q-card-section>
<q-card-section class="q-pt-none q-gutter-md">
<div>
{{ walletPaymentWatchPrompt.extensionName }} wants permission to receive
payment notifications for
<strong>{{ walletPaymentWatchPrompt.walletName }}</strong>.
</div>
<q-banner dense rounded class="bg-warning text-dark">
This permission exposes payment metadata for this wallet to the extension.
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
color="negative"
label="Deny"
@click="resolveWalletPaymentWatchPrompt(false)"
></q-btn>
<q-btn
unelevated
color="primary"
label="Allow"
@click="resolveWalletPaymentWatchPrompt(true)"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</div>
`,
data() {
@@ -68,6 +147,36 @@ window.WasmExtensionComponent = {
resolve: null,
show: false
},
backgroundPaymentDestinationOptions: [
{
label: 'Only transfers to my wallets',
value: 'own_wallets_only'
},
{
label: 'Allow external payments',
value: 'external_allowed'
}
],
backgroundPaymentPrompt: {
extensionName: '',
form: {
destinationPolicy: 'own_wallets_only',
maxAmount: 0
},
reject: null,
resolve: null,
show: false,
walletId: '',
walletName: ''
},
walletPaymentWatchPrompt: {
extensionName: '',
reject: null,
resolve: null,
show: false,
walletId: '',
walletName: ''
},
error: '',
extensionName: '',
frameUrl: '',
@@ -84,6 +193,12 @@ window.WasmExtensionComponent = {
unmounted() {
window.removeEventListener('message', this.handleWindowMessage)
this.rejectCameraPrompt('Camera scan cancelled.')
this.rejectBackgroundPaymentPrompt(
'Background payment permission cancelled.'
)
this.rejectWalletPaymentWatchPrompt(
'Wallet payment watch permission cancelled.'
)
this.closeBridgePort()
},
watch: {
@@ -119,6 +234,20 @@ window.WasmExtensionComponent = {
cameraPromptStorageKey() {
return `lnbits.ext.permissions.${this.bridge.extensionId}.ui.camera.scan_qr`
},
emptyBackgroundPaymentPrompt() {
return {
extensionName: '',
form: {
destinationPolicy: 'own_wallets_only',
maxAmount: 0
},
reject: null,
resolve: null,
show: false,
walletId: '',
walletName: ''
}
},
emptyCameraPrompt() {
return {
extensionName: '',
@@ -127,6 +256,16 @@ window.WasmExtensionComponent = {
show: false
}
},
emptyWalletPaymentWatchPrompt() {
return {
extensionName: '',
reject: null,
resolve: null,
show: false,
walletId: '',
walletName: ''
}
},
plainValue(value) {
try {
return JSON.parse(JSON.stringify(value))
@@ -143,6 +282,12 @@ window.WasmExtensionComponent = {
this.bridge = this.emptyBridge()
this.allowedPaymentHashes.clear()
this.rejectCameraPrompt('Camera scan cancelled.')
this.rejectBackgroundPaymentPrompt(
'Background payment permission cancelled.'
)
this.rejectWalletPaymentWatchPrompt(
'Wallet payment watch permission cancelled.'
)
this.closeBridgePort()
try {
@@ -310,6 +455,320 @@ window.WasmExtensionComponent = {
this.g.scanner = onScan
})
},
async requestBackgroundPaymentPermission(message) {
const response = await this.requestExtensionPermissions({
permissions: [
{
id: 'wallet.pay_invoice_background',
grant: message?.grant || {}
}
]
})
return response.permissions?.[0] || response
},
async requestWalletPaymentWatchPermission(message) {
const response = await this.requestExtensionPermissions({
permissions: [
{
id: 'wallet.payments.watch',
grant: message?.grant || {}
}
]
})
return response.permissions?.[0] || response
},
async requestExtensionPermissions(message) {
if (this.bridge.public) {
throw new Error('Public pages cannot request permissions.')
}
const permissions = Array.isArray(message.permissions)
? message.permissions
: []
if (!permissions.length) {
throw new Error('No permissions requested.')
}
const requestedPermissions = permissions.map(permission =>
this.normalizePermissionRequest(permission)
)
const checkResult = await this.checkExtensionPermissions(
requestedPermissions.map(permission => ({
id: permission.id,
grant: permission.grant
}))
)
const checks = Array.isArray(checkResult?.permissions)
? checkResult.permissions
: []
const approvedLabels = []
const results = []
for (const [index, permission] of requestedPermissions.entries()) {
const check = checks[index] || {}
if (check.id && check.id !== permission.id) {
throw new Error('Permission check response did not match request.')
}
if (check.approved) {
approvedLabels.push(permission.label)
results.push({
id: permission.id,
approved: true,
grant: check.grant || permission.grant
})
continue
}
const granted = await this.promptExtensionPermission(permission)
results.push({
id: permission.id,
approved: true,
grant: granted?.grant || permission.grant
})
}
this.notifyApprovedPermissionUse(approvedLabels)
return {permissions: results}
},
normalizePermissionRequest(permission) {
const id = String(permission?.id || '')
const grant = permission?.grant || {}
if (id === 'wallet.pay_invoice_background') {
return this.normalizeBackgroundPaymentPermission(grant)
}
if (id === 'wallet.payments.watch') {
return this.normalizeWalletPaymentWatchPermission(grant)
}
throw new Error(`Unsupported permission request: ${id}.`)
},
normalizeBackgroundPaymentPermission(grant) {
if (!this.hasBridgePermission('wallet.pay_invoice_background')) {
throw new Error('Extension is missing background payment permission.')
}
const walletId = String(grant.walletId || grant.wallet_id || '')
const wallet = this.walletById(walletId)
if (!wallet) {
throw new Error('Selected wallet is not available.')
}
if (wallet.walletType === 'lightning-shared') {
throw new Error(
'Background payments are not allowed from shared wallets.'
)
}
const requestedGrant = {
wallet_id: walletId,
max_amount: this.positiveInteger(
grant.maxAmount || grant.max_amount,
1000
),
destination_policy: this.backgroundPaymentDestinationPolicy(
grant.destinationPolicy || grant.destination_policy
)
}
if (!requestedGrant.max_amount) {
throw new Error('Max payment amount must be greater than zero.')
}
return {
id: 'wallet.pay_invoice_background',
grant: requestedGrant,
label: `Background payments from ${wallet.name || walletId}`,
wallet
}
},
normalizeWalletPaymentWatchPermission(grant) {
if (!this.hasBridgePermission('wallet.payments.watch')) {
throw new Error('Extension is missing wallet payment watch permission.')
}
const walletId = String(grant.walletId || grant.wallet_id || '')
const wallet = this.walletById(walletId)
if (!wallet) {
throw new Error('Selected wallet is not available.')
}
return {
id: 'wallet.payments.watch',
grant: {wallet_id: walletId},
label: `Watch wallet payments for ${wallet.name || walletId}`,
wallet
}
},
walletById(walletId) {
return (
(this.g?.user?.wallets || []).find(wallet => wallet.id === walletId) ||
null
)
},
async checkExtensionPermissions(permissions) {
return await this.postExtensionPermission(
'check',
{permissions},
'Could not check extension permissions.'
)
},
promptExtensionPermission(permission) {
if (permission.id === 'wallet.pay_invoice_background') {
return this.promptBackgroundPaymentPermission(permission)
}
if (permission.id === 'wallet.payments.watch') {
return this.promptWalletPaymentWatchPermission(permission)
}
throw new Error(`Unsupported permission request: ${permission.id}.`)
},
promptBackgroundPaymentPermission(permission) {
if (this.backgroundPaymentPrompt.show) {
throw new Error('Background payment prompt is already open.')
}
return new Promise((resolve, reject) => {
this.backgroundPaymentPrompt = {
extensionName:
this.extensionName || this.bridge.extensionId || 'This extension',
form: {
destinationPolicy: permission.grant.destination_policy,
maxAmount: permission.grant.max_amount
},
reject,
resolve,
show: true,
walletId: permission.grant.wallet_id,
walletName: permission.wallet.name || permission.grant.wallet_id
}
})
},
async resolveBackgroundPaymentPrompt(approved) {
const prompt = this.backgroundPaymentPrompt
if (!prompt.show) return
if (!approved) {
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
prompt.reject?.(new Error('Background payment permission denied.'))
return
}
try {
const grant = {
wallet_id: prompt.walletId,
max_amount: this.positiveInteger(prompt.form.maxAmount, 0),
destination_policy: this.backgroundPaymentDestinationPolicy(
prompt.form.destinationPolicy
)
}
if (!grant.max_amount) {
throw new Error('Max payment amount must be greater than zero.')
}
const data = await this.postExtensionPermission(
'background-payment',
grant,
'Could not save permission.'
)
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
prompt.resolve?.(data)
} catch (error) {
prompt.reject?.(error)
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
}
},
rejectBackgroundPaymentPrompt(message) {
const reject = this.backgroundPaymentPrompt.reject
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
reject?.(new Error(message))
},
promptWalletPaymentWatchPermission(permission) {
if (this.walletPaymentWatchPrompt.show) {
throw new Error('Wallet payment watch prompt is already open.')
}
return new Promise((resolve, reject) => {
this.walletPaymentWatchPrompt = {
extensionName:
this.extensionName || this.bridge.extensionId || 'This extension',
reject,
resolve,
show: true,
walletId: permission.grant.wallet_id,
walletName: permission.wallet.name || permission.grant.wallet_id
}
})
},
async resolveWalletPaymentWatchPrompt(approved) {
const prompt = this.walletPaymentWatchPrompt
if (!prompt.show) return
if (!approved) {
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
prompt.reject?.(new Error('Wallet payment watch permission denied.'))
return
}
try {
const data = await this.postExtensionPermission(
'wallet-payments-watch',
{wallet_id: prompt.walletId},
'Could not save permission.'
)
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
prompt.resolve?.(data)
} catch (error) {
prompt.reject?.(error)
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
}
},
rejectWalletPaymentWatchPrompt(message) {
const reject = this.walletPaymentWatchPrompt.reject
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
reject?.(new Error(message))
},
positiveInteger(value, fallback) {
const number = Number(value)
if (!Number.isFinite(number) || number <= 0) return fallback
return Math.floor(number)
},
backgroundPaymentDestinationPolicy(value) {
return value === 'external_allowed'
? 'external_allowed'
: 'own_wallets_only'
},
async postExtensionPermission(path, body, fallbackMessage) {
const response = await fetch(
`/api/v1/extension/${encodeURIComponent(
this.bridge.extensionId
)}/permissions/${path}`,
{
method: 'POST',
headers: {'content-type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify(body)
}
)
const text = await response.text()
let data = {}
if (text) {
try {
data = JSON.parse(text)
} catch (_error) {
data = {detail: text}
}
}
if (!response.ok) {
throw new Error(data?.detail || fallbackMessage)
}
return data
},
notifyApprovedPermissionUse(permissions) {
const permissionList = permissions.filter(Boolean).join(', ')
if (!permissionList) return
this.notify({
level: 'info',
message: `Using approved permissions: ${permissionList}.`
})
},
requireCameraScanApproval() {
if (this.isCameraScanRemembered()) return Promise.resolve()
if (this.cameraPrompt.show) {
@@ -515,6 +974,30 @@ window.WasmExtensionComponent = {
return
}
if (message.action === 'permissions.request') {
this.sendResponse(reply, message.id, {
ok: true,
data: await this.requestExtensionPermissions(message)
})
return
}
if (message.action === 'permissions.request_background_payment') {
this.sendResponse(reply, message.id, {
ok: true,
data: await this.requestBackgroundPaymentPermission(message)
})
return
}
if (message.action === 'permissions.request_wallet_payment_watch') {
this.sendResponse(reply, message.id, {
ok: true,
data: await this.requestWalletPaymentWatchPermission(message)
})
return
}
if (message.action === 'payment.subscribe') {
this.subscribePayment(message)
this.sendResponse(reply, message.id, {
@@ -6,9 +6,12 @@
:content-inset-level="0.5"
>
<q-card-section>
<q-banner dense rounded class="bg-warning text-black q-mb-md">
These keys should be kept safe, sharing them could risk losing funds.
</q-banner>
<q-banner
dense
rounded
class="bg-warning text-black q-mb-md"
v-text="$t('api_keys_warning')"
></q-banner>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
@@ -69,7 +72,7 @@
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="utils.copyText(g.wallet.adminkey)"
@click="copyAdminKey()"
></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
+167 -6
View File
@@ -356,7 +356,7 @@
@click="showManageExtension(extension)"
flat
color="primary"
v-if="g.user.admin"
v-if="canShowManageExtensionButton(extension)"
:label="$t('manage')"
><q-tooltip
><span v-text="$t('manage_extension_details')"></span
@@ -531,20 +531,43 @@
</div>
</div>
</q-card>
<q-card v-else class="q-pa-lg lnbits__dialog-card">
<q-card
v-else
class="q-pa-lg lnbits__dialog-card"
style="width: min(95vw, 920px); max-width: 95vw"
>
<q-tabs
v-model="manageExtensionTab"
active-color="primary"
align="justify"
>
<q-tab
v-if="canShowAdminManageTabs()"
name="releases"
:label="$t('releases')"
@update="val => (manageExtensionTab = val.name)"
></q-tab>
<q-tab
v-if="selectedExtension && selectedExtension.isInstalled"
v-if="canManageExtensionPermissions()"
name="extension-permissions"
:label="$t('extension_permissions_tab')"
@update="val => (manageExtensionTab = val.name)"
></q-tab>
<q-tab
v-if="canManageExtensionPermissions()"
name="user-permissions"
:label="$t('user_permissions_tab')"
@update="val => (manageExtensionTab = val.name)"
></q-tab>
<q-tab
v-if="
canShowAdminManageTabs() &&
selectedExtension &&
selectedExtension.isInstalled
"
name="sell"
:label="$t('sell')"
@update="val => (manageExtensionTab = val.name)"
@@ -554,7 +577,7 @@
<div
v-show="manageExtensionTab === 'releases'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
v-if="selectedExtensionRepos"
v-if="canShowAdminManageTabs() && selectedExtensionRepos"
>
<q-card
flat
@@ -815,9 +838,147 @@
></q-btn>
</div>
</div>
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
<q-spinner-bars
v-else-if="
canShowAdminManageTabs() && manageExtensionTab === 'releases'
"
color="primary"
size="2.55em"
></q-spinner-bars>
<div
v-if="selectedExtension"
v-if="canManageExtensionPermissions()"
v-show="manageExtensionTab === 'extension-permissions'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
<q-spinner-bars
v-if="managedExtensionPermissions.loading"
color="primary"
size="2.55em"
></q-spinner-bars>
<lnbits-extension-permissions
v-else-if="managedExtensionPermissions.extensionPermissions.length"
:permissions="managedExtensionPermissions.extensionPermissions"
:extensions="extensions"
></lnbits-extension-permissions>
<q-banner v-else rounded class="bg-grey-2 text-grey-8">
<span v-text="$t('extension_permissions_none')"></span>
</q-banner>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</div>
<div
v-if="canManageExtensionPermissions()"
v-show="manageExtensionTab === 'user-permissions'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
<q-spinner-bars
v-if="managedExtensionPermissions.loading"
color="primary"
size="2.55em"
></q-spinner-bars>
<q-banner
v-else-if="!managedUserPermissionRows.length"
rounded
class="bg-grey-2 text-grey-8"
>
<span v-text="$t('user_permissions_none')"></span>
</q-banner>
<q-list v-else bordered separator>
<q-expansion-item
v-for="row in managedUserPermissionRows"
:key="row.key"
>
<template v-slot:header>
<q-item-section>
<q-item-label class="text-weight-medium">
<span v-text="row.label"></span>
</q-item-label>
<q-item-label caption>
<span v-text="userPermissionRowCaption(row)"></span>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
flat
dense
round
color="negative"
icon="delete"
:loading="managedExtensionPermissions.deletingKey === row.key"
@click.stop="deleteUserPermissionGrant(row)"
>
<q-tooltip>
<span v-text="$t('delete')"></span>
</q-tooltip>
</q-btn>
</q-item-section>
</template>
<div class="q-pa-md">
<div
v-if="isBackgroundPaymentPermission(row)"
class="row q-col-gutter-sm"
>
<div class="col-12 col-sm-5">
<q-input
dense
outlined
type="number"
min="1"
v-model.number="row.grant.max_amount"
:label="$t('user_permissions_max_amount')"
suffix="sat"
></q-input>
</div>
<div class="col-12 col-sm-7">
<q-select
dense
outlined
emit-value
map-options
v-model="row.grant.destination_policy"
:options="backgroundPaymentDestinationOptions"
:label="$t('user_permissions_destination_policy')"
></q-select>
</div>
<div class="col-12">
<q-btn
unelevated
color="primary"
icon="save"
:loading="managedExtensionPermissions.savingKey === row.key"
@click="saveUserPermissionGrant(row)"
>
<span v-text="$t('save')"></span>
</q-btn>
</div>
</div>
<q-item-label v-else caption>
<span
v-text="$t('user_permissions_no_editable_settings')"
></span>
</q-item-label>
</div>
</q-expansion-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</div>
<div
v-if="canShowAdminManageTabs() && selectedExtension"
v-show="manageExtensionTab === 'sell'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
+9 -2
View File
@@ -664,7 +664,8 @@
unelevated
color="primary"
@click="payInvoice"
:label="$t('pay')"
:disable="parse.sending"
:label="parse.sending ? $t('sending') + '...' : $t('pay')"
></q-btn>
<q-btn
v-close-popup
@@ -836,7 +837,13 @@
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Send</q-btn>
<q-btn
unelevated
color="primary"
type="submit"
:disable="parse.sending"
:label="parse.sending ? $t('sending') + '...' : $t('send')"
></q-btn>
<q-btn
:label="$t('cancel')"
v-close-popup
-5
View File
@@ -1,5 +1,3 @@
from collections.abc import AsyncGenerator
from loguru import logger
from .base import (
@@ -40,6 +38,3 @@ class VoidWallet(Wallet):
async def get_payment_status(self, *_, **__) -> PaymentStatus:
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
yield ""
+2 -1
View File
@@ -83,7 +83,8 @@ async def test_admin_audit_monitor_and_test_email(
)
assert monitor.status_code == 200
task_names = [t["name"] for t in monitor.json()]
assert any("invoice_listener" in name for name in task_names)
assert "core_invoice_listener" in task_names
assert "core_wasm_invoice_listener" in task_names
test_email = await client.get(
"/admin/api/v1/testemail",
+187
View File
@@ -1,3 +1,6 @@
import json
import zipfile
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
@@ -8,6 +11,7 @@ from starlette.requests import Request
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
from lnbits.core.crud.extensions import (
create_installed_extension,
delete_installed_extension,
get_installed_extension,
get_user_extension,
)
@@ -19,11 +23,14 @@ from lnbits.core.models.extensions import (
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionPermission,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
WasmRuntimeLimitsUpdate,
wasm_extension_icon_url,
)
from lnbits.core.models.users import AccountId
from lnbits.core.services.payments import create_wallet_invoice
@@ -35,9 +42,11 @@ from lnbits.core.views.extension_api import (
api_enable_extension,
api_extension_details,
api_get_user_extensions,
api_get_wasm_runtime_limit_extensions,
api_install_extension,
api_uninstall_extension,
api_update_pay_to_enable,
api_update_wasm_runtime_limits,
create_extension_review,
delete_extension_db,
extensions,
@@ -162,6 +171,147 @@ async def test_extension_api_install_details_and_release_endpoints(mocker):
assert release_info["is_version_compatible"] is True
@pytest.mark.anyio
async def test_extension_api_installs_wasm_with_granted_permissions(
tmp_path,
settings,
mocker,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
granted_permissions = [
ExtensionPermission(
id="http.request",
policies=[{"host": "https://api.example.com"}],
)
]
create_data = CreateExtension(
ext_id=ext_id,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
permissions=granted_permissions,
)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
register_wasm_routes_mock = mocker.patch(
"lnbits.core.services.extensions.core_app_extra.register_new_wasm_ext_routes"
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch.object(
InstallableExtension,
"download_archive",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
_write_wasm_extension_archive(ext_id, release.version, settings)
installed = await api_install_extension(create_data)
stored = await get_installed_extension(ext_id)
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert installed.code == ext_id
assert installed.is_wasm is True
assert stored is not None
assert stored.permissions == [
ExtensionPermission(
id="http.request",
description="Call example API.",
policies=[{"host": "https://api.example.com"}],
)
]
register_wasm_routes_mock.assert_called_once_with(ext_id)
@pytest.mark.anyio
async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metadata(
tmp_path,
settings,
mocker,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
py_ext_id = f"py_{uuid4().hex[:8]}"
granted_permissions = [
ExtensionPermission(
id="http.request",
description="Call example API.",
policies=[{"host": "https://api.example.com"}],
)
]
original_extensions_path = settings.lnbits_extensions_path
try:
settings.lnbits_extensions_path = str(tmp_path)
_write_installed_wasm_config(ext_id, tmp_path)
await create_installed_extension(
InstallableExtension(
id=ext_id,
name="WASM Demo",
version="1.0.0",
active=True,
permissions=granted_permissions,
wasm_runtime_limits={"wasm_runtime_max_execution_ms": 1234},
)
)
await create_installed_extension(make_installable_extension(py_ext_id))
runtime_extensions = await api_get_wasm_runtime_limit_extensions()
wasm_info = next(info for info in runtime_extensions if info.id == ext_id)
updated_info = await api_update_wasm_runtime_limits(
ext_id,
WasmRuntimeLimitsUpdate(
limits={
"wasm_runtime_max_execution_ms": "2345",
"wasm_runtime_max_fuel": 0,
}
),
)
stored = await get_installed_extension(ext_id)
mocker.patch.object(
InstallableExtension,
"get_installable_extensions",
mocker.AsyncMock(
return_value=[
make_installable_extension(ext_id),
make_installable_extension(py_ext_id),
]
),
)
catalog = await extensions(AccountId(id=uuid4().hex))
finally:
await delete_installed_extension(ext_id=ext_id)
await delete_installed_extension(ext_id=py_ext_id)
settings.lnbits_extensions_path = original_extensions_path
assert wasm_info.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
assert py_ext_id not in {info.id for info in runtime_extensions}
assert updated_info.wasm_runtime_limits == {
"wasm_runtime_max_execution_ms": 2345,
"wasm_runtime_max_fuel": 0,
}
assert stored is not None
assert stored.wasm_runtime_limits == updated_info.wasm_runtime_limits
catalog_item = next(item for item in catalog if item["id"] == ext_id)
assert catalog_item["isWasm"] is True
assert catalog_item["icon"] == wasm_extension_icon_url(ext_id)
assert catalog_item["permissions"] == [
dict(permission) for permission in granted_permissions
]
@pytest.mark.anyio
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
regular_user = await create_user_account(
@@ -428,3 +578,40 @@ async def test_extension_api_review_endpoints(mocker):
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
)
assert payment_request.payment_hash.startswith("hash_")
def _write_wasm_extension_archive(ext_id: str, version: str, settings) -> None:
zip_path = Path(settings.lnbits_data_folder, "zips", f"{ext_id}.zip")
zip_path.parent.mkdir(parents=True, exist_ok=True)
config = _wasm_config(ext_id)
root = f"{ext_id}-{version}"
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr(f"{root}/config.json", json.dumps(config))
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
def _write_installed_wasm_config(ext_id: str, extensions_path) -> None:
config_dir = extensions_path / "extensions" / ext_id
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(
json.dumps(_wasm_config(ext_id)),
encoding="utf-8",
)
def _wasm_config(ext_id: str) -> dict:
return {
"id": ext_id,
"name": "WASM Demo",
"short_description": "WASM extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
"permissions": [
{
"id": "http.request",
"description": "Call example API.",
"policies": [{"host": "https://api.example.com"}],
}
],
}
+10
View File
@@ -12,6 +12,7 @@ from lnbits.core.crud.users import delete_account
from lnbits.core.models import User
from lnbits.core.models.users import AccessTokenPayload
from lnbits.decorators import (
_extension_id_from_request_path,
access_token_payload,
check_access_token,
check_admin_ui,
@@ -225,3 +226,12 @@ async def test_check_extension_builder_requires_admin_when_disabled_for_users(
admin_user = user_alan.copy(deep=True)
admin_user.admin = True
await check_extension_builder(admin_user)
def test_extension_id_from_request_path_handles_wasm_routes():
assert _extension_id_from_request_path("/ext/wasm_demo") == "wasm_demo"
assert _extension_id_from_request_path("/ext/wasm_demo/page/1") == "wasm_demo"
assert (
_extension_id_from_request_path("/api/v1/ext/wasm_demo/invoices") == "wasm_demo"
)
assert _extension_id_from_request_path("/lnurlp/api/v1") == "lnurlp"
+17
View File
@@ -2,6 +2,7 @@ import json
import pytest
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.db import (
dict_to_model,
dict_to_submodel,
@@ -103,3 +104,19 @@ async def test_helpers_dict_to_model_ignores_unknown_fields():
model = dict_to_model({**test_dict, "ignored": "field"}, DbTestModel3)
assert model == test_data
@pytest.mark.anyio
async def test_helpers_dict_to_model_handles_list_any_fields():
model = dict_to_model(
{
"id": "http.request",
"policies": '[{"host": "https://api.example.com"}]',
},
ExtensionPermission,
)
assert model == ExtensionPermission(
id="http.request",
policies=[{"host": "https://api.example.com"}],
)
+233
View File
@@ -1,3 +1,6 @@
import json
import zipfile
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from uuid import uuid4
@@ -11,6 +14,7 @@ from lnbits.core.crud import (
)
from lnbits.core.models.extensions import (
Extension,
ExtensionPermission,
InstallableExtension,
ReleasePaymentInfo,
)
@@ -150,6 +154,59 @@ async def test_install_extension_updates_existing_upgrade_and_preserves_payments
start_mock.assert_awaited_once_with(ext_id)
@pytest.mark.anyio
async def test_install_wasm_extension_requires_permissions_and_skips_background_work(
tmp_path,
settings: Settings,
mocker: MockerFixture,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
ext_info = make_installable_extension(ext_id)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
start_mock = mocker.patch(
"lnbits.core.services.extensions.start_extension_background_work",
mocker.AsyncMock(return_value=True),
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
_write_wasm_extension_archive(ext_info, _wasm_install_config(ext_id))
with pytest.raises(ValueError, match="requires permission approval"):
await install_extension(ext_info, skip_download=True)
granted_permissions = [
ExtensionPermission(
id="http.request",
policies=[{"host": "https://api.example.com"}],
)
]
extension = await install_extension(
ext_info,
skip_download=True,
granted_permissions=granted_permissions,
)
stored = await get_installed_extension(ext_id)
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert extension.code == ext_id
assert extension.is_wasm is True
assert stored is not None
assert stored.permissions == [
ExtensionPermission(
id="http.request",
description="Call example API.",
policies=[{"host": "https://api.example.com"}],
)
]
start_mock.assert_not_awaited()
@pytest.mark.anyio
async def test_uninstall_activate_and_deactivate_extensions(
tmp_path, settings: Settings, mocker: MockerFixture
@@ -204,6 +261,35 @@ async def test_uninstall_activate_and_deactivate_extensions(
assert start_mock.await_count == 1
@pytest.mark.anyio
async def test_wasm_invocation_monitoring_marks_stale_once_and_cleans_periodically(
settings: Settings,
mocker: MockerFixture,
):
_reset_wasm_invocation_state()
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
mark_stale_mock = mocker.patch(
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
mocker.AsyncMock(),
)
cleanup_mock = mocker.patch(
"lnbits.core.services.extensions.delete_old_wasm_invocations",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions._now",
side_effect=[now, now + timedelta(minutes=1), now + timedelta(hours=2)],
)
await extension_services.ensure_wasm_invocation_monitoring_ready()
await extension_services.ensure_wasm_invocation_monitoring_ready()
await extension_services.ensure_wasm_invocation_monitoring_ready()
mark_stale_mock.assert_awaited_once()
assert cleanup_mock.await_count == 2
cleanup_mock.assert_awaited_with(settings.lnbits_wasm_invocation_retention_days)
@pytest.mark.anyio
async def test_stop_extension_background_work_handles_missing_and_async_stops(
mocker: MockerFixture,
@@ -287,6 +373,72 @@ async def test_wasm_invocation_tracking_counts_and_stops(mocker: MockerFixture):
assert saved.storage_call_count == 1
@pytest.mark.anyio
async def test_wasm_invocation_context_and_error_message_are_sanitized(
mocker: MockerFixture,
):
_reset_wasm_invocation_state()
create_mock = mocker.patch(
"lnbits.core.services.extensions.create_wasm_invocation",
mocker.AsyncMock(),
)
update_mock = mocker.patch(
"lnbits.core.services.extensions.update_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.get_wasm_invocation",
mocker.AsyncMock(return_value=None),
)
mocker.patch(
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.delete_old_wasm_invocations",
mocker.AsyncMock(),
)
long_key = "k" * 80
secret_hex = "a" * 64
invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
context={
long_key: "v" * 300,
"attempt": 2,
"enabled": True,
"nested": {"raw": "value"},
7: "ignored",
},
)
await finish_wasm_invocation(
invocation.id,
status="failed",
error_type="RuntimeError",
error_message=f"api_key=supersecret Bearer abc.def {secret_hex} " + ("x" * 600),
)
create_mock.assert_awaited_once()
assert create_mock.await_args is not None
created_invocation = create_mock.await_args.args[0]
assert created_invocation.context == {
"k" * 64: "v" * 256,
"attempt": 2,
"enabled": True,
}
update_mock.assert_awaited_once()
assert update_mock.await_args is not None
saved_invocation = update_mock.await_args.args[0]
assert saved_invocation.error_message is not None
assert "supersecret" not in saved_invocation.error_message
assert "abc.def" not in saved_invocation.error_message
assert secret_hex not in saved_invocation.error_message
assert "api_key=[redacted]" in saved_invocation.error_message
assert "Bearer [redacted]" in saved_invocation.error_message
assert "[redacted-hex]" in saved_invocation.error_message
def _reset_wasm_invocation_state():
with extension_services._wasm_invocation_lock:
extension_services._wasm_invocation_handles.clear()
@@ -294,6 +446,35 @@ def _reset_wasm_invocation_state():
extension_services._wasm_invocations_last_cleanup_at = None
def _write_wasm_extension_archive(
ext_info: InstallableExtension,
config: dict,
) -> None:
ext_info.zip_path.parent.mkdir(parents=True, exist_ok=True)
root = f"{ext_info.id}-{ext_info.version}"
with zipfile.ZipFile(ext_info.zip_path, "w") as archive:
archive.writestr(f"{root}/config.json", json.dumps(config))
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
def _wasm_install_config(ext_id: str) -> dict:
return {
"id": ext_id,
"name": f"WASM {ext_id}",
"short_description": "WASM extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
"permissions": [
{
"id": "http.request",
"description": "Call example API.",
"policies": [{"host": "https://api.example.com"}],
}
],
}
def test_wasm_runtime_limits_merge_sparse_extension_overrides(settings: Settings):
original_execution_ms = settings.wasm_runtime_max_execution_ms
original_memory_bytes = settings.wasm_runtime_max_memory_bytes
@@ -489,6 +670,58 @@ async def test_wasm_invocation_host_call_limits(mocker: MockerFixture):
await finish_wasm_invocation(invocation.id, status="failed")
@pytest.mark.anyio
async def test_wasm_invocation_host_call_category_limits_can_be_disabled(
mocker: MockerFixture,
):
_reset_wasm_invocation_state()
mocker.patch(
"lnbits.core.services.extensions.create_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.update_wasm_invocation",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.get_wasm_invocation",
mocker.AsyncMock(return_value=None),
)
mocker.patch(
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.services.extensions.delete_old_wasm_invocations",
mocker.AsyncMock(),
)
limits = extension_services.wasm_runtime_limit_defaults()
limits["wasm_runtime_max_host_calls"] = 10
limits["wasm_runtime_max_http_calls"] = 1
invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
runtime_limits=limits,
)
record_wasm_invocation_host_call(invocation.id, "http.request")
with pytest.raises(ValueError, match="http host call limit"):
record_wasm_invocation_host_call(invocation.id, "extension.api.request")
await finish_wasm_invocation(invocation.id, status="failed")
limits["wasm_runtime_max_host_calls"] = 0
limits["wasm_runtime_max_http_calls"] = 0
unlimited_invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
runtime_limits=limits,
)
for _ in range(5):
record_wasm_invocation_host_call(unlimited_invocation.id, "http.request")
await finish_wasm_invocation(unlimited_invocation.id, status="completed")
@pytest.mark.anyio
async def test_start_extension_background_work_handles_missing_and_sync_starts(
mocker: MockerFixture,
+18
View File
@@ -27,6 +27,7 @@ from lnbits.core.services.payments import (
check_pending_payments,
check_time_limit_between_transactions,
check_transaction_status,
check_wallet_daily_withdraw_limit,
check_wallet_limits,
create_payment_request,
get_payments_daily_stats,
@@ -248,6 +249,23 @@ async def test_check_wallet_limits_and_time_limit(
settings.lnbits_wallet_limit_secs_between_trans = original_limit
@pytest.mark.anyio
async def test_check_wallet_daily_limit_counts_all_daily_payments(settings: Settings):
wallet = await _create_wallet()
await _create_payment(wallet, amount_msat=-2_000, status=PaymentState.SUCCESS)
await _create_payment(wallet, amount_msat=-3_000, status=PaymentState.SUCCESS)
original_limit = settings.lnbits_wallet_limit_daily_max_withdraw
try:
settings.lnbits_wallet_limit_daily_max_withdraw = 5
with pytest.raises(
ValueError, match="Daily withdrawal limit of 5 sats reached."
):
await check_wallet_daily_withdraw_limit(wallet.id, 1_000)
finally:
settings.lnbits_wallet_limit_daily_max_withdraw = original_limit
@pytest.mark.anyio
async def test_calculate_fiat_amounts_handles_conversion_and_errors(
mocker: MockerFixture,
@@ -0,0 +1,301 @@
from types import SimpleNamespace
from typing import cast
import httpx
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.wasm_ext.api.models import ExtensionApiRequest
from lnbits.core.wasm_ext.client import extensions as extension_client
from lnbits.settings import Settings
def test_wasm_extension_api_path_validation():
assert (
extension_client._extension_api_path("/api/v1/payments?limit=1")
== "/api/v1/payments?limit=1"
)
for path, message in [
("https://example.com/api/v1/payments", "relative"),
("/wallet", "start with '/api/'"),
("/api/v1/payments#frag", "fragment"),
("/api/v1/../admin", "traverse"),
("/api/v1/%2e%2e/admin", "traverse"),
("/api//v1/payments", "invalid"),
]:
with pytest.raises(PermissionError, match=message):
extension_client._extension_api_path(path)
def test_wasm_extension_api_target_and_access_validation():
assert extension_client._target_extension_id(" target_ext ") == "target_ext"
for extension_id in ["", "../admin", "bad.ext"]:
with pytest.raises(PermissionError, match="invalid target"):
extension_client._target_extension_id(extension_id)
assert extension_client._target_extension_access(["target"], "target") == {"read"}
assert extension_client._target_extension_access(
[{"id": "target", "access": ["read", "write", "ignored"]}],
"target",
) == {"read", "write"}
read_request = ExtensionApiRequest(
extension_id="target",
method="GET",
path="/api/v1/demo",
body=None,
)
write_request = ExtensionApiRequest(
extension_id="target",
method="POST",
path="/api/v1/demo",
body="{}",
)
extension_client._require_method_access(
"caller",
"target",
{"read"},
read_request,
)
with pytest.raises(PermissionError, match="cannot write"):
extension_client._require_method_access(
"caller",
"target",
{"read"},
write_request,
)
@pytest.mark.anyio
async def test_wasm_extension_api_request_enforces_auth_policy_and_user_enablement(
settings: Settings,
mocker: MockerFixture,
):
settings.host = "127.0.0.1"
settings.port = 5000
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
mocker.AsyncMock(return_value=["target"]),
)
client = _FakeAsyncClient(
_FakeStreamResponse(
status_code=202,
headers={"set-cookie": "secret", "x-result": "ok"},
chunks=[b'{"accepted":true}'],
)
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.httpx.AsyncClient",
client.factory,
)
response = await extension_client.send_extension_api_request(
"caller",
[{"id": "target", "access": ["write"]}],
"user-id",
"access-token",
ExtensionApiRequest(
extension_id="target",
method="POST",
path="/api/v1/run?value=1",
body="{}",
),
timeout_ms=750,
max_response_bytes=100,
)
assert response.status_code == 202
assert response.body == '{"accepted":true}'
assert response.headers == {"x-result": "ok"}
assert client.kwargs["follow_redirects"] is False
assert client.kwargs["trust_env"] is False
assert client.kwargs["timeout"] == 0.75
assert (
client.stream_kwargs["url"] == "http://127.0.0.1:5000/target/api/v1/run?value=1"
)
assert client.stream_kwargs["headers"] == {"Authorization": "Bearer access-token"}
@pytest.mark.anyio
async def test_wasm_extension_api_request_rejects_missing_auth_and_disabled_targets(
mocker: MockerFixture,
):
request = ExtensionApiRequest(
extension_id="target",
method="GET",
path="/api/v1/run",
body=None,
)
with pytest.raises(PermissionError, match="authentication"):
await extension_client.send_extension_api_request(
"caller",
["target"],
None,
"access-token",
request,
)
with pytest.raises(PermissionError, match="access token"):
await extension_client.send_extension_api_request(
"caller",
["target"],
"user-id",
None,
request,
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=False)),
)
with pytest.raises(PermissionError, match="not installed or enabled"):
await extension_client.send_extension_api_request(
"caller",
["target"],
"user-id",
"access-token",
request,
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
mocker.AsyncMock(return_value=[]),
)
with pytest.raises(PermissionError, match="not active for this user"):
await extension_client.send_extension_api_request(
"caller",
["target"],
"user-id",
"access-token",
request,
)
@pytest.mark.anyio
async def test_wasm_extension_api_request_rejects_oversized_body_and_response(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
mocker.AsyncMock(return_value=["target"]),
)
with pytest.raises(ValueError, match="body is too large"):
await extension_client.send_extension_api_request(
"caller",
[{"id": "target", "access": ["write"]}],
"user-id",
"access-token",
ExtensionApiRequest.construct(
extension_id="target",
method="POST",
path="/api/v1/run",
body="x" * 65_537,
),
)
with pytest.raises(ValueError, match="response is too large"):
await extension_client._read_limited_response(
cast(httpx.Response, _FakeStreamResponse(chunks=[b"12345", b"67890"])),
max_response_bytes=8,
)
@pytest.mark.anyio
async def test_wasm_extension_api_request_hides_transport_errors(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
)
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
mocker.AsyncMock(return_value=["target"]),
)
client = _FakeAsyncClient(_FakeStreamError())
mocker.patch(
"lnbits.core.wasm_ext.client.extensions.httpx.AsyncClient",
client.factory,
)
with pytest.raises(ValueError, match="Extension API request failed"):
await extension_client.send_extension_api_request(
"caller",
["target"],
"user-id",
"access-token",
ExtensionApiRequest(
extension_id="target",
method="GET",
path="/api/v1/run",
body=None,
),
)
class _FakeAsyncClient:
def __init__(self, response):
self.response = response
self.kwargs = {}
self.stream_kwargs = {}
def factory(self, **kwargs):
self.kwargs = kwargs
return self
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
def stream(self, method, url, **kwargs):
self.stream_kwargs = {"method": method, "url": url, **kwargs}
return self.response
class _FakeStreamResponse:
def __init__(
self,
*,
status_code: int = 200,
headers: dict[str, str] | None = None,
chunks: list[bytes] | None = None,
):
self.status_code = status_code
self.headers = headers or {}
self.encoding = "utf-8"
self._chunks = chunks or []
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def aiter_bytes(self):
for chunk in self._chunks:
yield chunk
class _FakeStreamError:
async def __aenter__(self):
raise httpx.RequestError(
"network failed",
request=httpx.Request("GET", "http://127.0.0.1"),
)
async def __aexit__(self, *_args):
return False
+295
View File
@@ -0,0 +1,295 @@
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.db import core_app_extra
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
from lnbits.core.wasm_ext.wasm.events import (
_payment_extension_id,
_payment_source_id,
_wasm_public_invoice_source_tables_from_permissions,
dispatch_wasm_invoice_paid,
)
from lnbits.core.wasm_ext.wasm.loader import WasmExtension
from lnbits.helpers import sha256s
def test_wasm_invoice_paid_helpers_extract_extension_and_source_tables():
payment = SimpleNamespace(
extension="",
extra={"tag": "demoext", "source_id": "row-1"},
tag="fallback",
)
permissions = [
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[
{"table": "tip_jars", "wallet_field": "wallet_id"},
{"table": "", "wallet_field": "wallet_id"},
],
),
ExtensionPermission(id="http.request"),
]
assert _payment_extension_id(payment) == "demoext"
assert _payment_source_id(payment) == "row-1"
assert _wasm_public_invoice_source_tables_from_permissions(permissions) == [
"tip_jars"
]
@pytest.mark.anyio
async def test_dispatch_wasm_invoice_paid_invokes_registered_event_export_with_owner(
mocker: MockerFixture,
):
ext_id = "demo_event_ext"
extension = _wasm_extension(ext_id)
registry = core_app_extra.wasm_extension_registry
registry.register(extension)
installed_extension = SimpleNamespace(
permissions=[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "tip_jars", "wallet_field": "wallet_id"}],
)
]
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
mocker.AsyncMock(return_value=installed_extension),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_wallet",
mocker.AsyncMock(return_value=None),
)
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
invoke_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
mocker.AsyncMock(),
)
payment = _payment(ext_id)
try:
await dispatch_wasm_invoice_paid(payment)
finally:
registry._extensions.pop(ext_id, None)
storage_mock.assert_awaited_once_with(ext_id, "tip_jars", "row-1")
invoke_mock.assert_awaited_once()
assert invoke_mock.await_args is not None
args = invoke_mock.await_args.args
kwargs = invoke_mock.await_args.kwargs
assert args[0] == ext_id
assert args[1] == "on_invoice_paid"
assert args[2]["paymentHash"] == "payment-hash"
assert args[2]["payment"] == {"id": "payment-row"}
assert kwargs["context"] == "event"
assert kwargs["owner_id"] == "owner-1"
assert kwargs["trigger_type"] == "event"
assert kwargs["event_type"] == "invoice_paid"
assert kwargs["wallet_id"] == "wallet-1"
assert kwargs["payment_hash"] == "payment-hash"
assert kwargs["checking_id"] == "checking-id"
@pytest.mark.anyio
async def test_dispatch_wasm_invoice_paid_skips_invalid_event_export_visibility(
mocker: MockerFixture,
):
ext_id = "demo_public_event_ext"
extension = _wasm_extension(ext_id, visibility="public")
registry = core_app_extra.wasm_extension_registry
registry.register(extension)
invoke_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
mocker.AsyncMock(),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_wallet",
mocker.AsyncMock(return_value=None),
)
try:
await dispatch_wasm_invoice_paid(_payment(ext_id))
finally:
registry._extensions.pop(ext_id, None)
invoke_mock.assert_not_awaited()
@pytest.mark.anyio
async def test_dispatch_wasm_invoice_paid_invokes_wallet_watch_grant(
mocker: MockerFixture,
):
ext_id = "demo_wallet_watch_ext"
extension = _wasm_extension(ext_id)
registry = core_app_extra.wasm_extension_registry
registry.register(extension)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_wallet",
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="user-1")),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_user_extensions",
mocker.AsyncMock(
return_value=[
SimpleNamespace(
active=True,
extension=ext_id,
permissions={
"wallet.payments.watch": [
{
"id": "grant-1",
"wallet_id": "wallet-1",
"enabled": True,
}
]
},
)
]
),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
mocker.AsyncMock(
return_value=SimpleNamespace(
active=True,
permissions=[ExtensionPermission(id="wallet.payments.watch")],
)
),
)
invoke_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
mocker.AsyncMock(),
)
try:
await dispatch_wasm_invoice_paid(_payment(""))
finally:
registry._extensions.pop(ext_id, None)
invoke_mock.assert_awaited_once()
assert invoke_mock.await_args is not None
args = invoke_mock.await_args.args
kwargs = invoke_mock.await_args.kwargs
assert args[0] == ext_id
assert args[1] == "on_invoice_paid"
assert args[2]["paymentHash"] == "payment-hash"
assert kwargs["context"] == "event"
assert kwargs["owner_id"] == sha256s("user-1")
@pytest.mark.anyio
async def test_dispatch_wasm_invoice_paid_dedupes_tagged_wallet_watch_grant(
mocker: MockerFixture,
):
ext_id = "demo_dedupe_event_ext"
extension = _wasm_extension(ext_id)
registry = core_app_extra.wasm_extension_registry
registry.register(extension)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_wallet",
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="user-1")),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_user_extensions",
mocker.AsyncMock(
return_value=[
SimpleNamespace(
active=True,
extension=ext_id,
permissions={
"wallet.payments.watch": [
{
"id": "grant-1",
"wallet_id": "wallet-1",
"enabled": True,
}
]
},
)
]
),
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
mocker.AsyncMock(
return_value=SimpleNamespace(
active=True,
permissions=[ExtensionPermission(id="wallet.payments.watch")],
)
),
)
invoke_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
mocker.AsyncMock(),
)
try:
await dispatch_wasm_invoice_paid(_payment(ext_id, extra={}))
finally:
registry._extensions.pop(ext_id, None)
invoke_mock.assert_awaited_once()
assert invoke_mock.await_args is not None
assert invoke_mock.await_args.kwargs["owner_id"] == sha256s("user-1")
def _wasm_extension(ext_id: str, *, visibility: str = "event") -> WasmExtension:
config = parse_wasm_extension_config(
ext_id,
{
"id": ext_id,
"name": "Demo event extension",
"short_description": "Demo",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {
"module": "extension.wasm",
"exports": [
{
"name": "on_invoice_paid",
"visibility": visibility,
}
],
},
"events": {"onInvoicePaid": "on_invoice_paid"},
},
)
root_path = Path(__file__).resolve().parent / ext_id
return WasmExtension(
id=ext_id,
name=config.name,
version=config.version,
root_path=root_path,
module_path=root_path / "extension.wasm",
wit_path=None,
world=config.wasm.world,
exports=config.wasm.exports,
config=config,
)
def _payment(ext_id: str, *, extra: dict | None = None) -> SimpleNamespace:
return SimpleNamespace(
extension=ext_id,
extra={"source_id": "row-1"} if extra is None else extra,
tag=None,
wallet_id="wallet-1",
payment_hash="payment-hash",
checking_id="checking-id",
amount=1000,
fee=0,
bolt11="lnbc1",
memo="memo",
pending=False,
status="success",
json=lambda: json.dumps({"id": "payment-row"}),
)
@@ -0,0 +1,57 @@
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_wasm_frontend_assets_are_registered_in_component_bundle():
package_json = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
components = package_json["bundle"]["components"]
assert "js/wasm-extension-component.js" in components
assert "js/components/lnbits-extension-permissions.js" in components
assert "js/components/admin/lnbits-admin-wasm-runtime.js" in components
assert "js/components/admin/lnbits-admin-wasm-limit-config.js" in components
def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
encoding="utf-8"
)
assert "allowedApiRoute(method, path)" in bridge
assert "url.origin !== window.location.origin" in bridge
assert "Extension API route is not allowed." in bridge
assert "message.action === 'payment.subscribe'" in bridge
assert "message.action === 'payment.unsubscribe'" in bridge
assert "message.action === 'ui.scan_qr'" in bridge
def test_wasm_extension_install_ui_requests_permissions_before_install_paths():
extensions_page = (ROOT / "lnbits/static/js/pages/extensions.js").read_text(
encoding="utf-8"
)
wasm_bulk_update_skip_message = (
"Skipping ${ext.id}; this extension update requires permission approval."
)
assert "await this.resolveExtensionPermissionGrant(release)" in extensions_page
assert "permissions: grantedPermissions" in extensions_page
assert "release.extension_type === 'wasm'" in extensions_page
assert "this.selectedExtension?.isWasm === true" in extensions_page
assert wasm_bulk_update_skip_message in extensions_page
def test_wasm_admin_frontend_calls_runtime_limit_and_invocation_endpoints():
runtime = (
ROOT / "lnbits/static/js/components/admin/lnbits-admin-wasm-runtime.js"
).read_text(encoding="utf-8")
limits = (
ROOT / "lnbits/static/js/components/admin/lnbits-admin-wasm-limit-config.js"
).read_text(encoding="utf-8")
assert "/api/v1/extension/wasm/invocations/current" in runtime
assert "/api/v1/extension/wasm/invocations?" in runtime
assert "/api/v1/extension/wasm/invocations/stats?" in runtime
assert "/api/v1/extension/wasm/runtime-limits/extensions" in limits
assert "/api/v1/extension/wasm/runtime-limits/${encodeURIComponent(" in limits
+348
View File
@@ -0,0 +1,348 @@
import json
from types import SimpleNamespace
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.core.wasm_ext.api.host import ExtensionHostAPI
from lnbits.core.wasm_ext.api.models import (
CreateInvoicePublicRequest,
EmptyRequest,
PayInvoiceRequest,
StorageGetRequest,
WalletBalanceRequest,
)
from lnbits.exceptions import PaymentError
from lnbits.helpers import sha256s
@pytest.mark.anyio
async def test_host_api_filters_public_storage_fields(mocker: MockerFixture):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
mocker.AsyncMock(
return_value={
"id": "tip-1",
"title": "Tip jar",
"wallet_id": "secret-wallet",
}
),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[{"table_name": "tips", "public_fields": ["id", "title"]}],
)
],
)
response = await api.storage_get_public(StorageGetRequest(table="tips", id="tip-1"))
assert json.loads(response.data_json or "{}") == {
"id": "tip-1",
"title": "Tip jar",
}
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
@pytest.mark.anyio
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
mocker: MockerFixture,
):
api_without_owner = ExtensionHostAPI(
"demoext",
["ext.storage.read"],
context="event",
)
with pytest.raises(PermissionError, match="owner context"):
await api_without_owner.storage_get(StorageGetRequest(table="notes", id="1"))
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row",
mocker.AsyncMock(return_value={"id": "1", "title": "Note"}),
)
api = ExtensionHostAPI("demoext", ["ext.storage.read"], user_id="user-1")
response = await api.storage_get(StorageGetRequest(table="notes", id="1"))
assert json.loads(response.data_json or "{}") == {"id": "1", "title": "Note"}
storage_mock.assert_awaited_once_with(
"demoext",
"notes",
"1",
sha256s("user-1"),
)
@pytest.mark.anyio
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
mocker: MockerFixture,
):
api_without_permission = ExtensionHostAPI("demoext", [], user_id="user-1")
with pytest.raises(PermissionError, match="wallet.list"):
await api_without_permission.wallet_list_user_wallets(EmptyRequest())
wallet = SimpleNamespace(id="wallet-1", name="Wallet", currency="USD")
mocker.patch(
"lnbits.core.crud.wallets.get_wallets",
mocker.AsyncMock(return_value=[wallet]),
)
api = ExtensionHostAPI("demoext", ["wallet.list"], user_id="user-1")
response = await api.wallet_list_user_wallets(EmptyRequest())
assert response.wallets[0].id == "wallet-1"
assert response.wallets[0].name == "Wallet"
@pytest.mark.anyio
async def test_host_api_wallet_balance_rejects_other_users_wallets(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="other")),
)
api = ExtensionHostAPI(
"demoext",
["wallet.balance.read"],
user_id="user-1",
)
with pytest.raises(PermissionError, match="not allowed"):
await api.wallet_balance(WalletBalanceRequest(wallet_id="wallet-1"))
@pytest.mark.anyio
async def test_host_api_wallet_balance_returns_safe_summary(mocker: MockerFixture):
wallet = SimpleNamespace(
id="wallet-1",
user="user-1",
name="Wallet",
currency="USD",
balance_msat=10_000,
balance=10,
withdrawable_balance=8_000,
can_send_payments=True,
)
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
api = ExtensionHostAPI(
"demoext",
["wallet.balance.read"],
user_id="user-1",
)
response = await api.wallet_balance(WalletBalanceRequest(wallet_id="wallet-1"))
assert response.wallet_id == "wallet-1"
assert response.balance_msat == 10_000
assert response.withdrawable_msat == 8_000
assert response.fee_reserve_msat == 2_000
assert response.can_send_payments is True
@pytest.mark.anyio
async def test_host_api_pay_invoice_checks_wallet_owner_and_returns_payment_errors(
mocker: MockerFixture,
):
wallet = SimpleNamespace(id="wallet-1", user="user-1")
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
mocker.patch(
"lnbits.core.services.payments.pay_invoice",
mocker.AsyncMock(side_effect=PaymentError("insufficient balance")),
)
api = ExtensionHostAPI("demoext", ["wallet.pay_invoice"], user_id="user-1")
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet-1",
payment_request="lnbc1demo",
max_sat=None,
description="Demo",
extra={"source": "test"},
)
)
assert response.ok is False
assert response.error == "insufficient balance"
@pytest.mark.anyio
async def test_host_api_background_pay_invoice_requires_background_permission(
mocker: MockerFixture,
):
wallet = SimpleNamespace(id="wallet-1", user="user-1")
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
api = ExtensionHostAPI("demoext", [], context="event")
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet-1",
payment_request="lnbc1demo",
max_sat=None,
description="Demo",
extra={},
)
)
assert response.ok is False
assert "wallet.pay_invoice_background" in (response.error or "")
@pytest.mark.anyio
async def test_host_api_background_pay_invoice_uses_background_grant_metadata(
mocker: MockerFixture,
):
wallet = SimpleNamespace(id="wallet-1", user="user-1")
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
mocker.patch(
"lnbits.core.wasm_ext.api.host.invoice_amount_msat",
return_value=21_000,
)
background_extra_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.background_payment_extra",
mocker.AsyncMock(
return_value={
"tag": "demoext",
"background_payment": True,
"background_permission": "wallet.pay_invoice_background",
"background_wallet_id": "wallet-1",
}
),
)
payment = SimpleNamespace(
checking_id="checking-id",
payment_hash="payment-hash",
status="success",
amount=-21_000,
fee=0,
pending=False,
success=True,
)
pay_mock = mocker.patch(
"lnbits.core.services.payments.pay_invoice",
mocker.AsyncMock(return_value=payment),
)
api = ExtensionHostAPI(
"demoext",
["wallet.pay_invoice_background"],
context="event",
)
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet-1",
payment_request="lnbc1demo",
max_sat=None,
description="Demo",
extra={"source": "test"},
)
)
assert response.ok is True
background_extra_mock.assert_awaited_once()
pay_mock.assert_awaited_once()
assert pay_mock.await_args is not None
assert pay_mock.await_args.kwargs["extra"] == {
"source": "test",
"tag": "demoext",
"background_payment": True,
"background_permission": "wallet.pay_invoice_background",
"background_wallet_id": "wallet-1",
}
@pytest.mark.anyio
async def test_host_api_public_invoice_uses_granted_source_policy(
mocker: MockerFixture,
):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
mocker.AsyncMock(return_value={"id": "tip-1", "wallet_id": "wallet-1"}),
)
payment = SimpleNamespace(
payment_hash="hash",
payment_request="lnbc1demo",
bolt11="lnbc1fallback",
checking_id="checking-id",
)
create_mock = mocker.patch(
"lnbits.core.services.payments.create_payment_request",
mocker.AsyncMock(return_value=payment),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "tips", "wallet_field": "wallet_id"}],
)
],
)
response = await api.wallet_create_invoice_public(
CreateInvoicePublicRequest(
source_id="tip-1",
amount=21,
currency="sat",
memo="Tip",
extra={"note": "thanks"},
)
)
assert response.payment_hash == "hash"
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
create_mock.assert_awaited_once()
assert create_mock.await_args is not None
wallet_id, invoice = create_mock.await_args.args
assert wallet_id == "wallet-1"
assert invoice.extra == {
"tag": "demoext",
"source_id": "tip-1",
"extra_demoext": {"note": "thanks"},
}
assert invoice.extension == "demoext"
@pytest.mark.anyio
async def test_host_api_public_invoice_rejects_missing_source_wallet(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
mocker.AsyncMock(return_value={"id": "tip-1"}),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "tips", "wallet_field": "wallet_id"}],
)
],
)
with pytest.raises(PermissionError, match="no valid wallet"):
await api.wallet_create_invoice_public(
CreateInvoicePublicRequest(
source_id="tip-1",
amount=21,
currency="sat",
memo="",
)
)
@@ -1,100 +0,0 @@
from types import SimpleNamespace
import pytest
from lnbits.core.wasm_ext.api.host import ExtensionHostAPI
from lnbits.core.wasm_ext.api.models import PayInvoiceRequest
@pytest.mark.anyio
async def test_wasm_wallet_pay_invoice_resolves_ln_address(mocker):
calls = {}
async def get_wallet(wallet_id: str):
calls["get_wallet"] = wallet_id
return SimpleNamespace(user="user1")
async def get_pr_from_lnurl(lnurl: str, amount_msat: int, comment: str | None):
calls["lnurl"] = (lnurl, amount_msat, comment)
return "lnbc1resolved"
async def pay_invoice(**kwargs):
calls["pay_invoice"] = kwargs
return SimpleNamespace(
checking_id="checking",
payment_hash="hash",
status="success",
amount=-21_000,
fee=-10,
pending=False,
success=True,
)
mocker.patch("lnbits.core.crud.wallets.get_wallet", get_wallet)
mocker.patch("lnbits.core.services.lnurl.get_pr_from_lnurl", get_pr_from_lnurl)
mocker.patch("lnbits.core.services.payments.pay_invoice", pay_invoice)
api = ExtensionHostAPI("demoext", ["wallet.pay_invoice"], user_id="user1")
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet1",
payment_request="alice@example.com",
max_sat=21,
description="winner",
)
)
assert response.ok is True
assert response.checking_id == "checking"
assert calls["get_wallet"] == "wallet1"
assert calls["lnurl"] == ("alice@example.com", 21_000, "winner")
assert calls["pay_invoice"]["payment_request"] == "lnbc1resolved"
assert calls["pay_invoice"]["max_sat"] == 21
@pytest.mark.anyio
async def test_wasm_wallet_pay_invoice_allows_event_wallet_only(mocker):
async def get_wallet(wallet_id: str):
return SimpleNamespace(user="other-user")
async def pay_invoice(**kwargs):
return SimpleNamespace(
checking_id="checking",
payment_hash="hash",
status="success",
amount=-1_000,
fee=0,
pending=False,
success=True,
)
mocker.patch("lnbits.core.crud.wallets.get_wallet", get_wallet)
mocker.patch("lnbits.core.services.payments.pay_invoice", pay_invoice)
api = ExtensionHostAPI(
"demoext",
["wallet.pay_invoice"],
context="event",
owner_id="owner",
wallet_id="wallet1",
)
allowed = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet1",
payment_request="lnbc1invoice",
max_sat=None,
description="",
)
)
assert allowed.ok is True
with pytest.raises(PermissionError, match="authenticated user context"):
await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet2",
payment_request="lnbc1invoice",
max_sat=None,
description="",
)
)
+214
View File
@@ -0,0 +1,214 @@
import ipaddress
from typing import cast
import httpx
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.wasm_ext.api.models import HttpRequest
from lnbits.core.wasm_ext.client import http as wasm_http
def test_wasm_http_origin_normalization_and_url_rejections():
assert (
wasm_http._request_origin("https://EXAMPLE.com/path") == "https://example.com"
)
assert (
wasm_http._request_origin("https://example.com:8443/path")
== "https://example.com:8443"
)
for url, message in [
("http://example.com", "https URLs"),
("https://user:pass@example.com", "credentials"),
("https:///missing-host", "hostname"),
("https://example.com:bad", "invalid port"),
]:
with pytest.raises(PermissionError, match=message):
wasm_http._request_origin(url)
@pytest.mark.anyio
async def test_wasm_http_rejects_internal_hosts(mocker: MockerFixture):
with pytest.raises(PermissionError, match="localhost"):
await wasm_http._reject_internal_host("https://localhost/api")
with pytest.raises(PermissionError, match="internal network"):
await wasm_http._reject_internal_host("https://127.0.0.1/api")
mocker.patch(
"lnbits.core.wasm_ext.client.http._resolve_host",
mocker.AsyncMock(return_value=[ipaddress.ip_address("10.0.0.1")]),
)
with pytest.raises(PermissionError, match="internal network"):
await wasm_http._reject_internal_host("https://api.example.com")
def test_wasm_http_strips_forbidden_headers_and_normalizes_timeout():
assert wasm_http._request_headers(
{
" Host ": "evil.example",
"Content-Length": "10",
"X-Trace": "ok",
"Cookie": "session=secret",
}
) == {"X-Trace": "ok"}
assert wasm_http._response_headers(
{
"content-length": "10",
"set-cookie": "session=secret",
"x-safe": "ok",
}
) == {"x-safe": "ok"}
assert wasm_http._timeout_seconds(None, 10.0) == 10.0
assert wasm_http._timeout_seconds(2500, 10.0) == 2.5
assert wasm_http._timeout_seconds(0, 10.0) is None
@pytest.mark.anyio
async def test_wasm_http_request_enforces_policies_and_response_bounds(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.client.http._reject_internal_host",
mocker.AsyncMock(),
)
client = _FakeAsyncClient(
_FakeStreamResponse(
status_code=201,
headers={
"content-type": "application/json",
"set-cookie": "session=secret",
},
chunks=[b'{"ok":true}'],
)
)
mocker.patch("lnbits.core.wasm_ext.client.http.httpx.AsyncClient", client.factory)
response = await wasm_http.send_extension_http_request(
"demoext",
[{"host": "https://api.example.com"}],
HttpRequest(
method="POST",
url="https://api.example.com/path",
headers={"Host": "evil.example", "X-Test": "yes"},
body="{}",
),
timeout_ms=500,
max_response_bytes=100,
)
assert response.status_code == 201
assert response.body == '{"ok":true}'
assert response.headers == {"content-type": "application/json"}
assert client.kwargs["follow_redirects"] is False
assert client.kwargs["trust_env"] is False
assert client.kwargs["timeout"] == 0.5
assert client.stream_kwargs["headers"] == {"X-Test": "yes"}
with pytest.raises(PermissionError, match="not allowed"):
await wasm_http.send_extension_http_request(
"demoext",
[{"host": "https://api.example.com"}],
HttpRequest(url="https://other.example.com/path", body=None),
)
@pytest.mark.anyio
async def test_wasm_http_request_rejects_oversized_bodies_and_responses(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.client.http._reject_internal_host",
mocker.AsyncMock(),
)
with pytest.raises(ValueError, match="body is too large"):
await wasm_http.send_extension_http_request(
"demoext",
[{"host": "https://api.example.com"}],
HttpRequest.construct(
method="GET",
url="https://api.example.com/path",
headers={},
body="x" * 65_537,
),
)
with pytest.raises(ValueError, match="response is too large"):
await wasm_http._read_limited_response(
cast(httpx.Response, _FakeStreamResponse(chunks=[b"12345", b"67890"])),
max_response_bytes=8,
)
@pytest.mark.anyio
async def test_wasm_http_request_hides_transport_errors(mocker: MockerFixture):
mocker.patch(
"lnbits.core.wasm_ext.client.http._reject_internal_host",
mocker.AsyncMock(),
)
client = _FakeAsyncClient(_FakeStreamError())
mocker.patch("lnbits.core.wasm_ext.client.http.httpx.AsyncClient", client.factory)
with pytest.raises(ValueError, match="HTTP request failed"):
await wasm_http.send_extension_http_request(
"demoext",
[{"host": "https://api.example.com"}],
HttpRequest(url="https://api.example.com/path", body=None),
)
class _FakeAsyncClient:
def __init__(self, response):
self.response = response
self.kwargs = {}
self.stream_kwargs = {}
def factory(self, **kwargs):
self.kwargs = kwargs
return self
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
def stream(self, method, url, **kwargs):
self.stream_kwargs = {"method": method, "url": url, **kwargs}
return self.response
class _FakeStreamResponse:
def __init__(
self,
*,
status_code: int = 200,
headers: dict[str, str] | None = None,
chunks: list[bytes] | None = None,
):
self.status_code = status_code
self.headers = headers or {}
self.encoding = "utf-8"
self._chunks = chunks or []
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def aiter_bytes(self):
for chunk in self._chunks:
yield chunk
class _FakeStreamError:
async def __aenter__(self):
raise httpx.RequestError(
"network failed",
request=httpx.Request("GET", "https://api.example.com"),
)
async def __aexit__(self, *_args):
return False
+200 -1
View File
@@ -4,7 +4,22 @@ from typing import Any, cast
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.core.models.extensions import (
ExtensionBackgroundPaymentDestinationPolicy,
ExtensionPermission,
)
from lnbits.core.views.extension_api import (
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
WALLET_PAYMENTS_WATCH_PERMISSION,
_background_destination_policy_covers,
_check_background_payment_permission,
_check_wallet_payments_watch_permission,
_find_background_payment_grant,
_find_wallet_payments_watch_grant,
_remove_user_permission_grant,
_safe_user_extension_permissions,
_user_permission_grant_id_for_wallet,
)
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
from lnbits.core.wasm_ext.wasm.events import _wasm_invoice_paid_owner_id
from lnbits.core.wasm_ext.wasm.invoke import _active_installed_extension
@@ -142,6 +157,190 @@ def test_validate_wasm_permissions_rejects_unrequested_permission_grant():
)
def test_validate_wasm_permissions_allows_wallet_payments_watch_permission():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[{"id": "wallet.payments.watch"}],
)
assert validate_wasm_extension_permissions(
ext_info,
[ExtensionPermission(id="wallet.payments.watch")],
extension_config,
) == [ExtensionPermission(id="wallet.payments.watch")]
def test_background_payment_grant_lookup_and_policy_coverage():
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{
"id": "grant-1",
"wallet_id": "wallet-1",
"enabled": True,
"max_amount": 5000,
"destination_policy": "external_allowed",
}
]
}
grant = _find_background_payment_grant(permissions, "wallet-1")
assert grant
assert grant.max_amount == 5000
assert _background_destination_policy_covers(
grant.destination_policy,
ExtensionBackgroundPaymentDestinationPolicy.OWN_WALLETS_ONLY,
)
assert _background_destination_policy_covers(
grant.destination_policy,
ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED,
)
assert not _background_destination_policy_covers(
ExtensionBackgroundPaymentDestinationPolicy.OWN_WALLETS_ONLY,
ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED,
)
def test_wallet_payments_watch_grant_lookup_ignores_disabled_grant():
permissions = {
WALLET_PAYMENTS_WATCH_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": False}
]
}
grant = _find_wallet_payments_watch_grant(permissions, "wallet-1")
assert grant
assert grant.enabled is False
def test_safe_user_extension_permissions_keeps_only_grants_with_ids():
permissions = {
WALLET_PAYMENTS_WATCH_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True},
{"wallet_id": "wallet-2", "enabled": True},
"broken",
],
"broken": "not-a-list",
}
safe_permissions = _safe_user_extension_permissions(permissions)
assert safe_permissions == {
WALLET_PAYMENTS_WATCH_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True}
]
}
def test_user_permission_grant_id_for_wallet_returns_existing_grant_id():
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True}
]
}
grant_id = _user_permission_grant_id_for_wallet(
permissions, WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "wallet-1"
)
assert grant_id == "grant-1"
def test_remove_user_permission_grant_removes_only_matching_grant_id():
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True},
{"id": "grant-2", "wallet_id": "wallet-2", "enabled": True},
],
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-3", "wallet_id": "wallet-1"}],
}
updated_permissions = _remove_user_permission_grant(permissions, "grant-1")
assert updated_permissions == {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{"id": "grant-2", "wallet_id": "wallet-2", "enabled": True}
],
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-3", "wallet_id": "wallet-1"}],
}
def test_remove_user_permission_grant_drops_empty_permission():
permissions = {
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-1", "wallet_id": "wallet-1"}]
}
updated_permissions = _remove_user_permission_grant(permissions, "grant-1")
assert updated_permissions == {}
@pytest.mark.anyio
async def test_background_payment_check_reports_approved_grant(
mocker: MockerFixture,
):
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
{
"id": "grant-1",
"wallet_id": "wallet-1",
"enabled": True,
"max_amount": 5000,
"destination_policy": "external_allowed",
}
]
}
mocker.patch(
"lnbits.core.views.extension_api.get_wallet",
mocker.AsyncMock(
return_value=SimpleNamespace(
user="user-1",
is_lightning_shared_wallet=False,
can_send_payments=True,
)
),
)
result = await _check_background_payment_permission(
"user-1",
permissions,
{
"wallet_id": "wallet-1",
"max_amount": 1000,
"destination_policy": "own_wallets_only",
},
)
assert result.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION
assert result.approved is True
assert result.grant["id"] == "grant-1"
assert result.grant["max_amount"] == 5000
@pytest.mark.anyio
async def test_wallet_payment_watch_check_returns_requested_unapproved_grant(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.views.extension_api.get_wallet",
mocker.AsyncMock(return_value=SimpleNamespace(user="user-1")),
)
result = await _check_wallet_payments_watch_permission(
"user-1",
{},
{"wallet_id": "wallet-1"},
)
assert result.id == WALLET_PAYMENTS_WATCH_PERMISSION
assert result.approved is False
assert result.grant["wallet_id"] == "wallet-1"
assert result.grant["enabled"] is True
assert isinstance(result.grant["id"], str)
@pytest.mark.anyio
async def test_invoice_paid_owner_lookup_uses_stored_granted_policies(
mocker: MockerFixture,
+199 -1
View File
@@ -1,16 +1,34 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from typing import cast
import pytest
from fastapi import Request
from fastapi import HTTPException, Request
from lnbits.core.wasm_ext.routes.api import (
WasmRequestBodyTooLargeError,
_read_api_payload,
_read_json_object_with_size,
_wasm_extension_api_export,
)
from lnbits.core.wasm_ext.routes.assets import (
_reject_html_like_wasm_static_asset,
_wasm_extension_core_asset_response,
)
from lnbits.core.wasm_ext.routes.security import (
consume_wasm_extension_frame_token,
wasm_extension_frame_csp,
wasm_extension_frame_url,
)
from lnbits.core.wasm_ext.routes.ui import (
_match_wasm_extension_ui_route,
_wasm_extension_bridge_api_routes,
_wasm_extension_entrypoint,
)
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
from lnbits.core.wasm_ext.wasm.loader import WasmExtension
@pytest.mark.anyio
@@ -56,6 +74,119 @@ async def test_wasm_api_payload_records_actual_body_bytes():
assert payload.request_bytes == len(body)
def test_wasm_api_export_visibility_is_enforced(tmp_path: Path):
extension = _wasm_extension(tmp_path)
assert _wasm_extension_api_export(extension, "render") == "render"
assert _wasm_extension_api_export(extension, "private_render") == "private_render"
with pytest.raises(PermissionError, match="not callable over HTTP"):
_wasm_extension_api_export(extension, "on_invoice_paid")
with pytest.raises(KeyError, match="has no export"):
_wasm_extension_api_export(extension, "missing")
def test_wasm_ui_entrypoint_rejects_escape_static_and_non_html(tmp_path: Path):
extension = _wasm_extension(tmp_path)
(tmp_path / "index.html").write_text("<html></html>", encoding="utf-8")
(tmp_path / "index.txt").write_text("text", encoding="utf-8")
(tmp_path / "static").mkdir()
(tmp_path / "static" / "index.html").write_text("<html></html>", encoding="utf-8")
assert (
_wasm_extension_entrypoint(extension, "index.html")
== (tmp_path / "index.html").resolve()
)
with pytest.raises(ValueError, match="escapes extension root"):
_wasm_extension_entrypoint(extension, "../outside.html")
with pytest.raises(ValueError, match="must not be inside the static"):
_wasm_extension_entrypoint(extension, "static/index.html")
with pytest.raises(ValueError, match="must be an HTML file"):
_wasm_extension_entrypoint(extension, "index.txt")
def test_wasm_frame_token_is_one_time_and_user_bound(tmp_path: Path):
extension = _wasm_extension(tmp_path)
frame_path = "/ext-frame/demoext/0"
frame_url = wasm_extension_frame_url(extension, frame_path, "user-1")
token = frame_url.split("frame_token=", 1)[1]
with pytest.raises(HTTPException) as wrong_user:
consume_wasm_extension_frame_token(
_request_with_query(token),
extension,
frame_path,
"user-2",
)
assert wrong_user.value.status_code == 404
consume_wasm_extension_frame_token(
_request_with_query(token),
extension,
frame_path,
"user-1",
)
with pytest.raises(HTTPException) as reused:
consume_wasm_extension_frame_token(
_request_with_query(token),
extension,
frame_path,
"user-1",
)
assert reused.value.status_code == 404
def test_wasm_frame_csp_is_locked_to_extension_assets(tmp_path: Path):
csp = wasm_extension_frame_csp(
_request_with_query("token"),
_wasm_extension(tmp_path),
)
assert "sandbox allow-scripts" in csp
assert "default-src 'none'" in csp
assert "connect-src 'none'" in csp
assert "frame-ancestors 'self'" in csp
assert "http://testserver/ext-assets/demoext/" in csp
def test_wasm_ui_route_matching_and_bridge_public_api_filtering(tmp_path: Path):
extension = _wasm_extension(tmp_path)
matched = _match_wasm_extension_ui_route(extension, "/ext/demo/abc")
public_routes = _wasm_extension_bridge_api_routes(extension, public=True)
private_routes = _wasm_extension_bridge_api_routes(extension, public=False)
assert matched["auth"] == "user"
assert matched["route_params"] == {"item_id": "abc"}
assert public_routes == [
{
"method": "GET",
"path": "/api/v1/ext/demoext/public/{item_id}",
"pattern": "^/api/v1/ext/demoext/public/[^/]+$",
}
]
assert {route["path"] for route in private_routes} == {
"/api/v1/ext/demoext/public/{item_id}",
"/api/v1/ext/demoext/private/{item_id}",
}
def test_wasm_static_core_assets_and_html_like_text_assets_are_guarded(tmp_path: Path):
response = _wasm_extension_core_asset_response("_lnbits/material-icons.css")
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["Cache-Control"] == "no-store"
for path in ["_lnbits/../bundle.min.css", "_lnbits/missing.css"]:
with pytest.raises(HTTPException) as exc_info:
_wasm_extension_core_asset_response(path)
assert exc_info.value.status_code == 404
script_path = tmp_path / "app.js"
script_path.write_text("<script>alert(1)</script>", encoding="utf-8")
with pytest.raises(HTTPException) as html_like:
_reject_html_like_wasm_static_asset(script_path)
assert html_like.value.status_code == 404
class _FakeRequest:
method = "POST"
@@ -79,3 +210,70 @@ class _FakeRequest:
self.stream_started = True
for chunk in self._chunks:
yield chunk
def _wasm_extension(root_path: Path) -> WasmExtension:
config = parse_wasm_extension_config(
"demoext",
{
"id": "demoext",
"name": "Demo",
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {
"module": "extension.wasm",
"exports": [
{"name": "render", "visibility": "public"},
{"name": "private_render", "visibility": "authenticated"},
{"name": "on_invoice_paid", "visibility": "event"},
],
},
"ui_routes": [
{
"path": "/demo/{item_id}",
"entrypoint": "index.html",
"auth": "user",
}
],
"api_routes": [
{
"method": "GET",
"path": "/public/{item_id}",
"export": "render",
"auth": "public",
},
{
"method": "POST",
"path": "/private/{item_id}",
"export": "private_render",
"auth": "user",
},
],
},
)
return WasmExtension(
id="demoext",
name="Demo",
version="1.0.0",
root_path=root_path,
module_path=root_path / "extension.wasm",
wit_path=None,
world="",
exports=config.wasm.exports,
config=config,
)
def _request_with_query(token: str) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"scheme": "http",
"server": ("testserver", 80),
"path": "/ext-frame/demoext/0",
"query_string": f"frame_token={token}".encode(),
"headers": [],
}
)
+409
View File
@@ -0,0 +1,409 @@
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import extensions as extension_crud
from lnbits.core.crud import update_migration_version
from lnbits.core.db import db as core_db
from lnbits.core.migrations import (
m010_create_installed_extensions_table,
m046_add_permissions_to_installed_extensions,
m047_create_wasm_invocations_table,
m048_add_wasm_runtime_limits_to_installed_extensions,
)
from lnbits.core.models.extensions import ExtensionPermission, WasmInvocation
from lnbits.core.wasm_ext.storage import crud as storage_crud
from lnbits.core.wasm_ext.storage.crud import (
OWNER_ID_FIELD,
migrate_wasm_extension_database,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_set_row,
)
from lnbits.db import DB_TYPE, SQLITE, Compat, Connection, Database
from lnbits.settings import Settings
from tests.helpers import make_installable_extension
@pytest.mark.anyio
async def test_core_wasm_migrations_create_persistent_columns(
tmp_path: Path, settings: Settings
):
if DB_TYPE != SQLITE:
pytest.skip("temporary core databases are SQLite-only")
db = _temporary_database(tmp_path, settings, "wasm_core_migrations")
async with db.connect() as conn:
await m010_create_installed_extensions_table(conn)
await m046_add_permissions_to_installed_extensions(conn)
await m047_create_wasm_invocations_table(conn)
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
installed_columns = {
row["name"]
for row in await conn.fetchall("PRAGMA table_info(installed_extensions)")
}
invocation_columns = {
row["name"]
for row in await conn.fetchall("PRAGMA table_info(wasm_invocations)")
}
assert {"permissions", "wasm_runtime_limits"}.issubset(installed_columns)
assert {
"extension_id",
"status",
"request_bytes",
"response_bytes",
"host_call_count",
"http_call_count",
"storage_call_count",
"wallet_call_count",
"context",
}.issubset(invocation_columns)
@pytest.mark.anyio
async def test_installed_extension_permissions_and_wasm_limits_round_trip(
app,
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
):
db = await _temporary_core_crud_database(tmp_path, settings)
mocker.patch.object(extension_crud, "db", db)
ext_id = f"wasmlimits_{uuid4().hex[:8]}"
extension = make_installable_extension(ext_id)
extension.permissions = [ExtensionPermission(id="utils.basic")]
extension.wasm_runtime_limits = {"wasm_runtime_max_execution_ms": 1234}
await extension_crud.create_installed_extension(extension)
stored = await extension_crud.get_installed_extension(ext_id)
assert stored is not None
assert stored.permissions == [ExtensionPermission(id="utils.basic")]
assert stored.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
stored.wasm_runtime_limits = {"wasm_runtime_max_fuel": 0}
await extension_crud.update_installed_extension(stored)
updated = await extension_crud.get_installed_extension(ext_id)
assert updated is not None
assert updated.wasm_runtime_limits == {"wasm_runtime_max_fuel": 0}
@pytest.mark.anyio
async def test_wasm_invocation_crud_stats_and_cleanup_are_isolated(
app,
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
):
db = await _temporary_core_crud_database(tmp_path, settings)
mocker.patch.object(extension_crud, "db", db)
now = datetime.now(timezone.utc)
old = now - timedelta(days=10)
ext_id = f"wasminv_{uuid4().hex[:8]}"
other_ext_id = f"wasminv_{uuid4().hex[:8]}"
invocations = [
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=ext_id,
export_name="render",
status="completed",
started_at=old,
finished_at=old + timedelta(milliseconds=20),
duration_ms=20,
host_call_count=2,
http_call_count=1,
),
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=ext_id,
export_name="render",
status="failed",
started_at=now,
finished_at=now,
duration_ms=40,
host_call_count=3,
storage_call_count=2,
),
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=ext_id,
export_name="render",
status="running",
started_at=old,
),
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=other_ext_id,
export_name="render",
status="completed",
started_at=now,
duration_ms=100,
),
]
for invocation in invocations:
await extension_crud.create_wasm_invocation(invocation)
failed = await extension_crud.get_wasm_invocations(
extension_id=ext_id,
status="failed",
)
stats = await extension_crud.get_wasm_invocation_stats(
extension_id=ext_id,
since=now - timedelta(days=30),
)
deleted = await extension_crud.delete_old_wasm_invocations(retention_days=7)
remaining_running = await extension_crud.get_wasm_invocations(
extension_id=ext_id,
status="running",
)
assert [invocation.id for invocation in failed] == [invocations[1].id]
assert stats.total == 3
assert stats.completed == 1
assert stats.failed == 1
assert stats.running == 1
assert stats.host_call_count == 5
assert stats.http_call_count == 1
assert stats.storage_call_count == 2
assert deleted == 1
assert [invocation.id for invocation in remaining_running] == [invocations[2].id]
@pytest.mark.anyio
async def test_wasm_datetime_queries_use_postgres_placeholders(
mocker: MockerFixture,
):
db = mocker.Mock()
db.timestamp_placeholder.side_effect = lambda key: f"to_timestamp(:{key})"
db.fetchone = mocker.AsyncMock(return_value=None)
db.execute = mocker.AsyncMock(return_value=SimpleNamespace(rowcount=0))
field = {"name": "created_at", "type": "datetime"}
assert (
storage_crud._value_placeholder(cast(Compat, db), field, "created_at")
== "to_timestamp(:created_at)"
)
where_sql, _ = storage_crud._where_sql(
cast(Compat, db), {"fields": [field]}, {"created_at": 0}, None, []
)
assert "created_at = to_timestamp(:filter_created_at)" in where_sql
conn = cast(Connection, db)
await extension_crud.get_wasm_invocation_stats(
since=datetime.now(timezone.utc), conn=conn
)
await extension_crud.delete_old_wasm_invocations(1, conn=conn)
await extension_crud.mark_stale_wasm_invocations(conn=conn)
queries = [
db.fetchone.call_args.args[0],
*[c.args[0] for c in db.execute.call_args_list],
]
assert all("to_timestamp(:" in query for query in queries)
@pytest.mark.anyio
async def test_wasm_storage_migration_and_owner_scoped_crud(
tmp_path: Path,
settings: Settings,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
ext_dir = _write_storage_extension(settings, ext_id)
await migrate_wasm_extension_database(make_installable_extension(ext_id))
await storage_set_row(
ext_id,
"notes",
{
"id": "note-1",
"title": "First",
"count": 1,
"published": True,
"tags": ["alpha", "beta"],
"created_at": 1_700_000_000,
},
"owner-1",
)
await storage_set_row(
ext_id,
"notes",
{
"id": "note-1",
"title": "Other owner attempt",
"count": 2,
"published": False,
"tags": ["gamma"],
"created_at": 1_700_000_001,
},
"owner-2",
)
owner_row = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
other_owner_row = await storage_get_row(ext_id, "notes", "note-1", "owner-2")
public_row = await storage_get_public_row(ext_id, "notes", "note-1")
page = await storage_get_paginated_rows(
ext_id,
"notes",
{"published": True},
owner_id="owner-1",
search="fir",
search_fields=["title"],
sort_by="count",
descending=True,
limit=50,
offset=0,
)
await storage_delete_row(ext_id, "notes", "note-1", "owner-2")
still_owned = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
await storage_delete_row(ext_id, "notes", "note-1", "owner-1")
deleted = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
finally:
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_data_folder = original_data_folder
assert ext_dir.is_dir()
assert owner_row is not None
assert owner_row["title"] == "First"
assert owner_row["tags"] == ["alpha", "beta"]
assert owner_row["created_at"] == 1_700_000_000
assert other_owner_row is None
assert public_row is not None
assert public_row["title"] == "First"
assert page["total"] == 1
assert page["data"][0]["id"] == "note-1"
assert still_owned is not None
assert deleted is None
@pytest.mark.anyio
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
tmp_path: Path,
settings: Settings,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
_write_storage_extension(settings, ext_id)
with pytest.raises(ValueError, match="Invalid WASM storage SQL identifier"):
await storage_get_row(ext_id, "notes;DROP", "note-1", "owner-1")
with pytest.raises(ValueError, match="reserved owner field"):
await storage_set_row(
ext_id,
"notes",
{"id": "note-1", OWNER_ID_FIELD: "owner-2"},
"owner-1",
)
schema_path = (
Path(settings.lnbits_extensions_path)
/ "extensions"
/ ext_id
/ "storage"
/ "schema.json"
)
schema = json.loads(schema_path.read_text(encoding="utf-8"))
schema["tables"]["notes"]["fields"].append(
{"name": OWNER_ID_FIELD, "type": "string"}
)
schema_path.write_text(json.dumps(schema), encoding="utf-8")
with pytest.raises(ValueError, match="reserved field"):
await storage_get_row(ext_id, "notes", "note-1", "owner-1")
finally:
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_data_folder = original_data_folder
def _temporary_database(
tmp_path: Path,
settings: Settings,
name: str,
) -> Database:
settings.lnbits_data_folder = str(tmp_path / "data")
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
return Database(name)
async def _temporary_core_crud_database(
tmp_path: Path,
settings: Settings,
) -> Database:
if DB_TYPE != SQLITE:
return core_db
db = _temporary_database(tmp_path, settings, f"core_{uuid4().hex[:8]}")
async with db.connect() as conn:
await conn.execute("""
CREATE TABLE dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
""")
await update_migration_version(conn, "core", 48)
await m010_create_installed_extensions_table(conn)
await m046_add_permissions_to_installed_extensions(conn)
await m047_create_wasm_invocations_table(conn)
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
return db
def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
storage_dir = ext_dir / "storage"
migrations_dir = storage_dir / "migrations"
migrations_dir.mkdir(parents=True)
schema = {
"tables": {
"notes": {
"fields": [
{"name": "id", "type": "string"},
{"name": "title", "type": "string"},
{"name": "count", "type": "integer", "default": 0},
{"name": "published", "type": "boolean", "default": False},
{"name": "tags", "type": "string", "list": True},
{"name": "created_at", "type": "datetime"},
]
}
}
}
migration = {
"operations": [
{
"op": "create_table",
"table": "notes",
"fields": schema["tables"]["notes"]["fields"],
}
]
}
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")
(migrations_dir / "001_init.json").write_text(
json.dumps(migration),
encoding="utf-8",
)
return ext_dir