Compare commits

..
Author SHA1 Message Date
Vlad Stan 54e8dbcd8d chore: make bundle 2026-07-16 15:31:55 +03:00
Vlad Stan b678773728 fix: stricter public reads 2026-07-16 15:29:56 +03:00
Vlad Stan 58ccdb7212 chore: make bundle 2026-07-16 11:36:29 +03:00
Vlad Stan 44f627ffda test: fix 2026-07-16 11:35:04 +03:00
Vlad Stan a25cadb66d feat: partial implementation 2026-07-16 11:35:04 +03:00
Vlad Stan 9f317c5edf feat: append public row 2026-07-16 11:35:02 +03: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
dni ⚡andGitHub 09e44f18e5 test: fix revolut test timestamp exceeded (#4054) 2026-07-13 10:53:17 +03:00
76 changed files with 9925 additions and 256 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:
+2
View File
@@ -18,6 +18,7 @@ from .extensions import (
get_user_extensions,
update_installed_extension,
update_installed_extension_state,
update_installed_extension_wasm_runtime_limits,
update_user_extension,
)
from .payments import (
@@ -156,6 +157,7 @@ __all__ = [
"update_admin_settings",
"update_installed_extension",
"update_installed_extension_state",
"update_installed_extension_wasm_runtime_limits",
"update_migration_version",
"update_payment",
"update_payment_checking_id",
+197
View File
@@ -1,7 +1,12 @@
import json
from datetime import datetime, timedelta, timezone
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
UserExtension,
WasmInvocation,
WasmInvocationStats,
)
from lnbits.db import Connection, Database
@@ -11,6 +16,11 @@ async def create_installed_extension(
conn: Connection | None = None,
) -> None:
await (conn or db).insert("installed_extensions", ext)
await update_installed_extension_wasm_runtime_limits(
ext_id=ext.id,
limits=ext.wasm_runtime_limits,
conn=conn,
)
async def update_installed_extension(
@@ -18,6 +28,11 @@ async def update_installed_extension(
conn: Connection | None = None,
) -> None:
await (conn or db).update("installed_extensions", ext)
await update_installed_extension_wasm_runtime_limits(
ext_id=ext.id,
limits=ext.wasm_runtime_limits,
conn=conn,
)
async def update_installed_extension_state(
@@ -31,6 +46,33 @@ async def update_installed_extension_state(
)
async def update_installed_extension_wasm_runtime_limits(
*, ext_id: str, limits: dict, conn: Connection | None = None
) -> None:
if not await _has_installed_extension_wasm_runtime_limits_column(conn=conn):
return
await (conn or db).execute(
"""
UPDATE installed_extensions
SET wasm_runtime_limits = :limits
WHERE id = :id
""",
{"id": ext_id, "limits": json.dumps(limits)},
)
async def _has_installed_extension_wasm_runtime_limits_column(
conn: Connection | None = None,
) -> bool:
row: dict | None = await (conn or db).fetchone(
"SELECT version FROM dbversions WHERE db = 'core'"
)
if not row:
return False
return int(row["version"] or 0) >= 48
async def delete_installed_extension(
*, ext_id: str, conn: Connection | None = None
) -> None:
@@ -144,3 +186,158 @@ async def get_user_active_extensions_ids(
UserExtension,
)
return [ext.extension for ext in exts]
async def create_wasm_invocation(
invocation: WasmInvocation,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("wasm_invocations", invocation)
async def update_wasm_invocation(
invocation: WasmInvocation,
conn: Connection | None = None,
) -> None:
await (conn or db).update("wasm_invocations", invocation)
async def get_wasm_invocation(
invocation_id: str,
conn: Connection | None = None,
) -> WasmInvocation | None:
return await (conn or db).fetchone(
"SELECT * FROM wasm_invocations WHERE id = :id",
{"id": invocation_id},
model=WasmInvocation,
)
async def get_wasm_invocations(
*,
extension_id: str | None = None,
status: str | None = None,
limit: int = 100,
offset: int = 0,
conn: Connection | None = None,
) -> list[WasmInvocation]:
where: list[str] = []
values: dict = {
"limit": max(1, min(limit, 500)),
"offset": max(offset, 0),
}
if extension_id:
where.append("extension_id = :extension_id")
values["extension_id"] = extension_id
if status:
where.append("status = :status")
values["status"] = status
query = "SELECT * FROM wasm_invocations"
if where:
query += f" WHERE {' AND '.join(where)}"
query += " ORDER BY started_at DESC LIMIT :limit OFFSET :offset"
return await (conn or db).fetchall(query, values, model=WasmInvocation)
async def get_running_wasm_invocations(
conn: Connection | None = None,
) -> list[WasmInvocation]:
return await get_wasm_invocations(status="running", conn=conn)
async def get_wasm_invocation_stats(
*,
extension_id: str | None = None,
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(f"started_at >= {database.timestamp_placeholder('since')}")
values["since"] = since
query = """
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0)
AS running,
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)
AS completed,
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0)
AS failed,
COALESCE(SUM(CASE WHEN status = 'stopped' THEN 1 ELSE 0 END), 0)
AS stopped,
COALESCE(SUM(CASE WHEN status = 'timeout' THEN 1 ELSE 0 END), 0)
AS timeout,
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
COALESCE(MAX(duration_ms), 0) AS max_duration_ms,
COALESCE(SUM(host_call_count), 0) AS host_call_count,
COALESCE(SUM(http_call_count), 0) AS http_call_count,
COALESCE(SUM(storage_call_count), 0) AS storage_call_count,
COALESCE(SUM(wallet_call_count), 0) AS wallet_call_count
FROM wasm_invocations
"""
if where:
query += f" WHERE {' AND '.join(where)}"
row: dict | None = await (conn or db).fetchone(query, values)
if not row:
return WasmInvocationStats()
return WasmInvocationStats(
total=int(row["total"] or 0),
running=int(row["running"] or 0),
completed=int(row["completed"] or 0),
failed=int(row["failed"] or 0),
stopped=int(row["stopped"] or 0),
timeout=int(row["timeout"] or 0),
avg_duration_ms=float(row["avg_duration_ms"] or 0),
max_duration_ms=int(row["max_duration_ms"] or 0),
host_call_count=int(row["host_call_count"] or 0),
http_call_count=int(row["http_call_count"] or 0),
storage_call_count=int(row["storage_call_count"] or 0),
wallet_call_count=int(row["wallet_call_count"] or 0),
)
async def delete_old_wasm_invocations(
retention_days: int,
conn: Connection | None = None,
) -> int:
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
database = conn or db
result = await database.execute(
f"""
DELETE FROM wasm_invocations
WHERE status != 'running'
AND started_at < {database.timestamp_placeholder("cutoff")}
""", # noqa: S608
{"cutoff": cutoff},
)
return int(result.rowcount or 0)
async def mark_stale_wasm_invocations(
conn: Connection | None = None,
) -> None:
database = conn or db
await database.execute(
f"""
UPDATE wasm_invocations
SET status = 'abandoned',
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)},
)
+66
View File
@@ -824,3 +824,69 @@ async def m046_add_permissions_to_installed_extensions(db: Connection):
await db.execute(
"ALTER TABLE installed_extensions ADD COLUMN permissions TEXT DEFAULT '[]'"
)
async def m047_create_wasm_invocations_table(db: Connection):
"""
Tracks WASM extension invocations for runtime monitoring and controls.
"""
await db.execute(f"""
CREATE TABLE IF NOT EXISTS wasm_invocations (
id TEXT PRIMARY KEY,
extension_id TEXT NOT NULL,
export_name TEXT NOT NULL,
trigger_type TEXT NOT NULL DEFAULT 'unknown',
status TEXT NOT NULL DEFAULT 'running',
started_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
finished_at TIMESTAMP,
duration_ms INT,
user_id TEXT,
wallet_id TEXT,
request_id TEXT,
method TEXT,
path TEXT,
event_type TEXT,
payment_hash TEXT,
checking_id TEXT,
memory_peak_bytes INT,
request_bytes INT,
response_bytes INT,
host_call_count INT NOT NULL DEFAULT 0,
http_call_count INT NOT NULL DEFAULT 0,
storage_call_count INT NOT NULL DEFAULT 0,
wallet_call_count INT NOT NULL DEFAULT 0,
error_type TEXT,
error_message TEXT,
stop_reason TEXT,
"context" TEXT NOT NULL DEFAULT '{{}}'
);
""")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_extension_started
ON wasm_invocations (extension_id, started_at);
""")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_status
ON wasm_invocations (status);
""")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_started
ON wasm_invocations (started_at);
""")
async def m048_add_wasm_runtime_limits_to_installed_extensions(db: Connection):
"""
Adds per-extension WASM runtime limit overrides.
"""
await db.execute(
"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 '{}'")
+139 -4
View File
@@ -7,12 +7,15 @@ import os
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
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, StrictStr
from lnbits.helpers import (
download_url,
@@ -79,11 +82,13 @@ class GitHubRepo(BaseModel):
class ExtensionPermission(BaseModel):
id: str
label: str | None = None
description: str | None = None
id: StrictStr
description: StrictStr | None = None
policies: list[Any] | None = None
class Config:
extra = "ignore"
@staticmethod
def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]:
return [
@@ -136,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:
@@ -196,6 +273,63 @@ class Extension(BaseModel):
)
class WasmInvocation(BaseModel):
id: str
extension_id: str
export_name: str
trigger_type: str = "unknown"
status: str = "running"
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
finished_at: datetime | None = None
duration_ms: int | None = None
user_id: str | None = None
wallet_id: str | None = None
request_id: str | None = None
method: str | None = None
path: str | None = None
event_type: str | None = None
payment_hash: str | None = None
checking_id: str | None = None
memory_peak_bytes: int | None = None
request_bytes: int | None = None
response_bytes: int | None = None
host_call_count: int = 0
http_call_count: int = 0
storage_call_count: int = 0
wallet_call_count: int = 0
error_type: str | None = None
error_message: str | None = None
stop_reason: str | None = None
context: dict = Field(default_factory=dict)
class WasmInvocationStats(BaseModel):
total: int = 0
running: int = 0
completed: int = 0
failed: int = 0
stopped: int = 0
timeout: int = 0
avg_duration_ms: float = 0
max_duration_ms: int = 0
host_call_count: int = 0
http_call_count: int = 0
storage_call_count: int = 0
wallet_call_count: int = 0
class WasmRuntimeLimitsUpdate(BaseModel):
limits: dict[str, Any] = Field(default_factory=dict)
class WasmRuntimeLimitsInfo(BaseModel):
id: str
name: str
active: bool | None = False
wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
effective_wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
class ExtensionRelease(BaseModel):
name: str
version: str
@@ -371,6 +505,7 @@ class InstallableExtension(BaseModel):
stars: int = 0
meta: ExtensionMeta | None = None
permissions: list[ExtensionPermission] = []
wasm_runtime_limits: dict = Field(default_factory=dict, no_database=True)
@property
def hash(self) -> str:
+17 -8
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from collections.abc import Callable
from pathlib import Path
from typing import Any
from pydantic import BaseModel
@@ -10,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()
@@ -31,8 +25,16 @@ class WasmExtensionRegistry:
self._extensions: dict[str, Any] = {}
def register(self, extension: Any) -> None:
self.require_available(extension)
self._extensions[extension.id] = extension
def require_available(self, extension: Any) -> None:
existing = self._extensions.get(extension.id)
if existing and not _same_wasm_extension_registration(existing, extension):
raise ValueError(
f"WASM extension id '{extension.id}' is already registered."
)
def get(self, ext_id: str) -> Any | None:
return self._extensions.get(ext_id)
@@ -40,6 +42,13 @@ class WasmExtensionRegistry:
return list(self._extensions.values())
def _same_wasm_extension_registration(left: Any, right: Any) -> bool:
try:
return Path(left.root_path).resolve() == Path(right.root_path).resolve()
except (AttributeError, TypeError):
return left is right
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
+546 -1
View File
@@ -1,5 +1,12 @@
import asyncio
import importlib
import re
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from threading import RLock
from typing import Any
from uuid import uuid4
from loguru import logger
@@ -13,22 +20,168 @@ from lnbits.core.crud import (
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
create_wasm_invocation,
delete_old_wasm_invocations,
get_installed_extensions,
get_wasm_invocation,
mark_stale_wasm_invocations,
update_installed_extension,
update_installed_extension_wasm_runtime_limits,
update_wasm_invocation,
)
from lnbits.core.crud.extensions import (
get_wasm_invocation_stats as get_wasm_invocation_stats_crud,
)
from lnbits.core.crud.extensions import (
get_wasm_invocations as get_wasm_invocations_crud,
)
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
from lnbits.db import Connection
from lnbits.settings import settings
from lnbits.settings import WasmRuntimeLimits, settings
from ..models.extensions import (
Extension,
ExtensionMeta,
ExtensionPermission,
InstallableExtension,
WasmInvocation,
WasmInvocationStats,
)
_WASM_INVOCATION_CLEANUP_INTERVAL = timedelta(hours=1)
WASM_RUNTIME_LIMIT_FIELDS = tuple(WasmRuntimeLimits.__fields__.keys())
@dataclass
class WasmInvocationHandle:
invocation: WasmInvocation
engine: Any | None = None
store: Any | None = None
runtime_limits: dict[str, int] | None = None
stop_requested: bool = False
stop_reason: str | None = None
_wasm_invocation_lock = RLock()
_wasm_invocation_ready_lock = asyncio.Lock()
_wasm_invocation_handles: dict[str, WasmInvocationHandle] = {}
_wasm_invocations_marked_stale = False
_wasm_invocations_last_cleanup_at: datetime | None = None
def wasm_runtime_limit_defaults() -> dict[str, int]:
return {field: int(getattr(settings, field)) for field in WASM_RUNTIME_LIMIT_FIELDS}
def validate_wasm_runtime_limit_overrides(
limits: Mapping[str, Any] | None,
*,
strict: bool = True,
) -> dict[str, int]:
if not limits:
return {}
validated: dict[str, int] = {}
for field, raw_value in limits.items():
if field not in WASM_RUNTIME_LIMIT_FIELDS:
if strict:
raise ValueError(f"Unknown WASM runtime limit field '{field}'.")
continue
value = _validate_wasm_runtime_limit_value(field, raw_value, strict=strict)
if value is None:
continue
validated[field] = value
return validated
def _validate_wasm_runtime_limit_value(
field: str,
raw_value: Any,
*,
strict: bool,
) -> int | None:
if raw_value is None or raw_value == "":
return None
if isinstance(raw_value, bool):
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
if isinstance(raw_value, str):
raw_value = raw_value.strip()
if raw_value == "":
return None
if not raw_value.isdecimal():
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
if isinstance(raw_value, float) and not raw_value.is_integer():
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
try:
value = int(raw_value)
except (TypeError, ValueError) as exc:
return _invalid_wasm_runtime_limit(
field,
strict,
"must be an integer",
exc=exc,
)
if value < 0:
return _invalid_wasm_runtime_limit(field, strict, "cannot be negative")
return value
def _invalid_wasm_runtime_limit(
field: str,
strict: bool,
message: str,
*,
exc: Exception | None = None,
) -> int | None:
if not strict:
return None
error = ValueError(f"WASM runtime limit '{field}' {message}.")
if exc:
raise error from exc
raise error
def resolve_wasm_runtime_limits(
installed_extension: InstallableExtension | None = None,
) -> dict[str, int]:
limits = wasm_runtime_limit_defaults()
if installed_extension:
limits.update(
validate_wasm_runtime_limit_overrides(
installed_extension.wasm_runtime_limits,
strict=False,
)
)
return limits
async def get_wasm_runtime_limits_for_extension(ext_id: str) -> dict[str, int]:
installed_extension = await get_installed_extension(ext_id)
return resolve_wasm_runtime_limits(installed_extension)
async def update_wasm_extension_runtime_limits(
ext_id: str,
limits: Mapping[str, Any] | None,
) -> dict[str, int]:
installed_extension = await get_installed_extension(ext_id)
if not installed_extension:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not installed_extension.is_wasm:
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
validated_limits = validate_wasm_runtime_limit_overrides(limits)
await update_installed_extension_wasm_runtime_limits(
ext_id=ext_id,
limits=validated_limits,
)
return validated_limits
async def install_extension(
ext_info: InstallableExtension,
@@ -47,6 +200,8 @@ async def install_extension(
installed_ext = await get_installed_extension(ext_info.id)
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
if installed_ext:
ext_info.wasm_runtime_limits = installed_ext.wasm_runtime_limits
await check_extensions_limit(installed_ext)
@@ -90,6 +245,394 @@ async def check_extensions_limit(installed_ext: InstallableExtension | None = No
raise ValueError("Max amount of extensions have been installed")
async def ensure_wasm_invocation_monitoring_ready() -> None:
global _wasm_invocations_last_cleanup_at, _wasm_invocations_marked_stale
async with _wasm_invocation_ready_lock:
now = _now()
if not _wasm_invocations_marked_stale:
await mark_stale_wasm_invocations()
_wasm_invocations_marked_stale = True
if (
_wasm_invocations_last_cleanup_at is None
or now - _wasm_invocations_last_cleanup_at
>= _WASM_INVOCATION_CLEANUP_INTERVAL
):
_wasm_invocations_last_cleanup_at = now
await delete_old_wasm_invocations(
settings.lnbits_wasm_invocation_retention_days
)
async def start_wasm_invocation(
*,
extension_id: str,
export_name: str,
trigger_type: str = "unknown",
user_id: str | None = None,
wallet_id: str | None = None,
request_id: str | None = None,
method: str | None = None,
path: str | None = None,
event_type: str | None = None,
payment_hash: str | None = None,
checking_id: str | None = None,
request_bytes: int | None = None,
context: dict | None = None,
runtime_limits: dict[str, int] | None = None,
) -> WasmInvocation:
await ensure_wasm_invocation_monitoring_ready()
_check_wasm_invocation_concurrency(
extension_id=extension_id,
user_id=user_id,
limits=runtime_limits,
)
invocation = WasmInvocation(
id=uuid4().hex,
extension_id=extension_id,
export_name=export_name,
trigger_type=trigger_type,
user_id=user_id,
wallet_id=wallet_id,
request_id=request_id,
method=method,
path=path,
event_type=event_type,
payment_hash=payment_hash,
checking_id=checking_id,
request_bytes=request_bytes,
context=_safe_wasm_invocation_context(context or {}),
)
await create_wasm_invocation(invocation)
with _wasm_invocation_lock:
_wasm_invocation_handles[invocation.id] = WasmInvocationHandle(
invocation,
runtime_limits=runtime_limits,
)
return invocation
def attach_wasm_invocation_runtime(
invocation_id: str,
*,
engine: Any,
store: Any,
) -> None:
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
if not handle:
return
handle.engine = engine
handle.store = store
if handle.stop_requested:
_interrupt_wasm_invocation(handle)
def record_wasm_invocation_host_call(
invocation_id: str | None,
method_id: str,
) -> None:
if not invocation_id:
return
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
if not handle:
return
invocation = handle.invocation
invocation.host_call_count += 1
category = _wasm_host_call_category(method_id)
if category == "http":
invocation.http_call_count += 1
elif category == "storage":
invocation.storage_call_count += 1
elif category == "wallet":
invocation.wallet_call_count += 1
_check_wasm_host_call_limit(invocation, category, handle.runtime_limits)
async def stop_wasm_invocation(
invocation_id: str,
*,
reason: str = "Stopped by admin.",
) -> bool:
interrupted = False
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
if handle:
handle.stop_requested = True
handle.stop_reason = reason
handle.invocation.stop_reason = reason
interrupted = _interrupt_wasm_invocation(handle)
invocation = await get_wasm_invocation(invocation_id)
if invocation and invocation.status == "running":
invocation.stop_reason = reason
await update_wasm_invocation(invocation)
return interrupted
async def stop_wasm_extension_invocations(
extension_id: str,
*,
reason: str = "Extension deactivated.",
) -> int:
with _wasm_invocation_lock:
invocation_ids = [
invocation_id
for invocation_id, handle in _wasm_invocation_handles.items()
if handle.invocation.extension_id == extension_id
]
for invocation_id in invocation_ids:
await stop_wasm_invocation(invocation_id, reason=reason)
return len(invocation_ids)
def wasm_invocation_stop_requested(invocation_id: str) -> bool:
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
return bool(handle and handle.stop_requested)
def get_wasm_invocation_stop_reason(invocation_id: str) -> str | None:
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
return handle.stop_reason if handle else None
async def finish_wasm_invocation(
invocation_id: str,
*,
status: str,
response_bytes: int | None = None,
memory_peak_bytes: int | None = None,
error_type: str | None = None,
error_message: str | None = None,
stop_reason: str | None = None,
) -> None:
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.pop(invocation_id, None)
invocation = (
handle.invocation if handle else await get_wasm_invocation(invocation_id)
)
if not invocation:
return
reason = stop_reason or (handle.stop_reason if handle else None)
if handle and handle.stop_requested and status == "failed":
status = "stopped"
reason = reason or "Stopped by admin."
finished_at = _now()
invocation.status = status
invocation.finished_at = finished_at
invocation.duration_ms = max(
0, int((finished_at - invocation.started_at).total_seconds() * 1000)
)
invocation.response_bytes = response_bytes
invocation.memory_peak_bytes = memory_peak_bytes
invocation.error_type = error_type
invocation.error_message = _safe_wasm_error_message(error_message)
invocation.stop_reason = reason
await update_wasm_invocation(invocation)
def get_current_wasm_invocations(
extension_id: str | None = None,
) -> list[WasmInvocation]:
with _wasm_invocation_lock:
invocations = []
for handle in _wasm_invocation_handles.values():
if extension_id and handle.invocation.extension_id != extension_id:
continue
invocation = handle.invocation.copy(deep=True)
if handle.stop_requested and invocation.status == "running":
invocation.status = "stopping"
invocation.stop_reason = handle.stop_reason
invocations.append(invocation)
return sorted(
invocations, key=lambda invocation: invocation.started_at, reverse=True
)
def _check_wasm_invocation_concurrency(
*,
extension_id: str,
user_id: str | None,
limits: dict[str, int] | None,
) -> None:
if not limits:
return
with _wasm_invocation_lock:
handles = list(_wasm_invocation_handles.values())
if _wasm_limit_exceeded(
limits["wasm_runtime_max_concurrent_invocations"],
len(handles) + 1,
):
raise ValueError("WASM runtime has too many active invocations.")
extension_invocations = sum(
1 for handle in handles if handle.invocation.extension_id == extension_id
)
if _wasm_limit_exceeded(
limits["wasm_runtime_max_concurrent_invocations_per_extension"],
extension_invocations + 1,
):
raise ValueError(
f"WASM extension '{extension_id}' has too many active invocations."
)
if not user_id:
return
user_invocations = sum(
1 for handle in handles if handle.invocation.user_id == user_id
)
if _wasm_limit_exceeded(
limits["wasm_runtime_max_concurrent_invocations_per_user"],
user_invocations + 1,
):
raise ValueError("WASM user has too many active invocations.")
def _check_wasm_host_call_limit(
invocation: WasmInvocation,
category: str,
limits: dict[str, int] | None,
) -> None:
if not limits:
return
if _wasm_limit_exceeded(
limits["wasm_runtime_max_host_calls"],
invocation.host_call_count,
):
raise ValueError("WASM host call limit exceeded.")
category_limits = {
"http": (
limits["wasm_runtime_max_http_calls"],
invocation.http_call_count,
),
"storage": (
limits["wasm_runtime_max_storage_calls"],
invocation.storage_call_count,
),
"wallet": (
limits["wasm_runtime_max_wallet_calls"],
invocation.wallet_call_count,
),
}
category_limit = category_limits.get(category)
if category_limit and _wasm_limit_exceeded(*category_limit):
raise ValueError(f"WASM {category} host call limit exceeded.")
def _wasm_limit_exceeded(limit: int, value: int) -> bool:
return limit > 0 and value > limit
async def get_wasm_invocation_history(
*,
extension_id: str | None = None,
status: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[WasmInvocation]:
await ensure_wasm_invocation_monitoring_ready()
return await get_wasm_invocations_crud(
extension_id=extension_id,
status=status,
limit=limit,
offset=offset,
)
async def get_wasm_invocation_summary(
*,
extension_id: str | None = None,
hours: int = 24,
) -> WasmInvocationStats:
await ensure_wasm_invocation_monitoring_ready()
since = _now() - timedelta(hours=max(1, min(hours, 24 * 30)))
return await get_wasm_invocation_stats_crud(
extension_id=extension_id,
since=since,
)
def _interrupt_wasm_invocation(handle: WasmInvocationHandle) -> bool:
if not handle.store or not handle.engine:
return False
try:
handle.store.set_epoch_deadline(1)
handle.engine.increment_epoch()
return True
except Exception as exc:
logger.warning(
f"Failed to interrupt WASM invocation '{handle.invocation.id}': {exc}"
)
return False
def _wasm_host_call_category(method_id: str) -> str:
if method_id.startswith("http.") or method_id.startswith("extension.api."):
return "http"
if method_id.startswith("storage."):
return "storage"
if method_id.startswith("wallet."):
return "wallet"
return "host"
def _safe_wasm_invocation_context(context: dict) -> dict:
safe_context: dict = {}
for key, value in context.items():
if not isinstance(key, str):
continue
if value is None or isinstance(value, (bool, int, float)):
safe_context[key[:64]] = value
elif isinstance(value, str):
safe_context[key[:64]] = value[:256]
return safe_context
def _safe_wasm_error_message(message: str | None) -> str | None:
if not message:
return None
safe_message = message[:500]
redactions = [
(
r"(?i)(api[-_ ]?key|token|authorization|password|secret|preimage)"
r"\s*[:=]\s*[^\s,;]+",
r"\1=[redacted]",
),
(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer [redacted]"),
(r"\b[a-fA-F0-9]{64}\b", "[redacted-hex]"),
]
for pattern, replacement in redactions:
safe_message = re.sub(pattern, replacement, safe_message)
return safe_message
def _now() -> datetime:
return datetime.now(timezone.utc)
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
@@ -113,6 +656,8 @@ async def activate_extension(ext: Extension):
async def deactivate_extension(ext_id: str):
if is_wasm_extension_id(ext_id):
await stop_wasm_extension_invocations(ext_id, reason="Extension deactivated.")
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
await stop_extension_background_work(ext_id)
+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:
+507 -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,17 +19,30 @@ 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,
UserExtension,
UserExtensionInfo,
WasmInvocation,
WasmInvocationStats,
WasmRuntimeLimitsInfo,
WasmRuntimeLimitsUpdate,
wasm_extension_icon_url,
)
from lnbits.core.models.users import Account, AccountId
@@ -36,10 +50,17 @@ from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.services.extensions import (
activate_extension,
deactivate_extension,
get_current_wasm_invocations,
get_valid_extension,
get_valid_extensions,
get_wasm_invocation_history,
get_wasm_invocation_summary,
install_extension,
resolve_wasm_runtime_limits,
stop_wasm_invocation,
uninstall_extension,
update_wasm_extension_runtime_limits,
validate_wasm_runtime_limit_overrides,
)
from lnbits.core.wasm_ext.api.permissions import validate_extension_permissions
from lnbits.db import Page
@@ -68,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):
@@ -132,6 +156,106 @@ async def api_install_extension(data: CreateExtension):
) from exc
@extension_router.get(
"/wasm/invocations/current",
dependencies=[Depends(check_admin)],
)
async def api_get_current_wasm_invocations(
extension_id: str | None = None,
) -> list[WasmInvocation]:
return get_current_wasm_invocations(extension_id=extension_id)
@extension_router.get(
"/wasm/invocations",
dependencies=[Depends(check_admin)],
)
async def api_get_wasm_invocations(
extension_id: str | None = None,
status: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[WasmInvocation]:
return await get_wasm_invocation_history(
extension_id=extension_id,
status=status,
limit=limit,
offset=offset,
)
@extension_router.get(
"/wasm/invocations/stats",
dependencies=[Depends(check_admin)],
)
async def api_get_wasm_invocation_stats(
extension_id: str | None = None,
hours: int = 24,
) -> WasmInvocationStats:
return await get_wasm_invocation_summary(extension_id=extension_id, hours=hours)
@extension_router.post(
"/wasm/invocations/{invocation_id}/stop",
dependencies=[Depends(check_admin)],
)
async def api_stop_wasm_invocation(invocation_id: str) -> SimpleStatus:
await stop_wasm_invocation(invocation_id, reason="Stopped by admin.")
return SimpleStatus(success=True, message="WASM invocation stop requested.")
@extension_router.get(
"/wasm/runtime-limits/extensions",
dependencies=[Depends(check_admin)],
)
async def api_get_wasm_runtime_limit_extensions() -> list[WasmRuntimeLimitsInfo]:
installed_extensions = await get_installed_extensions()
return [
WasmRuntimeLimitsInfo(
id=extension.id,
name=extension.name,
active=extension.active,
wasm_runtime_limits=validate_wasm_runtime_limit_overrides(
extension.wasm_runtime_limits,
strict=False,
),
effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension),
)
for extension in installed_extensions
if extension.is_wasm
]
@extension_router.put(
"/wasm/runtime-limits/{ext_id}",
dependencies=[Depends(check_admin)],
)
async def api_update_wasm_runtime_limits(
ext_id: str,
data: WasmRuntimeLimitsUpdate,
) -> WasmRuntimeLimitsInfo:
try:
wasm_runtime_limits = await update_wasm_extension_runtime_limits(
ext_id, data.limits
)
extension = await get_installed_extension(ext_id)
if not extension:
raise ValueError(f"Extension '{ext_id}' is not installed.")
extension.wasm_runtime_limits = wasm_runtime_limits
return WasmRuntimeLimitsInfo(
id=extension.id,
name=extension.name,
active=extension.active,
wasm_runtime_limits=wasm_runtime_limits,
effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension),
)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(exc),
) from exc
@extension_router.get("/{ext_id}/details")
async def api_extension_details(
ext_id: str,
@@ -255,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:
@@ -672,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
)
+6 -1
View File
@@ -189,6 +189,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks)
@generic_router.get("/admin", dependencies=admin_ui_checks)
@generic_router.get("/admin/extensions/wasm", dependencies=admin_ui_checks)
@generic_router.get("/admin/extensions/wasm/limits", dependencies=admin_ui_checks)
@generic_router.get("/admin/extensions/wasm/{ext_id}", dependencies=admin_ui_checks)
@generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
)
@@ -196,7 +199,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request, user: User = Depends(check_user_exists)
request: Request,
ext_id: str | None = None,
user: User = Depends(check_user_exists),
) -> HTMLResponse:
return template_renderer().TemplateResponse(
request,
+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)
+423 -30
View File
@@ -11,12 +11,22 @@ from lnbits.helpers import sha256s
from ..client.extensions import send_extension_api_request
from ..storage.crud import (
OWNER_ID_FIELD,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
storage_set_row,
)
from .background_payments import (
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
background_payment_extra,
invoice_amount_msat,
)
from .models import (
CreateInvoicePublicRequest,
CreateInvoiceRequest,
@@ -31,23 +41,31 @@ from .models import (
NowResponse,
PayInvoiceRequest,
PayInvoiceResponse,
PayLnurlRequest,
RandomIdRequest,
RandomIdResponse,
StorageAppendPublicRequest,
StorageAppendPublicResponse,
StorageDeleteRequest,
StorageDeleteResponse,
StorageGetRequest,
StorageGetResponse,
StoragePaginatedRequest,
StoragePaginatedResponse,
StoragePublicPaginatedRequest,
StorageSetRequest,
StorageSetResponse,
UserWalletSummary,
WalletBalanceRequest,
WalletBalanceResponse,
WebsocketPublishRequest,
WebsocketPublishResponse,
)
from .registry import extension_api_method
from .websockets import scoped_websocket_item_id
logger = logging.getLogger("lnbits.extensions")
PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
class ExtensionHostAPI:
@@ -60,6 +78,8 @@ class ExtensionHostAPI:
access_token: str | None = None,
context: str = "user",
owner_id: str | None = None,
invocation_id: str | None = None,
runtime_limits: dict[str, int] | None = None,
) -> None:
self.extension_id = extension_id
self.permissions, self.permission_policies = self._permission_data(permissions)
@@ -67,9 +87,15 @@ class ExtensionHostAPI:
self.access_token = access_token
self.context = context
self.owner_id = sha256s(user_id) if user_id else owner_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",
@@ -103,7 +129,7 @@ class ExtensionHostAPI:
async def storage_get_public(
self, request: StorageGetRequest
) -> StorageGetResponse:
public_fields = self._public_storage_fields(request.table)
public_fields = self._public_storage_policy(request.table)["public_fields"]
row = await storage_get_public_row(self.extension_id, request.table, request.id)
if not row:
return StorageGetResponse()
@@ -115,6 +141,45 @@ class ExtensionHostAPI:
# todo: check public fields filtering
return StorageGetResponse(data_json=json.dumps(public_row))
@extension_api_method(
method_id="storage.append_public",
namespace="storage",
name="Append public storage row",
host_name="storage_append_public",
sdk_name="appendPublic",
description="Append one public row to an extension storage table.",
required_permission="ext.storage.append_public",
require_auth=False,
)
async def storage_append_public(
self, request: StorageAppendPublicRequest
) -> StorageAppendPublicResponse:
policy, owner_id = await self._public_storage_append_policy(
request.table, request.source_id
)
data = dict(request.data)
self._validate_public_append_data(policy, data)
source_id_field = policy["source_id_field"]
current_rows = await storage_count_rows(
self.extension_id,
request.table,
{source_id_field: request.source_id},
owner_id=owner_id,
)
if current_rows >= policy["max_rows_per_source"]:
raise PermissionError(
f"Public storage append limit reached for '{request.table}'."
)
row_id = await storage_append_public_row(
self.extension_id,
request.table,
{**data, source_id_field: request.source_id},
owner_id,
)
return StorageAppendPublicResponse(id=row_id)
@extension_api_method(
method_id="storage.set",
namespace="storage",
@@ -164,6 +229,55 @@ class ExtensionHostAPI:
total=page["total"],
)
@extension_api_method(
method_id="storage.get_public_paginated",
namespace="storage",
name="Get paginated public storage rows",
host_name="storage_get_public_paginated",
sdk_name="getPublicPaginated",
description="Get filtered, searched, sorted, paginated public storage rows.",
required_permission="ext.storage.read_public",
require_auth=False,
)
async def storage_get_public_paginated(
self, request: StoragePublicPaginatedRequest
) -> StoragePaginatedResponse:
policy = self._public_storage_policy(request.table)
public_fields = policy["public_fields"]
source_id_field = policy["source_id_field"]
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public paginated storage reads require a source ID field policy."
)
filters = self._public_storage_paginated_filters(
request, public_fields, source_id_field
)
page = await storage_get_public_paginated_rows(
self.extension_id,
request.table,
filters,
search=request.search,
search_fields=request.search_fields,
sort_by=request.sort_by,
descending=request.descending,
limit=request.limit,
offset=request.offset,
)
return StoragePaginatedResponse(
rows_json=json.dumps(
[
{
field_name: value
for field_name, value in row.items()
if field_name in public_fields
}
for row in page["data"]
]
),
total=page["total"],
)
@extension_api_method(
method_id="storage.delete",
namespace="storage",
@@ -185,6 +299,25 @@ class ExtensionHostAPI:
)
return StorageDeleteResponse()
@extension_api_method(
method_id="websocket.publish",
namespace="websocket",
name="Publish websocket message",
host_name="websocket_publish",
sdk_name="publish",
description="Publish a JSON message on an extension-local websocket channel.",
required_permission="websocket.publish",
require_auth=False,
)
async def websocket_publish(
self, request: WebsocketPublishRequest
) -> WebsocketPublishResponse:
from lnbits.core.services import websocket_manager
item_id = scoped_websocket_item_id(self.extension_id, request.item_id)
await websocket_manager.send(item_id, request.data_json)
return WebsocketPublishResponse()
@extension_api_method(
method_id="wallet.create_invoice",
namespace="wallet",
@@ -358,7 +491,6 @@ 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
@@ -367,38 +499,136 @@ class ExtensionHostAPI:
from lnbits.core.services.payments import pay_invoice
from lnbits.exceptions import PaymentError
if not self.user_id:
raise PermissionError(
"Paying an invoice requires an authenticated user context."
)
wallet = await get_wallet(request.wallet_id)
if wallet is None or wallet.user != self.user_id:
if wallet is None:
raise PermissionError("Paying invoices from this wallet is not allowed.")
try:
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,
)
except (PaymentError, ValueError) as exc:
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 = 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,
)
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",
@@ -413,7 +643,15 @@ class ExtensionHostAPI:
from ..client.http import send_extension_http_request
policies = self.permission_policies.get("http.request") or []
return await send_extension_http_request(self.extension_id, policies, request)
return await send_extension_http_request(
self.extension_id,
policies,
request,
timeout_ms=self.runtime_limits.get("wasm_runtime_http_timeout_ms"),
max_response_bytes=self.runtime_limits.get(
"wasm_runtime_max_http_response_bytes"
),
)
@extension_api_method(
method_id="extension.api.request",
@@ -434,6 +672,10 @@ class ExtensionHostAPI:
self.user_id,
self.access_token,
request,
timeout_ms=self.runtime_limits.get("wasm_runtime_http_timeout_ms"),
max_response_bytes=self.runtime_limits.get(
"wasm_runtime_max_http_response_bytes"
),
)
@extension_api_method(
@@ -505,7 +747,7 @@ class ExtensionHostAPI:
return permission_ids, policies
def _public_storage_fields(self, table: str) -> set[str]:
def _public_storage_policy(self, table: str) -> dict[str, Any]:
tables = self.permission_policies.get("ext.storage.read_public")
if not isinstance(tables, list) or not tables:
raise PermissionError(
@@ -525,10 +767,148 @@ class ExtensionHostAPI:
raise PermissionError(
f"Public storage table '{table}' has no valid public fields."
)
return set(public_fields)
source_id_field = table_policy.get("source_id_field")
if source_id_field is not None and (
not isinstance(source_id_field, str) or not source_id_field
):
raise PermissionError(
f"Public storage table '{table}' has no valid source ID field."
)
return {
"public_fields": set(public_fields),
"source_id_field": source_id_field,
}
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
def _validate_public_storage_query_fields(
self,
request: StoragePaginatedRequest,
public_fields: set[str],
allowed_private_fields: set[str] | None = None,
) -> None:
allowed_private_fields = allowed_private_fields or set()
query_fields = set(request.filters)
query_fields.update(request.search_fields)
if request.sort_by:
query_fields.add(request.sort_by)
private_fields = sorted(query_fields - public_fields - allowed_private_fields)
if private_fields:
raise PermissionError(
"Public storage query uses non-public fields: "
+ ", ".join(private_fields)
)
def _public_storage_paginated_filters(
self,
request: StoragePublicPaginatedRequest,
public_fields: set[str],
source_id_field: str,
) -> dict[str, Any]:
self._validate_public_storage_query_fields(
request, public_fields, {source_id_field}
)
filters = dict(request.filters)
requested_source_id = filters.get(source_id_field)
if requested_source_id is not None and requested_source_id != request.source_id:
raise PermissionError(
"Public storage source filter does not match source_id."
)
filters[source_id_field] = request.source_id
return filters
async def _public_storage_append_policy(
self, table: str, source_id: str
) -> tuple[dict[str, Any], str]:
policies = self.permission_policies.get("ext.storage.append_public")
if not isinstance(policies, list) or not policies:
raise PermissionError(
"Public storage appends require policies for "
"'ext.storage.append_public'."
)
source_not_found = False
for raw_policy in policies:
policy = self._normalize_public_storage_append_policy(raw_policy)
if policy["table"] != table:
continue
owner_id = await storage_get_row_owner_id(
self.extension_id,
policy["source_table"],
source_id,
)
if not owner_id:
source_not_found = True
continue
return policy, owner_id
if source_not_found:
raise PermissionError("Public storage append source was not found.")
raise PermissionError(f"Storage table '{table}' is not publicly appendable.")
def _normalize_public_storage_append_policy(self, policy: Any) -> dict[str, Any]:
if not isinstance(policy, dict):
raise PermissionError("Public storage append policies must be objects.")
table = policy.get("table")
source_table = policy.get("source_table")
source_id_field = policy.get("source_id_field")
allowed_fields = policy.get("allowed_fields")
max_rows_per_source = policy.get(
"max_rows_per_source", PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
)
if not isinstance(table, str) or not table:
raise PermissionError("Public storage append requires a table policy.")
if not isinstance(source_table, str) or not source_table:
raise PermissionError(
"Public storage append requires a source table policy."
)
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public storage append requires a source ID field policy."
)
if source_id_field == "id":
raise PermissionError("Public storage append source field cannot be 'id'.")
if (
not isinstance(allowed_fields, list)
or not all(isinstance(field, str) and field for field in allowed_fields)
or "id" in allowed_fields
or OWNER_ID_FIELD in allowed_fields
or source_id_field in allowed_fields
):
raise PermissionError(
"Public storage append requires valid allowed fields."
)
if (
isinstance(max_rows_per_source, bool)
or not isinstance(max_rows_per_source, int)
or max_rows_per_source <= 0
):
raise PermissionError(
"Public storage append requires a positive row limit."
)
return {
"table": table,
"source_table": source_table,
"source_id_field": source_id_field,
"allowed_fields": set(allowed_fields),
"max_rows_per_source": max_rows_per_source,
}
def _validate_public_append_data(
self, policy: dict[str, Any], data: dict[str, Any]
) -> None:
if not isinstance(data, dict):
raise PermissionError("Public storage append data must be an object.")
unknown_fields = sorted(set(data) - policy["allowed_fields"])
if unknown_fields:
raise PermissionError(
"Public storage append contains disallowed fields: "
+ ", ".join(unknown_fields)
)
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
policies = self.permission_policies.get("wallet.create_invoice_public")
if not isinstance(policies, list) or not policies:
@@ -580,3 +960,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)
+80
View File
@@ -67,6 +67,23 @@ class StorageSetResponse(BaseModel):
ok: bool = True
class StorageAppendPublicRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
source_id: str = Field(..., min_length=1, max_length=512)
data: dict[str, Any] = Field(default_factory=dict)
@root_validator(pre=True)
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
data_json = values.get("data_json")
if data_json is not None and "data" not in values:
values["data"] = json.loads(data_json)
return values
class StorageAppendPublicResponse(BaseModel):
id: str
class StoragePaginatedRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
filters: dict[str, Any] = Field(default_factory=dict)
@@ -92,11 +109,47 @@ class StoragePaginatedRequest(BaseModel):
return values
class StoragePublicPaginatedRequest(StoragePaginatedRequest):
source_id: str = Field(..., min_length=1, max_length=512)
class StoragePaginatedResponse(BaseModel):
rows_json: str = "[]"
total: int = 0
class WebsocketPublishRequest(BaseModel):
item_id: str = Field(..., min_length=1, max_length=128)
data: Any = Field(default_factory=dict)
@root_validator(pre=True)
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
data_json = values.get("data_json")
if data_json is not None and "data" not in values:
values["data"] = json.loads(data_json)
return values
@root_validator
def validate_data_size(cls, values: dict[str, Any]) -> dict[str, Any]:
data = values.get("data")
try:
encoded = json.dumps(data, separators=(",", ":"))
except TypeError as exc:
raise ValueError("websocket data must be JSON serializable.") from exc
if len(encoded.encode()) > 65536:
raise ValueError("websocket data must not exceed 65536 bytes.")
values["data"] = data
return values
@property
def data_json(self) -> str:
return json.dumps(self.data, separators=(",", ":"))
class WebsocketPublishResponse(BaseModel):
sent: bool = True
class StorageDeleteRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
id: str = Field(..., min_length=1, max_length=512)
@@ -181,6 +234,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 +346,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
+284 -10
View File
@@ -3,6 +3,21 @@ from typing import Any
from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension
from lnbits.core.wasm_ext.api.registry import extension_api_permission_ids
from lnbits.core.wasm_ext.client.http import _request_origin
from lnbits.core.wasm_ext.wasm.config import (
WasmExtensionConfig,
parse_wasm_extension_config,
)
_POLICY_AWARE_PERMISSION_IDS = {
"ext.storage.append_public",
"ext.storage.read_public",
"extension.api.request",
"http.request",
"wallet.create_invoice_public",
}
_OWNER_ID_FIELD = "__lnbits_owner_id__"
_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
def validate_extension_permissions(
@@ -20,7 +35,7 @@ def validate_extension_permissions(
unknown_ids.append(permission.id)
if strict:
continue
normalized_permissions.append(permission.copy(update={"label": None}))
normalized_permissions.append(permission.copy())
if unknown_ids and strict:
raise ValueError(
@@ -34,14 +49,17 @@ def validate_extension_permissions(
def validate_wasm_extension_permissions(
ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None,
extension_config: dict[str, Any],
extension_config: dict[str, Any] | WasmExtensionConfig,
) -> list[ExtensionPermission]:
if extension_config.get("extension_type") != "wasm":
if isinstance(extension_config, WasmExtensionConfig):
config = extension_config
elif extension_config.get("extension_type") != "wasm":
return []
else:
config = parse_wasm_extension_config(ext_info.id, extension_config)
requested_permissions = validate_extension_permissions(
ext_info.id,
ExtensionPermission.list_from_config(extension_config),
ext_info.id, config.permissions
)
if not requested_permissions:
return []
@@ -49,11 +67,267 @@ def validate_wasm_extension_permissions(
if granted_permissions is None:
raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
requested_ids = {permission.id for permission in requested_permissions}
granted_ids = {permission.id for permission in granted_permissions}
if requested_ids != granted_ids:
granted_permissions = validate_extension_permissions(
ext_info.id,
granted_permissions,
)
requested_by_id = _permission_index(ext_info.id, requested_permissions, "requested")
granted_by_id = _permission_index(ext_info.id, granted_permissions, "granted")
extra_granted_ids = sorted(set(granted_by_id) - set(requested_by_id))
if extra_granted_ids:
raise ValueError(
f"Extension '{ext_info.id}' was not granted all requested permissions."
f"Extension '{ext_info.id}' was granted unrequested permissions: "
+ ", ".join(extra_granted_ids)
)
return requested_permissions
effective_permissions: list[ExtensionPermission] = []
for permission_id, granted_permission in granted_by_id.items():
requested_permission = requested_by_id[permission_id]
if not _permission_grant_is_subset(requested_permission, granted_permission):
raise ValueError(
f"Extension '{ext_info.id}' was granted broader policies for "
f"permission '{permission_id}'."
)
effective_permissions.append(
requested_permission.copy(update={"policies": granted_permission.policies})
)
return effective_permissions
def _permission_index(
ext_id: str,
permissions: Iterable[ExtensionPermission],
source: str,
) -> dict[str, ExtensionPermission]:
indexed: dict[str, ExtensionPermission] = {}
duplicate_ids: list[str] = []
for permission in permissions:
if permission.id in indexed:
duplicate_ids.append(permission.id)
continue
indexed[permission.id] = permission
if duplicate_ids:
raise ValueError(
f"Extension '{ext_id}' has duplicate {source} permissions: "
+ ", ".join(sorted(set(duplicate_ids)))
)
return indexed
def _permission_grant_is_subset(
requested: ExtensionPermission,
granted: ExtensionPermission,
) -> bool:
if requested.id != granted.id:
return False
if requested.id not in _POLICY_AWARE_PERMISSION_IDS:
return True
if requested.id == "http.request":
return _http_request_grant_is_subset(requested.policies, granted.policies)
if requested.id == "extension.api.request":
return _extension_api_grant_is_subset(requested.policies, granted.policies)
if requested.id == "ext.storage.append_public":
return _public_storage_append_grant_is_subset(
requested.policies, granted.policies
)
if requested.id == "ext.storage.read_public":
return _public_storage_grant_is_subset(requested.policies, granted.policies)
if requested.id == "wallet.create_invoice_public":
return _public_invoice_grant_is_subset(requested.policies, granted.policies)
return False
def _policy_list(policies: list[Any] | None) -> list[Any]:
return policies if isinstance(policies, list) else []
def _http_request_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
return _http_origins(granted_policies).issubset(_http_origins(requested_policies))
def _http_origins(policies: list[Any] | None) -> set[str]:
origins: set[str] = set()
for policy in _policy_list(policies):
host = policy.get("host") if isinstance(policy, dict) else policy
if not isinstance(host, str) or not host:
continue
try:
origins.add(_request_origin(host))
except PermissionError:
continue
return origins
def _extension_api_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
requested_targets = _extension_api_targets(requested_policies)
granted_targets = _extension_api_targets(granted_policies)
for extension_id, granted_access in granted_targets.items():
requested_access = requested_targets.get(extension_id)
if requested_access is None or not granted_access.issubset(requested_access):
return False
return True
def _extension_api_targets(policies: list[Any] | None) -> dict[str, set[str]]:
targets: dict[str, set[str]] = {}
for policy in _policy_list(policies):
extension_id: str | None = None
access: list[Any] = []
if isinstance(policy, str):
extension_id = policy
access = ["read"]
elif isinstance(policy, dict):
raw_extension_id = policy.get("id")
raw_access = policy.get("access")
if isinstance(raw_extension_id, str) and isinstance(raw_access, list):
extension_id = raw_extension_id
access = raw_access
if not extension_id or extension_id in targets:
continue
clean_access = {
item
for item in access
if isinstance(item, str) and item in {"read", "write"}
}
if clean_access:
targets[extension_id] = clean_access
return targets
def _public_storage_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
requested_tables = _public_storage_tables(requested_policies)
granted_tables = _public_storage_tables(granted_policies)
for table_name, granted_policy in granted_tables.items():
requested_policy = requested_tables.get(table_name)
if requested_policy is None:
return False
if not granted_policy["public_fields"].issubset(
requested_policy["public_fields"]
):
return False
requested_source_id_field = requested_policy["source_id_field"]
if (
requested_source_id_field
and granted_policy["source_id_field"] != requested_source_id_field
):
return False
return True
def _public_storage_tables(policies: list[Any] | None) -> dict[str, dict[str, Any]]:
tables: dict[str, dict[str, Any]] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table_name = policy.get("table_name")
public_fields = policy.get("public_fields")
source_id_field = policy.get("source_id_field")
if (
not isinstance(table_name, str)
or table_name in tables
or not isinstance(public_fields, list)
or (
source_id_field is not None
and (not isinstance(source_id_field, str) or not source_id_field)
)
):
continue
fields = {field for field in public_fields if isinstance(field, str) and field}
if fields:
tables[table_name] = {
"public_fields": fields,
"source_id_field": source_id_field,
}
return tables
def _public_storage_append_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
requested_targets = _public_storage_append_targets(requested_policies)
granted_targets = _public_storage_append_targets(granted_policies)
for target, granted_policy in granted_targets.items():
requested_policy = requested_targets.get(target)
if requested_policy is None:
return False
if not granted_policy["allowed_fields"].issubset(
requested_policy["allowed_fields"]
):
return False
if (
granted_policy["max_rows_per_source"]
> requested_policy["max_rows_per_source"]
):
return False
return True
def _public_storage_append_targets(policies: list[Any] | None) -> dict[Any, dict]:
targets: dict[Any, dict] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table = policy.get("table")
source_table = policy.get("source_table")
source_id_field = policy.get("source_id_field")
allowed_fields = policy.get("allowed_fields")
max_rows_per_source = policy.get(
"max_rows_per_source", _PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
)
if (
not isinstance(table, str)
or not table
or not isinstance(source_table, str)
or not source_table
or not isinstance(source_id_field, str)
or not source_id_field
or source_id_field == "id"
or not isinstance(allowed_fields, list)
or isinstance(max_rows_per_source, bool)
or not isinstance(max_rows_per_source, int)
or max_rows_per_source <= 0
):
continue
fields = {field for field in allowed_fields if isinstance(field, str) and field}
if "id" in fields or _OWNER_ID_FIELD in fields or source_id_field in fields:
continue
targets[(table, source_table, source_id_field)] = {
"allowed_fields": fields,
"max_rows_per_source": max_rows_per_source,
}
return targets
def _public_invoice_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
return _public_invoice_sources(granted_policies).issubset(
_public_invoice_sources(requested_policies)
)
def _public_invoice_sources(policies: list[Any] | None) -> set[tuple[str, str]]:
sources: set[tuple[str, str]] = set()
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table = policy.get("table")
wallet_field = policy.get("wallet_field")
if isinstance(table, str) and table and isinstance(wallet_field, str):
sources.add((table, wallet_field))
return sources
+7 -1
View File
@@ -10,7 +10,13 @@ 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",
"websocket.subscribe",
}
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
+3
View File
@@ -31,6 +31,9 @@ class ExtensionAPIHost:
payload: Mapping[str, Any] | BaseModel | None = None,
) -> dict[str, Any]:
method = self._require_method(host_name)
from lnbits.core.services.extensions import record_wasm_invocation_host_call
record_wasm_invocation_host_call(self.api.invocation_id, method.method_id)
request = self._request_model(method, payload)
handler = _resolve_attr_path(self.api, method.python_name)
response = handler(request)
+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,
}
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import re
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
_LOCAL_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$")
def scoped_websocket_item_id(extension_id: str, item_id: str) -> str:
if not _EXTENSION_ID_RE.fullmatch(extension_id):
raise ValueError("Extension websocket namespace is invalid.")
if not _LOCAL_ITEM_ID_RE.fullmatch(item_id):
raise ValueError(
"Extension websocket item ID must be 1-128 characters and contain "
"only letters, numbers, colon, underscore, or dash."
)
return f"ext:{extension_id}:{item_id}"
+27 -4
View File
@@ -35,6 +35,9 @@ async def send_extension_api_request(
user_id: str | None,
access_token: str | None,
request: ExtensionApiRequest,
*,
timeout_ms: int | None = None,
max_response_bytes: int | None = None,
) -> HttpResponse:
if not user_id:
raise PermissionError("Extension API requests require authentication.")
@@ -55,7 +58,7 @@ async def send_extension_api_request(
try:
async with httpx.AsyncClient(
follow_redirects=False,
timeout=EXTENSION_API_TIMEOUT_SECONDS,
timeout=_timeout_seconds(timeout_ms, EXTENSION_API_TIMEOUT_SECONDS),
trust_env=False,
) as client:
async with client.stream(
@@ -64,7 +67,10 @@ async def send_extension_api_request(
headers={"Authorization": f"Bearer {access_token}"},
content=body,
) as response:
response_body = await _read_limited_response(response)
response_body = await _read_limited_response(
response,
max_response_bytes=max_response_bytes,
)
return HttpResponse(
status_code=response.status_code,
headers=_response_headers(dict(response.headers)),
@@ -177,17 +183,34 @@ def _extension_api_path(path: str) -> str:
return urlunsplit(("", "", parts.path, parts.query, ""))
async def _read_limited_response(response: httpx.Response) -> bytes:
async def _read_limited_response(
response: httpx.Response,
*,
max_response_bytes: int | None = None,
) -> bytes:
limit = (
EXTENSION_API_MAX_RESPONSE_BYTES
if max_response_bytes is None
else max_response_bytes
)
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > EXTENSION_API_MAX_RESPONSE_BYTES:
if limit > 0 and size > limit:
raise ValueError("Extension API response is too large.")
chunks.append(chunk)
return b"".join(chunks)
def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None:
if timeout_ms is None:
return default
if timeout_ms <= 0:
return None
return timeout_ms / 1000
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
return {
key: value
+25 -4
View File
@@ -32,6 +32,9 @@ async def send_extension_http_request(
extension_id: str,
policies: list[Any],
request: HttpRequest,
*,
timeout_ms: int | None = None,
max_response_bytes: int | None = None,
) -> HttpResponse:
allowed_origins = _allowed_origins(policies)
origin = _request_origin(request.url)
@@ -49,7 +52,7 @@ async def send_extension_http_request(
try:
async with httpx.AsyncClient(
follow_redirects=False,
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
timeout=_timeout_seconds(timeout_ms, HTTP_REQUEST_TIMEOUT_SECONDS),
trust_env=False,
) as client:
async with client.stream(
@@ -58,7 +61,10 @@ async def send_extension_http_request(
headers=headers,
content=body,
) as response:
response_body = await _read_limited_response(response)
response_body = await _read_limited_response(
response,
max_response_bytes=max_response_bytes,
)
return HttpResponse(
status_code=response.status_code,
headers=_response_headers(dict(response.headers)),
@@ -164,17 +170,32 @@ def _request_headers(headers: dict[str, str]) -> dict[str, str]:
return clean
async def _read_limited_response(response: httpx.Response) -> bytes:
async def _read_limited_response(
response: httpx.Response,
*,
max_response_bytes: int | None = None,
) -> bytes:
limit = (
HTTP_MAX_RESPONSE_BYTES if max_response_bytes is None else max_response_bytes
)
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > HTTP_MAX_RESPONSE_BYTES:
if limit > 0 and size > limit:
raise ValueError("HTTP response is too large.")
chunks.append(chunk)
return b"".join(chunks)
def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None:
if timeout_ms is None:
return default
if timeout_ms <= 0:
return None
return timeout_ms / 1000
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
return {
key: value
+111 -18
View File
@@ -2,32 +2,46 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Annotated, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from lnbits.core.models import Account
from lnbits.core.services.extensions import get_wasm_runtime_limits_for_extension
from lnbits.decorators import check_access_token, check_account_exists
from lnbits.settings import settings
from ..wasm.config import WasmAPIRouteConfig
from ..wasm.invoke import invoke_wasm_extension_export
from ..wasm.loader import WasmExtension
class WasmRequestBodyTooLargeError(ValueError):
pass
@dataclass(frozen=True)
class WasmRoutePayload:
data: dict[str, Any]
request_bytes: int | None
def register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_config in extension.config.get("api_routes") or []:
for route_config in extension.config.api_routes:
_add_wasm_extension_api_route(app, extension, route_config)
def _add_wasm_extension_api_route(
app: FastAPI,
extension: WasmExtension,
route_config: dict[str, Any],
route_config: WasmAPIRouteConfig,
) -> None:
method = _wasm_extension_api_method(extension, route_config.get("method"))
route_path = _wasm_extension_api_path(extension, route_config.get("path"))
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
path_params = route_config.get("path_params") or {}
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
method = _wasm_extension_api_method(extension, route_config.method)
route_path = _wasm_extension_api_path(extension, route_config.path)
export_name = _wasm_extension_api_export(extension, route_config.export)
path_params = route_config.path_params
auth = _wasm_extension_route_auth(extension, route_config.auth)
if _has_route(app, route_path, method):
return
@@ -38,14 +52,27 @@ def _add_wasm_extension_api_route(
access_token: str | None = None,
) -> dict[str, Any]:
try:
payload = await _read_api_payload(request, path_params)
limits = await get_wasm_runtime_limits_for_extension(extension.id)
payload = await _read_api_payload(
request,
path_params,
max_body_bytes=limits["wasm_runtime_max_request_bytes"],
)
return await invoke_wasm_extension_export(
extension.id,
export_name,
payload,
payload.data,
user=account,
access_token=access_token,
trigger_type="http",
method=request.method,
path=request.url.path,
request_id=request.headers.get("x-request-id"),
request_bytes=payload.request_bytes,
context_data={"origin": _request_origin(request)},
)
except WasmRequestBodyTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
@@ -79,22 +106,70 @@ def _add_wasm_extension_api_route(
async def _read_api_payload(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
*,
max_body_bytes: int,
) -> WasmRoutePayload:
payload = _read_api_path_params(request, path_params)
payload.update(_read_api_query_params(request))
request_bytes: int | None = None
if request.method in {"POST", "PUT", "PATCH"}:
payload.update(await _read_json_object(request))
return payload
body, request_bytes = await _read_json_object_with_size(
request,
max_body_bytes=max_body_bytes,
)
payload.update(body)
return WasmRoutePayload(payload, request_bytes)
async def _read_json_object(request: Request) -> dict[str, Any]:
body = await request.body()
async def _read_json_object(
request: Request,
*,
max_body_bytes: int | None = None,
) -> dict[str, Any]:
body, _ = await _read_json_object_with_size(
request,
max_body_bytes=(
settings.wasm_runtime_max_request_bytes
if max_body_bytes is None
else max_body_bytes
),
)
return body
async def _read_json_object_with_size(
request: Request,
*,
max_body_bytes: int,
) -> tuple[dict[str, Any], int]:
body = await _read_limited_body(request, max_body_bytes=max_body_bytes)
if not body:
return {}
return {}, 0
value = json.loads(body)
if not isinstance(value, dict):
raise TypeError("WASM extension API payload must be a JSON object.")
return value
return value, len(body)
async def _read_limited_body(request: Request, *, max_body_bytes: int) -> bytes:
content_length = _request_content_length(request)
if _wasm_request_too_large(content_length, max_body_bytes):
raise WasmRequestBodyTooLargeError(
f"WASM extension request is too large: {content_length} bytes."
)
chunks: list[bytes] = []
size = 0
async for chunk in request.stream():
if not chunk:
continue
size += len(chunk)
if _wasm_request_too_large(size, max_body_bytes):
raise WasmRequestBodyTooLargeError(
f"WASM extension request is too large: {size} bytes."
)
chunks.append(chunk)
return b"".join(chunks)
def _read_api_path_params(
@@ -112,14 +187,32 @@ def _read_api_query_params(request: Request) -> dict[str, Any]:
return {_snake_to_camel(key): value for key, value in request.query_params.items()}
def _request_content_length(request: Request) -> int | None:
content_length = request.headers.get("content-length")
if content_length and content_length.isdigit():
return int(content_length)
return None
def _wasm_request_too_large(size: int | None, max_body_bytes: int) -> bool:
return size is not None and max_body_bytes > 0 and size > max_body_bytes
def _request_origin(request: Request) -> str | None:
origin = request.headers.get("origin")
if not origin:
return None
return origin[:256]
def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str:
if not isinstance(export_name, str) or not export_name:
raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.")
for export in extension.exports:
if export.get("name") != export_name:
if export.name != export_name:
continue
if export.get("visibility") in {"public", "authenticated"}:
if export.visibility in {"public", "authenticated"}:
return export_name
raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.")
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
+1
View File
@@ -15,6 +15,7 @@ from .ui import register_wasm_extension_ui_routes
def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
loaded = load_wasm_extension(ext_id)
core_app_extra.wasm_extension_registry.require_available(loaded)
warm_wasm_extension(loaded)
mount_wasm_extension_static(app, loaded)
+16 -15
View File
@@ -17,6 +17,7 @@ from lnbits.decorators import (
from ..wasm.loader import WasmExtension
from .api import (
WasmRequestBodyTooLargeError,
_has_route,
_path_template_pattern,
_read_json_object,
@@ -37,13 +38,11 @@ from .security import (
def register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
_add_wasm_extension_frame_config_route(app, extension)
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
entrypoint = _wasm_extension_entrypoint(
extension, route_config.get("entrypoint")
)
for route_index, route_config in enumerate(extension.config.ui_routes):
route_path = _wasm_extension_ui_route_path(extension, route_config.path)
entrypoint = _wasm_extension_entrypoint(extension, route_config.entrypoint)
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
auth = _wasm_extension_route_auth(extension, route_config.auth)
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
_add_wasm_extension_wrapper_route(
app,
@@ -68,6 +67,8 @@ def _add_wasm_extension_frame_config_route(
) -> dict[str, Any]:
try:
body = await _read_json_object(request)
except WasmRequestBodyTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -188,13 +189,13 @@ def _wasm_extension_bridge_api_routes(
public: bool,
) -> list[dict[str, str]]:
routes: list[dict[str, str]] = []
for route_config in extension.config.get("api_routes") or []:
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
for route_config in extension.config.api_routes:
auth = _wasm_extension_route_auth(extension, route_config.auth)
if public and auth != "public":
continue
method = _wasm_extension_api_method(extension, route_config.get("method"))
path = _wasm_extension_api_path(extension, route_config.get("path"))
_wasm_extension_api_export(extension, route_config.get("export"))
method = _wasm_extension_api_method(extension, route_config.method)
path = _wasm_extension_api_path(extension, route_config.path)
_wasm_extension_api_export(extension, route_config.export)
routes.append(
{
"method": method,
@@ -216,16 +217,16 @@ def _match_wasm_extension_ui_route(
if not isinstance(path, str) or not path.startswith("/"):
raise HTTPException(status_code=404, detail="Not found")
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
for route_index, route_config in enumerate(extension.config.ui_routes):
route_path = _wasm_extension_ui_route_path(extension, route_config.path)
route_params = _path_template_params(route_path, path)
if route_params is None:
continue
return {
"frame_path": f"/ext-frame/{extension.id}/{route_index}",
"auth": _wasm_extension_route_auth(extension, route_config.get("auth")),
"path_params": route_config.get("path_params") or {},
"auth": _wasm_extension_route_auth(extension, route_config.auth),
"path_params": route_config.path_params,
"route_params": route_params,
}
+6
View File
@@ -1,7 +1,10 @@
from .crud import (
migrate_wasm_extension_database,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
@@ -10,8 +13,11 @@ from .crud import (
__all__ = [
"migrate_wasm_extension_database",
"storage_append_public_row",
"storage_count_rows",
"storage_delete_row",
"storage_get_paginated_rows",
"storage_get_public_paginated_rows",
"storage_get_public_row",
"storage_get_row",
"storage_get_row_owner_id",
+108 -9
View File
@@ -5,6 +5,7 @@ import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
from loguru import logger
@@ -12,7 +13,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 +77,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,22 +95,55 @@ 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)
async def storage_append_public_row(
ext_id: str,
table: str,
data: dict[str, Any],
owner_id: str,
) -> str:
row_id = uuid4().hex
await storage_set_row(ext_id, table, {**data, "id": row_id}, owner_id)
return row_id
async def storage_count_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
owner_id: str,
) -> int:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(database, table_schema, filters, None, [])
where_sql = _append_owner_where_sql(where_sql)
values[OWNER_ID_FIELD] = owner_id
query = f"""
SELECT COUNT(*) AS count FROM {_table_ref_for_schema(ext_id, table)}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
row = await conn.fetchone(query, values)
return int(row["count"]) if row else 0
async def storage_get_paginated_rows(
ext_id: str,
table: str,
@@ -117,7 +158,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 +181,51 @@ 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)
return {
"data": [_row_from_db(table_schema, row) for row in rows],
"total": int(count_row["count"]) if count_row else 0,
}
async def storage_get_public_paginated_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
search: str | None,
search_fields: list[str],
sort_by: str | None,
descending: bool,
limit: int,
offset: int,
) -> dict[str, Any]:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, table_schema, filters, search, search_fields
)
order_sql = _order_sql(table_schema, sort_by, descending)
count_values = dict(values)
values.update({"limit": min(limit, 1000), "offset": offset})
table_ref = _table_ref_for_schema(ext_id, table)
rows_query = f"""
SELECT * FROM {table_ref}
{where_sql}
{order_sql}
LIMIT :limit
OFFSET :offset
""" # noqa: S608
count_query = f"""
SELECT COUNT(*) AS count FROM {table_ref}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
@@ -403,14 +491,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 ""
+27 -4
View File
@@ -5,6 +5,8 @@ from typing import Any
from wasmtime import Config, Engine
from lnbits.settings import settings
from .loader import WasmExtension
@@ -12,19 +14,37 @@ def warm_wasm_extension(extension: WasmExtension) -> None:
_wasm_component(extension)
@lru_cache(maxsize=1)
def _wasm_engine() -> Any:
@lru_cache(maxsize=8)
def _wasm_engine(max_wasm_stack_bytes: int | None = None) -> Any:
config = Config()
config.wasm_component_model = True
config.epoch_interruption = True
config.consume_fuel = True
stack_limit = (
settings.wasm_runtime_max_wasm_stack_bytes
if max_wasm_stack_bytes is None
else max_wasm_stack_bytes
)
if stack_limit > 0:
config.max_wasm_stack = stack_limit
return Engine(config)
def _wasm_component(extension: WasmExtension) -> Any:
def _wasm_component(
extension: WasmExtension,
limits: dict[str, int] | None = None,
) -> Any:
stat = extension.module_path.stat()
max_wasm_stack_bytes = (
limits["wasm_runtime_max_wasm_stack_bytes"]
if limits
else settings.wasm_runtime_max_wasm_stack_bytes
)
return _cached_wasm_component(
str(extension.module_path),
stat.st_mtime_ns,
stat.st_size,
max_wasm_stack_bytes,
)
@@ -33,7 +53,10 @@ def _cached_wasm_component(
module_path: str,
mtime_ns: int,
size: int,
max_wasm_stack_bytes: int,
) -> Any:
from wasmtime import component
return component.Component.from_file(_wasm_engine(), module_path)
return component.Component.from_file(
_wasm_engine(max_wasm_stack_bytes), module_path
)
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import (
BaseModel,
Field,
StrictBool,
StrictStr,
ValidationError,
)
from lnbits.core.models.extensions import ExtensionPermission
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
class _StrictWasmModel(BaseModel):
class Config:
extra = "ignore"
allow_population_by_field_name = True
class WasmExtensionExport(_StrictWasmModel):
name: StrictStr
visibility: Literal["authenticated", "event", "public"]
class WasmRuntimeConfig(_StrictWasmModel):
module: StrictStr
wit: StrictStr | None = None
world: StrictStr = ""
exports: list[WasmExtensionExport] = Field(default_factory=list)
class WasmUIConfig(_StrictWasmModel):
entrypoint: StrictStr | None = None
sandbox: StrictBool | None = None
class WasmSDKConfig(_StrictWasmModel):
frontend_js: StrictStr | None = None
class WasmUIRouteConfig(_StrictWasmModel):
path: StrictStr
entrypoint: StrictStr
auth: Literal["public", "user"]
path_params: dict[str, StrictStr] = Field(default_factory=dict)
class WasmAPIRouteConfig(_StrictWasmModel):
method: Literal["DELETE", "GET", "PATCH", "POST", "PUT"]
path: StrictStr
export: StrictStr
auth: Literal["public", "user"]
path_params: dict[str, StrictStr] = Field(default_factory=dict)
class WasmEventsConfig(_StrictWasmModel):
on_invoice_paid: StrictStr | None = Field(None, alias="onInvoicePaid")
class WasmExtensionConfig(_StrictWasmModel):
id: StrictStr
name: StrictStr
short_description: StrictStr
tile: StrictStr | None = None
version: StrictStr
min_lnbits_version: StrictStr | None = None
max_lnbits_version: StrictStr | None = None
extension_type: Literal["wasm"]
wasm: WasmRuntimeConfig
events: WasmEventsConfig = Field(
default_factory=lambda: WasmEventsConfig.parse_obj({})
)
ui: WasmUIConfig | None = None
sdk: WasmSDKConfig | None = None
ui_routes: list[WasmUIRouteConfig] = Field(default_factory=list)
api_routes: list[WasmAPIRouteConfig] = Field(default_factory=list)
permissions: list[ExtensionPermission] = Field(default_factory=list)
def parse_wasm_extension_config(
ext_id: str,
config: dict[str, Any],
) -> WasmExtensionConfig:
validate_wasm_extension_config_id(ext_id, config)
try:
return WasmExtensionConfig.parse_obj(config)
except ValidationError as exc:
raise ValueError(
f"Invalid WASM extension config for '{ext_id}': {exc}"
) from exc
def validate_wasm_extension_config_id(
ext_id: str,
config: dict[str, Any] | WasmExtensionConfig,
) -> str:
if not _EXTENSION_ID_RE.fullmatch(ext_id):
raise ValueError(f"Invalid WASM extension id '{ext_id}'.")
config_id = (
config.id if isinstance(config, WasmExtensionConfig) else config.get("id")
)
if not isinstance(config_id, str) or not config_id:
raise ValueError(f"WASM extension '{ext_id}' config must define id.")
if config_id != ext_id:
raise ValueError(
f"WASM extension id mismatch: installed as '{ext_id}' "
f"but config declares '{config_id}'."
)
return config_id
+153 -19
View File
@@ -1,22 +1,56 @@
import json
from collections.abc import Iterable
from typing import Any
from loguru import logger
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
@@ -34,7 +68,12 @@ async def dispatch_wasm_invoice_paid(payment: Any) -> None:
export_name,
_wasm_invoice_paid_payload(payment),
context="event",
owner_id=await _wasm_invoice_paid_owner_id(extension, payment),
owner_id=owner_id,
trigger_type="event",
event_type="invoice_paid",
wallet_id=payment.wallet_id,
payment_hash=payment.payment_hash,
checking_id=payment.checking_id,
)
except Exception as exc:
logger.warning(
@@ -43,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
@@ -54,7 +173,7 @@ def _payment_extension_id(payment: Any) -> str | None:
async def _wasm_invoice_paid_owner_id(extension: Any, payment: Any) -> str | None:
source_id = _payment_source_id(payment)
source_tables = _wasm_public_invoice_source_tables(extension.config)
source_tables = await _wasm_public_invoice_source_tables(extension.id)
if not source_id or not source_tables:
return None
@@ -71,14 +190,31 @@ def _payment_source_id(payment: Any) -> str | None:
return source_id if isinstance(source_id, str) and source_id else None
def _wasm_public_invoice_source_tables(config: dict[str, Any]) -> list[str]:
permissions = config.get("permissions") or []
async def _wasm_public_invoice_source_tables(extension_id: str) -> list[str]:
installed_extension = await get_installed_extension(extension_id)
if not installed_extension:
return []
return _wasm_public_invoice_source_tables_from_permissions(
installed_extension.permissions
)
def _wasm_public_invoice_source_tables_from_permissions(
permissions: Iterable[Any],
) -> list[str]:
for permission in permissions:
if not isinstance(permission, dict):
permission_id = (
permission.get("id")
if isinstance(permission, dict)
else getattr(permission, "id", None)
)
if permission_id != "wallet.create_invoice_public":
continue
if permission.get("id") != "wallet.create_invoice_public":
continue
policies = permission.get("policies")
policies = (
permission.get("policies")
if isinstance(permission, dict)
else getattr(permission, "policies", None)
)
if not isinstance(policies, list):
return []
return [
@@ -91,16 +227,14 @@ def _wasm_public_invoice_source_tables(config: dict[str, Any]) -> list[str]:
return []
def _wasm_invoice_paid_export(config: dict[str, Any]) -> str | None:
events = config.get("events") or {}
export_name = events.get("onInvoicePaid")
return export_name if isinstance(export_name, str) and export_name else None
def _wasm_invoice_paid_export(config: Any) -> str | None:
return config.events.on_invoice_paid
def _is_wasm_event_export(extension: Any, export_name: str) -> bool:
for export in extension.exports:
if export.get("name") == export_name:
return export.get("visibility") == "event"
if export.name == export_name:
return export.visibility == "event"
return False
+182 -23
View File
@@ -9,6 +9,7 @@ from wasmtime import Store, WasiConfig, component
from lnbits.core.crud.extensions import get_installed_extension
from lnbits.core.db import core_app_extra
from lnbits.settings import settings
from ..api.host import ExtensionHostAPI
from ..api.runtime import ExtensionAPIHost
@@ -16,6 +17,9 @@ from .component import _wasm_component, _wasm_engine
from .host import add_extension_host_imports
from .loader import WasmExtension
_WASM_EPOCH_DEADLINE_TICKS = 1_000_000_000
_WASM_UNLIMITED_FUEL = 2**63 - 1
async def invoke_wasm_extension_export(
ext_id: str,
@@ -26,9 +30,52 @@ async def invoke_wasm_extension_export(
access_token: str | None = None,
context: str = "user",
owner_id: str | None = None,
trigger_type: str = "unknown",
request_id: str | None = None,
method: str | None = None,
path: str | None = None,
event_type: str | None = None,
wallet_id: str | None = None,
payment_hash: str | None = None,
checking_id: str | None = None,
request_bytes: int | None = None,
context_data: dict | None = None,
) -> dict[str, Any]:
from lnbits.core.services.extensions import (
finish_wasm_invocation,
get_wasm_invocation_stop_reason,
resolve_wasm_runtime_limits,
start_wasm_invocation,
stop_wasm_invocation,
wasm_invocation_stop_requested,
)
extension = _get_registered_extension(ext_id)
permissions = await _extension_permissions(extension)
installed_extension = await _active_installed_extension(extension)
permissions = installed_extension.permissions
limits = resolve_wasm_runtime_limits(installed_extension)
payload = payload or {}
payload_size = _json_size(payload)
effective_request_bytes = (
request_bytes if request_bytes is not None else payload_size
)
_check_wasm_request_size(effective_request_bytes, limits)
invocation = await start_wasm_invocation(
extension_id=extension.id,
export_name=export_name,
trigger_type=trigger_type,
user_id=_user_id(user) or owner_id,
wallet_id=wallet_id,
request_id=request_id,
method=method,
path=path,
event_type=event_type,
payment_hash=payment_hash,
checking_id=checking_id,
request_bytes=effective_request_bytes,
context={"host_context": context, **(context_data or {})},
runtime_limits=limits,
)
api = ExtensionHostAPI(
extension.id,
permissions,
@@ -36,17 +83,90 @@ async def invoke_wasm_extension_export(
access_token=access_token,
context=context,
owner_id=owner_id,
invocation_id=invocation.id,
runtime_limits=limits,
)
event_loop = asyncio.get_running_loop()
return await asyncio.to_thread(
_invoke_wasm_extension_export_sync,
extension,
export_name,
payload or {},
api,
event_loop,
thread_task = asyncio.create_task(
asyncio.to_thread(
_invoke_wasm_extension_export_sync,
extension,
export_name,
payload,
api,
event_loop,
invocation.id,
limits,
)
)
max_execution_ms = limits["wasm_runtime_max_execution_ms"]
timed_out = False
finished = False
try:
try:
if max_execution_ms > 0:
result = await asyncio.wait_for(
asyncio.shield(thread_task),
timeout=max_execution_ms / 1000,
)
else:
result = await thread_task
except asyncio.TimeoutError as exc:
timed_out = True
stop_reason = "WASM execution time limit exceeded."
await stop_wasm_invocation(invocation.id, reason=stop_reason)
try:
result = await asyncio.wait_for(
asyncio.shield(thread_task),
timeout=2,
)
except asyncio.TimeoutError:
await finish_wasm_invocation(
invocation.id,
status="timeout",
error_type="TimeoutError",
error_message=stop_reason,
stop_reason=stop_reason,
)
finished = True
raise TimeoutError(stop_reason) from exc
status = (
"timeout"
if timed_out
else (
"stopped"
if wasm_invocation_stop_requested(invocation.id)
else "completed"
)
)
await finish_wasm_invocation(
invocation.id,
status=status,
response_bytes=_json_size(result),
stop_reason=get_wasm_invocation_stop_reason(invocation.id),
)
finished = True
return result
except Exception as exc:
if not finished:
await finish_wasm_invocation(
invocation.id,
status=(
"timeout"
if timed_out
else (
"stopped"
if wasm_invocation_stop_requested(invocation.id)
else "failed"
)
),
error_type=exc.__class__.__name__,
error_message=str(exc),
stop_reason=get_wasm_invocation_stop_reason(invocation.id),
)
raise
def _invoke_wasm_extension_export_sync(
@@ -55,17 +175,24 @@ def _invoke_wasm_extension_export_sync(
payload: Mapping[str, Any],
api: ExtensionHostAPI,
event_loop: asyncio.AbstractEventLoop,
invocation_id: str,
limits: dict[str, int],
) -> dict[str, Any]:
from lnbits.core.services.extensions import attach_wasm_invocation_runtime
engine = _wasm_engine()
engine = _wasm_engine(limits["wasm_runtime_max_wasm_stack_bytes"])
store = Store(engine)
_set_store_limits(store, limits)
_set_store_fuel(store, limits)
store.set_epoch_deadline(_WASM_EPOCH_DEADLINE_TICKS)
attach_wasm_invocation_runtime(invocation_id, engine=engine, store=store)
store.set_wasi(WasiConfig())
linker = component.Linker(engine)
linker.add_wasip2()
add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop)
wasm_component = _wasm_component(extension)
wasm_component = _wasm_component(extension, limits)
instance = linker.instantiate(store, wasm_component)
function = instance.get_func(store, export_name)
if not function:
@@ -75,21 +202,17 @@ def _invoke_wasm_extension_export_sync(
result = function(store, json.dumps(payload))
function.post_return(store)
return _parse_wasm_export_result(extension, result)
return _parse_wasm_export_result(result, limits)
def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]:
def _parse_wasm_export_result(value: Any, limits: dict[str, int]) -> dict[str, Any]:
if isinstance(value, bytes):
value = value.decode()
if not isinstance(value, str):
return {"ok": True, "data": value}
max_response_bytes = (
(extension.config.get("wasm") or {})
.get("resource_limits", {})
.get("max_response_bytes")
)
if isinstance(max_response_bytes, int):
max_response_bytes = limits["wasm_runtime_max_response_bytes"]
if max_response_bytes > 0:
response_size = len(value.encode())
if response_size > max_response_bytes:
raise ValueError(
@@ -109,12 +232,48 @@ def _get_registered_extension(ext_id: str) -> WasmExtension:
raise RuntimeError(f"WASM extension '{ext_id}' is not registered.")
async def _extension_permissions(extension: WasmExtension) -> list[Any]:
async def _active_installed_extension(extension: WasmExtension) -> Any:
installed_extension = await get_installed_extension(extension.id)
if not installed_extension:
return []
return installed_extension.permissions
if (
not installed_extension
or settings.lnbits_extensions_deactivate_all
or not installed_extension.active
):
raise PermissionError(f"WASM extension '{extension.id}' is deactivated.")
return installed_extension
def _user_id(user: Any | None) -> str | None:
return getattr(user, "id", None) if user else None
def _set_store_limits(store: Any, limits: dict[str, int]) -> None:
store.set_limits(
memory_size=_wasm_limit(limits["wasm_runtime_max_memory_bytes"]),
table_elements=_wasm_limit(limits["wasm_runtime_max_table_elements"]),
instances=_wasm_limit(limits["wasm_runtime_max_instances"]),
tables=_wasm_limit(limits["wasm_runtime_max_tables"]),
memories=_wasm_limit(limits["wasm_runtime_max_memories"]),
)
def _set_store_fuel(store: Any, limits: dict[str, int]) -> None:
store.set_fuel(
limits["wasm_runtime_max_fuel"]
if limits["wasm_runtime_max_fuel"] > 0
else _WASM_UNLIMITED_FUEL
)
def _check_wasm_request_size(request_bytes: int, limits: dict[str, int]) -> None:
max_request_bytes = limits["wasm_runtime_max_request_bytes"]
if max_request_bytes > 0 and request_bytes > max_request_bytes:
raise ValueError(f"WASM extension request is too large: {request_bytes} bytes.")
def _wasm_limit(value: int) -> int:
return value if value > 0 else -1
def _json_size(value: Any) -> int:
return len(json.dumps(value, default=str).encode())
+25 -18
View File
@@ -5,6 +5,11 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from lnbits.core.wasm_ext.wasm.config import (
WasmExtensionConfig,
WasmExtensionExport,
parse_wasm_extension_config,
)
from lnbits.settings import settings
@@ -17,13 +22,13 @@ class WasmExtension:
module_path: Path
wit_path: Path | None
world: str
host_api: str
exports: list[dict[str, Any]]
config: dict[str, Any]
exports: list[WasmExtensionExport]
config: WasmExtensionConfig
def is_wasm_extension_id(ext_id: str) -> bool:
config = load_wasm_extension_config(ext_id)
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
config = _load_json(ext_dir / "config.json")
return bool(config and config.get("extension_type") == "wasm")
@@ -32,36 +37,38 @@ def is_wasm_extension_dir(ext_dir: Path) -> bool:
return bool(config and config.get("extension_type") == "wasm")
def load_wasm_extension_config(ext_id: str) -> dict[str, Any] | None:
def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
return _load_json(ext_dir / "config.json")
config = _load_json(ext_dir / "config.json")
if not config or config.get("extension_type") != "wasm":
return None
return parse_wasm_extension_config(ext_id, config)
def load_wasm_extension(ext_id: str) -> WasmExtension:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
config = load_wasm_extension_config(ext_id)
if not config:
raw_config = _load_json(ext_dir / "config.json")
if not raw_config:
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
if config.get("extension_type") != "wasm":
if raw_config.get("extension_type") != "wasm":
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
config = parse_wasm_extension_config(ext_id, raw_config)
wasm_config = config.get("wasm") or {}
module_path = _extension_path(ext_dir, wasm_config.get("module"))
wit_path = _optional_extension_path(ext_dir, wasm_config.get("wit"))
module_path = _extension_path(ext_dir, config.wasm.module)
wit_path = _optional_extension_path(ext_dir, config.wasm.wit)
_check_wasm_module(module_path)
if wit_path and not wit_path.is_file():
raise FileNotFoundError(f"WIT file not found: {wit_path}")
return WasmExtension(
id=config.get("id") or ext_id,
name=config.get("name") or ext_id,
version=config.get("version") or "0.0",
id=config.id,
name=config.name,
version=config.version,
root_path=ext_dir,
module_path=module_path,
wit_path=wit_path,
world=wasm_config.get("world") or "",
host_api=wasm_config.get("host_api") or "lnbits.core.wasm_ext.ExtensionHostAPI",
exports=wasm_config.get("exports") or [],
world=config.wasm.world,
exports=config.wasm.exports,
config=config,
)
+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)
+36
View File
@@ -60,6 +60,7 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_wasm_invocation_retention_days: int = Field(default=7, ge=0)
lnbits_extensions_reviews_url: str = Field(
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
description="""
@@ -81,6 +82,33 @@ class ExtensionsSettings(LNbitsSettings):
return Path(settings.lnbits_data_folder, "extensions_builder")
class WasmRuntimeLimits(LNbitsSettings):
# 0 disables the limit. Installed WASM extensions may override these defaults.
wasm_runtime_max_memory_bytes: int = Field(default=64 * 1024 * 1024, ge=0)
wasm_runtime_max_execution_ms: int = Field(default=5_000, ge=0)
wasm_runtime_max_fuel: int = Field(default=100_000_000, ge=0)
wasm_runtime_max_response_bytes: int = Field(default=1024 * 1024, ge=0)
wasm_runtime_max_request_bytes: int = Field(default=1024 * 1024, ge=0)
wasm_runtime_max_wasm_stack_bytes: int = Field(default=1024 * 1024, ge=0)
wasm_runtime_max_table_elements: int = Field(default=10_000, ge=0)
wasm_runtime_max_instances: int = Field(default=8, ge=0)
wasm_runtime_max_tables: int = Field(default=10, ge=0)
wasm_runtime_max_memories: int = Field(default=1, ge=0)
wasm_runtime_max_concurrent_invocations: int = Field(default=16, ge=0)
wasm_runtime_max_concurrent_invocations_per_extension: int = Field(default=4, ge=0)
wasm_runtime_max_concurrent_invocations_per_user: int = Field(default=4, ge=0)
wasm_runtime_max_host_calls: int = Field(default=1_000, ge=0)
wasm_runtime_max_http_calls: int = Field(default=20, ge=0)
wasm_runtime_max_storage_calls: int = Field(default=100, ge=0)
wasm_runtime_max_wallet_calls: int = Field(default=20, ge=0)
wasm_runtime_http_timeout_ms: int = Field(default=5_000, ge=0)
wasm_runtime_max_http_response_bytes: int = Field(default=1024 * 1024, ge=0)
class ExtensionsInstallSettings(LNbitsSettings):
lnbits_extensions_default_install: list[str] = Field(default=[])
# required due to GitHUb rate-limit
@@ -496,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")
@@ -1006,6 +1039,7 @@ class AuditSettings(LNbitsSettings):
class EditableSettings(
UsersSettings,
ExtensionsSettings,
WasmRuntimeLimits,
ThemesSettings,
OpsSettings,
AssetSettings,
@@ -1295,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")
@@ -1359,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
+25 -1
View File
@@ -116,6 +116,7 @@ window.localisation.en = {
read: 'Read',
write: 'Write',
pay: 'Pay',
sending: 'Sending',
memo: 'Memo',
date: 'Date',
path: 'Path',
@@ -520,6 +521,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,10 +537,20 @@ 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',
extension_permission_ext_storage_append_public:
'Append public extension storage',
extension_permission_ext_storage_append_public_sources:
'Allowed append targets',
extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_read_public_source_required:
'required to read',
extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
extension_permission_extension_api_request: 'Use other extensions',
@@ -542,13 +561,18 @@ 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_websocket: 'Use extension websockets',
extension_permission_websocket_publish: 'Publish websocket messages',
extension_permission_websocket_subscribe: 'Subscribe to websocket messages',
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',
@@ -0,0 +1,380 @@
window.app.component('lnbits-admin-wasm-limit-config', {
props: ['form-data'],
template: '#lnbits-admin-wasm-limit-config',
data() {
return {
selectedWasmExtensionId: null,
wasmExtensionLimitDraft: {},
wasmExtensionLimitsSaving: false,
wasmRuntimeLimitExtensions: [],
wasmRuntimeLimitExtensionsLoading: false,
wasmLimitInfoDialog: {
show: false,
title: '',
details: ''
},
wasmRuntimeLimitGroups: [
{
title: 'Execution',
fields: [
{
name: 'wasm_runtime_max_execution_ms',
label: 'Max execution time (ms)',
description:
'Maximum wall-clock time allowed for one WASM invocation.',
details:
'This is the elapsed time from starting the invocation until the export returns. When the limit is reached LNbits requests an interrupt and records the invocation as timed out if it cannot finish quickly. Use this to stop long sleeps, slow host calls, and CPU loops that run for too long.'
},
{
name: 'wasm_runtime_max_fuel',
label: 'Max fuel',
description:
'Maximum Wasmtime instruction budget for one invocation.',
details:
'Fuel is Wasmtime instruction budgeting. It is more deterministic than wall-clock time for CPU-heavy loops because each executed instruction consumes budget. Set this low enough to stop busy loops, but high enough for legitimate extension startup and JSON processing.'
},
{
name: 'wasm_runtime_max_wasm_stack_bytes',
label: 'Max WASM stack (bytes)',
description: 'Maximum stack space for WASM calls and recursion.',
details:
'This limits stack used by WebAssembly function calls. It protects the server from deep recursion or very large call chains in extension code. If legitimate extensions fail with stack overflow traps, raise this carefully.'
}
]
},
{
title: 'Memory and Data Size',
fields: [
{
name: 'wasm_runtime_max_memory_bytes',
label: 'Max memory (bytes)',
description: 'Maximum WASM linear memory per invocation.',
details:
'This caps the linear memory visible to the WASM module. It limits memory.grow and can make instantiation fail if the module asks for too much memory up front. This does not include every byte used by the Python process or Wasmtime engine internals.'
},
{
name: 'wasm_runtime_max_request_bytes',
label: 'Max request size (bytes)',
description:
'Maximum serialized input payload accepted before execution.',
details:
'This caps the serialized payload passed into a WASM export before execution starts. It protects against huge HTTP bodies, oversized event data, and expensive JSON parsing. Requests above this limit should be rejected before invoking the extension.'
},
{
name: 'wasm_runtime_max_response_bytes',
label: 'Max response size (bytes)',
description:
'Maximum serialized response returned by a WASM export.',
details:
'This caps the JSON or string response returned by the WASM export. It prevents extensions from returning huge responses that consume memory, slow down API calls, or overload the browser. Responses above this limit are treated as invalid.'
}
]
},
{
title: 'Wasmtime Objects',
fields: [
{
name: 'wasm_runtime_max_table_elements',
label: 'Max table elements',
description: 'Maximum total elements allowed in WASM tables.',
details:
'Tables store references used by WebAssembly, commonly function references. Limiting table elements prevents a module from allocating very large reference tables. Each table element also has host memory overhead.'
},
{
name: 'wasm_runtime_max_instances',
label: 'Max instances',
description:
'Maximum WebAssembly instances allowed inside one store.',
details:
'This limits how many WebAssembly instances can be created inside one Wasmtime store. LNbits normally needs one instance per invocation, so a low value is expected. Raising it should only be needed if the runtime starts supporting modules that instantiate other modules.'
},
{
name: 'wasm_runtime_max_tables',
label: 'Max tables',
description:
'Maximum WebAssembly tables allowed inside one store.',
details:
'This limits the number of WebAssembly tables in the store. It is separate from table elements: one setting limits the number of tables, the other limits their total size. Keep this small unless a component model module legitimately needs more tables.'
},
{
name: 'wasm_runtime_max_memories',
label: 'Max memories',
description:
'Maximum WebAssembly linear memories allowed inside one store.',
details:
'This limits how many separate linear memories a module can create. Most extensions should need only one memory. Keep this small to reduce memory accounting complexity and prevent multi-memory abuse.'
}
]
},
{
title: 'Concurrency',
fields: [
{
name: 'wasm_runtime_max_concurrent_invocations',
label: 'Max concurrent invocations',
description:
'Maximum running WASM invocations across the server.',
details:
'This is the global cap for running WASM invocations across all extensions and users. It protects the LNbits process from thread exhaustion, CPU pressure, and too many simultaneous stores. New invocations should be rejected or queued once this is reached.'
},
{
name: 'wasm_runtime_max_concurrent_invocations_per_extension',
label: 'Max concurrent per extension',
description:
'Maximum running WASM invocations for one extension.',
details:
'This caps how many invocations a single extension can run at once. It prevents one malicious or buggy extension from consuming the whole global concurrency budget. Set it lower than the global limit.'
},
{
name: 'wasm_runtime_max_concurrent_invocations_per_user',
label: 'Max concurrent per user',
description: 'Maximum running WASM invocations for one user.',
details:
'This caps concurrent invocations attributed to one user. It helps protect against a user repeatedly clicking, refreshing, or scripting extension calls. Invocations without a user can still be governed by the global and per-extension limits.'
}
]
},
{
title: 'Host Calls',
fields: [
{
name: 'wasm_runtime_max_host_calls',
label: 'Max host calls',
description:
'Maximum total calls from WASM into LNbits host APIs.',
details:
'This is the total budget for calls from the WASM module into LNbits host APIs during one invocation. It should count all categories together. It limits chatty extensions and prevents tight loops that repeatedly call back into Python.'
},
{
name: 'wasm_runtime_max_http_calls',
label: 'Max HTTP calls',
description: 'Maximum outbound HTTP host calls per invocation.',
details:
'This caps outbound HTTP requests made through the host API during one invocation. It reduces SSRF blast radius, protects network resources, and limits slow external dependencies. It should be enforced together with HTTP timeout and response-size limits.'
},
{
name: 'wasm_runtime_max_storage_calls',
label: 'Max storage calls',
description: 'Maximum storage host calls per invocation.',
details:
'This caps extension storage operations during one invocation. It protects the database from excessive reads and writes triggered by malicious loops. Use it with storage payload-size limits if those are added later.'
},
{
name: 'wasm_runtime_max_wallet_calls',
label: 'Max wallet calls',
description: 'Maximum wallet/payment host calls per invocation.',
details:
'This caps wallet and payment-related host calls during one invocation. These calls are security-sensitive and may touch balances, invoices, or payments. Keep this conservative and rely on explicit permissions for what the extension is allowed to do.'
}
]
},
{
title: 'HTTP',
fields: [
{
name: 'wasm_runtime_http_timeout_ms',
label: 'HTTP timeout (ms)',
description: 'Maximum time allowed for one WASM HTTP request.',
details:
'This is the per-request timeout for HTTP calls made through the WASM host API. It prevents a slow remote server from holding an invocation open indefinitely. The total invocation timeout still applies across all work.'
},
{
name: 'wasm_runtime_max_http_response_bytes',
label: 'Max HTTP response size (bytes)',
description:
'Maximum response body size accepted from one WASM HTTP request.',
details:
'This caps the response body accepted from each HTTP call made by an extension. It protects memory and parsing time when a remote server returns a very large body. Responses above the limit should fail the host call.'
}
]
}
]
}
},
computed: {
adminKey() {
return this.g.user.wallets[0].adminkey
},
isExtensionLimitRoute() {
return this.$route.path.startsWith('/admin/extensions/wasm/limits/')
},
routeWasmExtensionId() {
return this.isExtensionLimitRoute ? this.$route.params.extId : null
},
backRoute() {
return this.isExtensionLimitRoute
? '/admin/extensions/wasm/limits'
: '/admin#extensions'
},
backTooltip() {
return this.isExtensionLimitRoute
? 'Wasm Limit Config'
: 'Extensions Settings'
},
pageDescription() {
if (this.isExtensionLimitRoute) {
return 'Customize limits for one installed WASM extension.'
}
return 'These values are global defaults. Use 0 to disable a global limit.'
},
wasmRuntimeLimitExtensionOptions() {
return this.wasmRuntimeLimitExtensions.map(extension => ({
label: `${extension.name || extension.id} (${extension.id})`,
value: extension.id
}))
},
selectedWasmRuntimeLimitExtension() {
return (
this.wasmRuntimeLimitExtensions.find(
extension => extension.id === this.selectedWasmExtensionId
) || null
)
},
customWasmLimitCount() {
const extension = this.selectedWasmRuntimeLimitExtension
if (!extension || !extension.wasm_runtime_limits) {
return 0
}
return Object.keys(extension.wasm_runtime_limits).length
}
},
watch: {
selectedWasmExtensionId() {
this.loadSelectedWasmRuntimeLimitExtension()
},
routeWasmExtensionId() {
this.syncWasmExtensionLimitRoute()
}
},
created() {
this.fetchWasmRuntimeLimitExtensions()
},
methods: {
async fetchWasmRuntimeLimitExtensions() {
this.wasmRuntimeLimitExtensionsLoading = true
try {
const {data} = await LNbits.api.request(
'GET',
'/api/v1/extension/wasm/runtime-limits/extensions',
this.adminKey
)
this.wasmRuntimeLimitExtensions = data || []
if (
this.selectedWasmExtensionId &&
!this.wasmRuntimeLimitExtensions.some(
extension => extension.id === this.selectedWasmExtensionId
)
) {
this.selectedWasmExtensionId = null
}
this.syncWasmExtensionLimitRoute()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.wasmRuntimeLimitExtensionsLoading = false
}
},
syncWasmExtensionLimitRoute() {
this.selectedWasmExtensionId = this.routeWasmExtensionId
this.loadSelectedWasmRuntimeLimitExtension()
},
openWasmExtensionLimit(extensionId) {
this.$router.push(
`/admin/extensions/wasm/limits/${encodeURIComponent(extensionId)}`
)
},
loadSelectedWasmRuntimeLimitExtension() {
const extension = this.selectedWasmRuntimeLimitExtension
this.wasmExtensionLimitDraft = extension
? {...(extension.wasm_runtime_limits || {})}
: {}
},
wasmExtensionLimitHint(field) {
const globalValue = this.formData[field.name]
return `Inherited global value: ${globalValue}. ${field.description}`
},
wasmExtensionLimitPlaceholder(field) {
const globalValue = this.formData[field.name]
return globalValue === undefined || globalValue === null
? ''
: String(globalValue)
},
normalizedWasmExtensionLimitDraft() {
const limits = {}
this.wasmRuntimeLimitGroups.forEach(group => {
group.fields.forEach(field => {
const value = this.wasmExtensionLimitDraft[field.name]
const cleanValue = typeof value === 'string' ? value.trim() : value
if (
cleanValue === '' ||
cleanValue === null ||
cleanValue === undefined
) {
return
}
const numericValue = Number(cleanValue)
if (
!Number.isFinite(numericValue) ||
!Number.isInteger(numericValue) ||
numericValue < 0
) {
throw new Error(`${field.label} must be a non-negative integer.`)
}
limits[field.name] = numericValue
})
})
return limits
},
async clearWasmExtensionLimits() {
this.wasmExtensionLimitDraft = {}
await this.saveWasmExtensionLimits()
},
async saveWasmExtensionLimits() {
if (!this.selectedWasmExtensionId) {
return
}
this.wasmExtensionLimitsSaving = true
try {
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/wasm/runtime-limits/${encodeURIComponent(
this.selectedWasmExtensionId
)}`,
this.adminKey,
{
limits: this.normalizedWasmExtensionLimitDraft()
}
)
const index = this.wasmRuntimeLimitExtensions.findIndex(
extension => extension.id === data.id
)
if (index >= 0) {
this.wasmRuntimeLimitExtensions.splice(index, 1, data)
}
this.loadSelectedWasmRuntimeLimitExtension()
Quasar.Notify.create({
type: 'positive',
message: 'WASM extension limits saved.'
})
} catch (error) {
if (error instanceof Error && !error.response) {
Quasar.Notify.create({
type: 'negative',
message: error.message
})
} else {
LNbits.utils.notifyApiError(error)
}
} finally {
this.wasmExtensionLimitsSaving = false
}
},
showWasmLimitInfo(field) {
this.wasmLimitInfoDialog = {
show: true,
title: field.label,
details: field.details
}
}
}
})
@@ -0,0 +1,380 @@
window.app.component('lnbits-admin-wasm-runtime', {
props: ['form-data'],
template: '#lnbits-admin-wasm-runtime',
data() {
return {
wasmRuntimeLoading: false,
wasmHistoryLoading: false,
wasmRuntimeTimer: null,
wasmStats: {},
wasmCurrentInvocations: [],
wasmInvocationHistory: [],
wasmStatItems: [
{key: 'total', label: 'Total', icon: 'data_usage', color: 'primary'},
{key: 'running', label: 'Running', icon: 'play_circle', color: 'green'},
{key: 'completed', label: 'Completed', icon: 'task_alt', color: 'teal'},
{key: 'failed', label: 'Failed', icon: 'error', color: 'red'},
{
key: 'stopped',
label: 'Stopped',
icon: 'stop_circle',
color: 'orange'
},
{key: 'timeout', label: 'Timeouts', icon: 'timer_off', color: 'purple'}
],
wasmCurrentColumns: [
{
name: 'extension_id',
label: 'Extension',
field: 'extension_id',
align: 'left',
sortable: true
},
{
name: 'export_name',
label: 'Export',
field: 'export_name',
align: 'left',
sortable: true
},
{
name: 'trigger_type',
label: 'Trigger',
field: 'trigger_type',
align: 'left',
sortable: true
},
{
name: 'status',
label: 'Status',
field: 'status',
align: 'left',
sortable: true
},
{
name: 'user_id',
label: 'User',
field: 'user_id',
align: 'left',
sortable: true
},
{
name: 'started_at',
label: 'Started',
field: 'started_at',
align: 'left',
sortable: true
},
{
name: 'duration_ms',
label: 'Duration',
field: row => row.duration_ms || 0,
align: 'right',
sortable: true
},
{
name: 'context',
label: 'Context',
field: row => this.wasmContextValue(row),
align: 'left',
sortable: true
},
{name: 'actions', label: '', field: 'actions', align: 'right'}
],
wasmHistoryColumns: [
{
name: 'extension_id',
label: 'Extension',
field: 'extension_id',
align: 'left',
sortable: true
},
{
name: 'export_name',
label: 'Export',
field: 'export_name',
align: 'left',
sortable: true
},
{
name: 'trigger_type',
label: 'Trigger',
field: 'trigger_type',
align: 'left',
sortable: true
},
{
name: 'status',
label: 'Status',
field: 'status',
align: 'left',
sortable: true
},
{
name: 'user_id',
label: 'User',
field: 'user_id',
align: 'left',
sortable: true
},
{
name: 'started_at',
label: 'Started',
field: 'started_at',
align: 'left',
sortable: true
},
{
name: 'duration_ms',
label: 'Duration',
field: row => row.duration_ms || 0,
align: 'right',
sortable: true
},
{
name: 'calls',
label: 'Calls',
field: row => this.wasmCallCount(row),
align: 'left',
sortable: true
},
{
name: 'context',
label: 'Context',
field: row => this.wasmContextValue(row),
align: 'left',
sortable: true
},
{
name: 'error_message',
label: 'Error/Stop Reason',
field: row => row.error_message || row.stop_reason || '',
align: 'left',
sortable: true
}
]
}
},
computed: {
adminKey() {
return this.g.user.wallets[0].adminkey
},
wasmExtensionId() {
return this.$route.params.extId || null
},
wasmExtensionQuery() {
if (!this.wasmExtensionId) {
return ''
}
return `extension_id=${encodeURIComponent(this.wasmExtensionId)}`
}
},
watch: {
wasmExtensionId() {
this.fetchWasmRuntime()
}
},
methods: {
async fetchWasmRuntime() {
await Promise.all([
this.fetchWasmCurrentInvocations(),
this.fetchWasmInvocationHistory(),
this.fetchWasmInvocationStats()
])
},
async fetchWasmCurrentInvocations() {
this.wasmRuntimeLoading = true
try {
const query = this.wasmExtensionQuery
? `?${this.wasmExtensionQuery}`
: ''
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/wasm/invocations/current${query}`,
this.adminKey
)
this.wasmCurrentInvocations = data || []
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.wasmRuntimeLoading = false
}
},
async fetchWasmInvocationHistory() {
this.wasmHistoryLoading = true
try {
const params = ['limit=50']
if (this.wasmExtensionQuery) {
params.push(this.wasmExtensionQuery)
}
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/wasm/invocations?${params.join('&')}`,
this.adminKey
)
this.wasmInvocationHistory = data || []
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.wasmHistoryLoading = false
}
},
async fetchWasmInvocationStats() {
try {
const params = ['hours=24']
if (this.wasmExtensionQuery) {
params.push(this.wasmExtensionQuery)
}
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/wasm/invocations/stats?${params.join('&')}`,
this.adminKey
)
this.wasmStats = data || {}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async stopWasmInvocation(invocationId) {
try {
await LNbits.api.request(
'POST',
`/api/v1/extension/wasm/invocations/${encodeURIComponent(invocationId)}/stop`,
this.adminKey
)
Quasar.Notify.create({
type: 'positive',
message: 'WASM invocation stop requested.'
})
await this.fetchWasmRuntime()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deactivateWasmExtension(extensionId) {
LNbits.utils
.confirmDialog(
`Deactivate extension '${extensionId}'?`,
'Deactivate Extension'
)
.onOk(async () => {
try {
await LNbits.api.request(
'PUT',
`/api/v1/extension/${encodeURIComponent(extensionId)}/deactivate`,
this.adminKey
)
Quasar.Notify.create({
type: 'positive',
message: `Extension '${extensionId}' deactivated.`
})
await this.fetchWasmRuntime()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
formatWasmStat(key) {
const value = this.wasmStats[key]
return value === undefined || value === null ? '0' : String(value)
},
formatWasmDate(value) {
return value ? this.utils.formatDate(value) : ''
},
wasmStatusColor(status) {
return (
{
running: 'green',
stopping: 'orange',
completed: 'teal',
failed: 'red',
stopped: 'orange',
timeout: 'purple',
abandoned: 'grey'
}[status] || 'grey'
)
},
wasmTriggerColor(triggerType) {
return (
{
http: 'primary',
event: 'purple'
}[triggerType] || 'grey'
)
},
formatWasmDuration(row) {
let duration = row.duration_ms
if (
(row.status === 'running' || row.status === 'stopping') &&
row.started_at
) {
duration = Date.now() - new Date(row.started_at).getTime()
}
if (duration === undefined || duration === null) {
return ''
}
if (duration >= 1000) {
return `${(duration / 1000).toFixed(1)}s`
}
return `${duration}ms`
},
formatWasmCalls(row) {
return [
`host ${row.host_call_count || 0}`,
`http ${row.http_call_count || 0}`,
`storage ${row.storage_call_count || 0}`,
`wallet ${row.wallet_call_count || 0}`
].join(' / ')
},
wasmCallCount(row) {
return (
(row.host_call_count || 0) +
(row.http_call_count || 0) +
(row.storage_call_count || 0) +
(row.wallet_call_count || 0)
)
},
wasmContextValue(row) {
return [
row.method,
row.path,
row.event_type,
row.wallet_id,
row.payment_hash
]
.filter(Boolean)
.join(' ')
},
formatWasmContext(row) {
const items = [
row.method,
row.path,
row.event_type,
row.wallet_id ? `wallet ${row.wallet_id}` : '',
row.payment_hash ? `payment ${row.payment_hash.slice(0, 12)}...` : ''
].filter(Boolean)
return items.join(' | ')
},
formatWasmUserId(userId) {
if (!userId) {
return '-'
}
const value = String(userId)
if (value.length <= 12) {
return value
}
return `${value.slice(0, 3)}...${value.slice(-3)}`
}
},
created() {
this.fetchWasmRuntime()
this.wasmRuntimeTimer = setInterval(() => {
this.fetchWasmCurrentInvocations()
}, 5000)
},
unmounted() {
if (this.wasmRuntimeTimer) {
clearInterval(this.wasmRuntimeTimer)
}
}
})
@@ -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,22 @@
if (permission.id === 'http.request') {
return mediumRisk(translateFn)
}
if (permission.id === 'wallet.payments.watch') {
return mediumRisk(
translateFn,
'extension_permission_warning_wallet_payments_watch'
)
}
if (['websocket.publish', 'websocket.subscribe'].includes(permission.id)) {
return mediumRisk(translateFn)
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.read_public',
'payments.watch'
'ext.storage.append_public',
'ext.storage.read_public'
].includes(permission.id)
) {
return mediumRisk(translateFn)
@@ -124,14 +139,20 @@
function permissionOrderIndex(permissionId) {
const order = [
'wallet.pay_invoice',
'wallet.pay_invoice_background',
'wallet.payments.watch',
'wallet.list',
'wallet.balance.read',
'extension.api.request',
'http.request',
'ui.camera.scan_qr',
'websocket',
'websocket.publish',
'websocket.subscribe',
'ext.storage.read',
'ext.storage.write',
'ext.storage.read_public',
'ext.storage.append_public',
'wallet.create_invoice_public',
'wallet.create_invoice',
'utils.basic'
@@ -153,7 +174,12 @@
: table.public_fields.filter(
field => typeof field === 'string' && field
)
return tableName ? {table: tableName, fields} : null
const sourceIdField =
typeof table === 'string' ||
typeof table?.source_id_field !== 'string'
? ''
: table.source_id_field
return tableName ? {table: tableName, fields, sourceIdField} : null
})
.filter(Boolean)
}
@@ -181,24 +207,68 @@
.filter(Boolean)
}
function publicAppendPolicies(permission) {
const policies = permission.policies
if (!Array.isArray(policies)) return []
return policies
.map(policy => {
if (!policy || typeof policy !== 'object') return null
const table = policy.table
const sourceTable = policy.source_table
const sourceIdField = policy.source_id_field
const allowedFields = Array.isArray(policy.allowed_fields)
? policy.allowed_fields.filter(
field => typeof field === 'string' && field
)
: []
const maxRowsPerSource = Number.isInteger(policy.max_rows_per_source)
? policy.max_rows_per_source
: 10000
if (typeof table !== 'string' || !table) return null
if (typeof sourceTable !== 'string' || !sourceTable) return null
if (typeof sourceIdField !== 'string' || !sourceIdField) return null
return {
table,
sourceTable,
sourceIdField,
allowedFields,
maxRowsPerSource
}
})
.filter(Boolean)
}
function permissionDisplayItem(permissions, extensions, translateFn) {
const permission = permissions[0]
const isReadWriteStorage =
permissions.length === 2 &&
permissions.some(permission => permission.id === 'ext.storage.read') &&
permissions.some(permission => permission.id === 'ext.storage.write')
const isWebsocket =
permissions.every(permission =>
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) &&
permissions.some(permission => permission.id === 'websocket.publish') &&
permissions.some(permission => permission.id === 'websocket.subscribe')
const descriptions = permissions
.map(permission => permissionManifestDescription(permission))
.filter(Boolean)
const item = {
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
id: isReadWriteStorage
? 'ext.storage.read_write'
: isWebsocket
? 'websocket'
: permission.id,
label: isReadWriteStorage
? translate(translateFn, 'extension_permission_ext_storage_read_write')
: permissionLabel(permission, translateFn),
: isWebsocket
? translate(translateFn, 'extension_permission_websocket')
: permissionLabel(permission, translateFn),
risk: permissionRisk(permissions, extensions, translateFn),
badges: [],
descriptions,
fieldGroups: [],
appendPolicies: [],
invoicePolicies: [],
extensionAccess: [],
httpHosts: []
@@ -231,6 +301,15 @@
item.invoicePolicies = publicInvoicePolicies(permission)
}
if (permission.id === 'ext.storage.append_public') {
item.appendPolicies = publicAppendPolicies(permission)
item.badges = item.appendPolicies.map(policy => ({
key:
policy.table + ':' + policy.sourceTable + ':' + policy.sourceIdField,
label: policy.table
}))
}
return item
}
@@ -242,7 +321,11 @@
const hasReadWriteStorage =
permissionsById.has('ext.storage.read') &&
permissionsById.has('ext.storage.write')
const hasWebsocket =
permissionsById.has('websocket.publish') &&
permissionsById.has('websocket.subscribe')
let addedReadWriteStorage = false
let addedWebsocket = false
return permissionList
.map((permission, index) => {
@@ -261,6 +344,21 @@
]
}
}
if (
hasWebsocket &&
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) {
if (addedWebsocket) return null
addedWebsocket = true
return {
index,
orderId: 'websocket',
permissions: [
permissionsById.get('websocket.publish'),
permissionsById.get('websocket.subscribe')
]
}
}
return {
index,
orderId: permission.id,
@@ -314,6 +412,9 @@
publicInvoicePolicySentence(policy) {
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
},
publicAppendPolicySentence(policy) {
return `${policy.table} rows can be appended for ${policy.sourceTable} using ${policy.sourceIdField}. Limit: ${policy.maxRowsPerSource} rows per source.`
},
permissionAccessLabel(access) {
const key = `extension_permission_access_${access}`
const label = this.$t(key)
+20
View File
@@ -99,6 +99,26 @@ const routes = [
name: 'Users',
component: PageUsers
},
{
path: '/admin/extensions/wasm',
name: 'AdminWasmRuntime',
component: PageAdmin
},
{
path: '/admin/extensions/wasm/limits',
name: 'AdminWasmLimitConfig',
component: PageAdmin
},
{
path: '/admin/extensions/wasm/limits/:extId',
name: 'AdminWasmLimitConfigDetail',
component: PageAdmin
},
{
path: '/admin/extensions/wasm/:extId',
name: 'AdminWasmRuntimeDetail',
component: PageAdmin
},
{
path: '/admin',
name: 'Admin',
+35 -6
View File
@@ -16,18 +16,26 @@ window.PageAdmin = {
},
watch: {
tab(tab) {
this.$router.push(`/admin#${tab}`)
if (
['wasm-runtime', 'wasm-limit-config'].includes(tab) &&
this.$route.path.startsWith('/admin/extensions/wasm')
) {
return
}
const target = this.adminRouteForTab(tab)
if (this.$route.fullPath !== target) {
this.$router.push(target)
}
},
$route(to) {
if (to.hash.length > 1) {
this.tab = to.hash.replace('#', '')
const tab = this.adminTabFromRoute(to)
if (this.tab !== tab) {
this.tab = tab
}
}
},
async created() {
if (this.$route.hash.length > 1) {
this.tab = this.$route.hash.replace('#', '')
}
this.tab = this.adminTabFromRoute(this.$route)
await this.getSettings()
},
computed: {
@@ -36,6 +44,27 @@ window.PageAdmin = {
}
},
methods: {
adminTabFromRoute(route) {
if (route.path.startsWith('/admin/extensions/wasm/limits')) {
return 'wasm-limit-config'
}
if (route.path.startsWith('/admin/extensions/wasm')) {
return 'wasm-runtime'
}
if (route.hash.length > 1) {
return route.hash.replace('#', '')
}
return 'funding'
},
adminRouteForTab(tab) {
if (tab === 'wasm-runtime') {
return '/admin/extensions/wasm'
}
if (tab === 'wasm-limit-config') {
return '/admin/extensions/wasm/limits'
}
return `/admin#${tab}`
},
getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => {
this.formData[fieldName] = response.data.default_value
+243 -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
@@ -164,6 +207,11 @@ window.PageExtensions = {
)
extension.isAvailable = true
extension.isInstalled = true
extension.isWasm =
response.data.is_wasm === true ||
response.data.isWasm === true ||
release.extension_type === 'wasm' ||
extension.isWasm === true
extension.icon = response.data.icon || extension.icon
extension.installedRelease = release
this.toggleExtension(extension)
@@ -352,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(
@@ -391,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) {
@@ -637,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({
+612 -1
View File
@@ -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,13 +147,44 @@ 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: '',
handleWindowMessage: null,
loading: false,
loadId: 0,
paymentSubscriptions: new Map()
paymentSubscriptions: new Map(),
websocketSubscriptions: new Map()
}
},
created() {
@@ -84,6 +194,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 +235,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 +257,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 +283,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 {
@@ -212,6 +358,29 @@ window.WasmExtensionComponent = {
)
})
},
extensionRoute(path) {
let url
try {
url = new URL(String(path || ''), window.location.origin)
} catch (_error) {
throw new Error('Invalid extension route.')
}
if (url.origin !== window.location.origin) {
throw new Error('Extension route must stay on this server.')
}
const basePath = `/ext/${encodeURIComponent(this.bridge.extensionId)}`
if (
url.pathname !== basePath &&
!url.pathname.startsWith(`${basePath}/`)
) {
throw new Error('Extension route must stay inside this extension.')
}
return `${url.pathname}${url.search}${url.hash}`
},
replaceExtensionRoute(message) {
return this.$router.replace(this.extensionRoute(message.path))
},
async callApi(message) {
const method = String(message.method || 'GET').toUpperCase()
const path = String(message.path || '')
@@ -310,6 +479,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) {
@@ -385,6 +868,18 @@ window.WasmExtensionComponent = {
isPaymentHash(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
},
isWebsocketItemId(value) {
return (
typeof value === 'string' &&
/^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$/.test(value)
)
},
scopedWebsocketItemId(itemId) {
if (!this.isWebsocketItemId(itemId)) {
throw new Error('Invalid websocket item ID.')
}
return `ext:${this.bridge.extensionId}:${itemId}`
},
websocketUrl(path) {
const url = new URL(window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -415,8 +910,24 @@ window.WasmExtensionComponent = {
this.closePaymentSubscription(subscriptionId)
}
},
closeWebsocketSubscription(subscriptionId) {
const subscription = this.websocketSubscriptions.get(subscriptionId)
if (!subscription) return
this.websocketSubscriptions.delete(subscriptionId)
try {
subscription.socket.close()
} catch (_error) {}
},
closeWebsocketSubscriptions() {
for (const subscriptionId of Array.from(
this.websocketSubscriptions.keys()
)) {
this.closeWebsocketSubscription(subscriptionId)
}
},
closeBridgePort() {
this.closePaymentSubscriptions()
this.closeWebsocketSubscriptions()
this.bridgePort?.close()
this.bridgePort = null
},
@@ -478,6 +989,55 @@ window.WasmExtensionComponent = {
this.paymentSubscriptions.delete(subscriptionId)
})
},
subscribeWebsocket(message) {
if (!this.hasBridgePermission('websocket.subscribe')) {
throw new Error('Extension is missing websocket subscribe permission.')
}
const subscriptionId = String(message.subscriptionId || '')
const itemId = String(message.itemId || '')
if (
!subscriptionId ||
subscriptionId.length > 128 ||
!this.isWebsocketItemId(itemId)
) {
throw new Error('Invalid websocket subscription.')
}
this.closeWebsocketSubscription(subscriptionId)
const scopedItemId = this.scopedWebsocketItemId(itemId)
const socket = new WebSocket(
this.websocketUrl(`/api/v1/ws/${encodeURIComponent(scopedItemId)}`)
)
this.websocketSubscriptions.set(subscriptionId, {itemId, socket})
socket.addEventListener('message', event => {
let data = event.data
try {
data = JSON.parse(event.data)
} catch (_error) {}
this.sendBridgeEvent({
event: 'websocket.message',
subscriptionId,
itemId,
data
})
})
socket.addEventListener('error', () => {
this.sendBridgeEvent({
event: 'websocket.error',
subscriptionId,
itemId
})
this.closeWebsocketSubscription(subscriptionId)
})
socket.addEventListener('close', () => {
this.websocketSubscriptions.delete(subscriptionId)
})
},
async handleBridgeRequest(message, reply) {
if (!message || message.type !== 'lnbits-extension:request') return
@@ -507,6 +1067,15 @@ window.WasmExtensionComponent = {
return
}
if (message.action === 'navigation.replace') {
await this.replaceExtensionRoute(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'ui.scan_qr') {
this.sendResponse(reply, message.id, {
ok: true,
@@ -515,6 +1084,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, {
@@ -533,6 +1126,24 @@ window.WasmExtensionComponent = {
return
}
if (message.action === 'websocket.subscribe') {
this.subscribeWebsocket(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'websocket.unsubscribe') {
this.closeWebsocketSubscription(String(message.subscriptionId || ''))
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
throw new Error('Unknown extension bridge action.')
} catch (error) {
this.sendResponse(reply, message.id, {
+2
View File
@@ -67,6 +67,8 @@
"js/components/admin/lnbits-admin-users.js",
"js/components/admin/lnbits-admin-server.js",
"js/components/admin/lnbits-admin-extensions.js",
"js/components/admin/lnbits-admin-wasm-runtime.js",
"js/components/admin/lnbits-admin-wasm-limit-config.js",
"js/components/admin/lnbits-admin-notifications.js",
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
+2
View File
@@ -8,6 +8,8 @@ include('components/admin/users.vue') %} {%
include('components/admin/site_customisation.vue') %} {%
include('components/admin/audit.vue') %} {%
include('components/admin/extensions.vue') %} {%
include('components/admin/wasm-runtime.vue') %} {%
include('components/admin/wasm-limit-config.vue') %} {%
include('components/admin/assets-config.vue') %} {%
include('components/admin/notifications.vue') %} {%
include('components/admin/server.vue') %} {%
@@ -1,9 +1,11 @@
<template id="lnbits-admin-extensions">
<q-card-section class="q-pa-none">
<div>
<h6 class="q-my-none">
<span v-text="$t('extensions')"></span>
</h6>
<div class="row items-center justify-between q-mb-md">
<h6 class="q-my-none">
<span v-text="$t('extensions')"></span>
</h6>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 q-mb-md">
<p>
@@ -33,6 +35,27 @@
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 q-mb-md">
<p>Wasm Extension</p>
<div class="row q-gutter-sm">
<q-btn
unelevated
color="primary"
icon="memory"
label="WASM Runtime"
to="/admin/extensions/wasm"
></q-btn>
<q-btn
unelevated
color="primary"
icon="tune"
label="Wasm Limit Config"
to="/admin/extensions/wasm/limits"
></q-btn>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>
@@ -124,6 +147,15 @@
/>
</q-item-section>
</q-item>
<q-input
class="q-mt-md"
filled
v-model.number="formData.lnbits_wasm_invocation_retention_days"
type="number"
min="0"
label="WASM invocation retention days"
hint="Set to 0 to disable automatic cleanup."
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
@@ -0,0 +1,242 @@
<template id="lnbits-admin-wasm-limit-config">
<q-card-section class="q-pa-none">
<div>
<div class="row items-center justify-between q-mb-md q-col-gutter-sm">
<div class="row items-center q-gutter-sm">
<q-btn flat dense round icon="arrow_back" :to="backRoute">
<q-tooltip v-text="backTooltip"></q-tooltip>
</q-btn>
<div>
<h6 class="q-my-none">Wasm Limit Config</h6>
<div
class="text-caption text-grey-7"
v-text="pageDescription"
></div>
</div>
</div>
<q-btn-dropdown
unelevated
color="primary"
icon="tune"
label="Custom Extension Limit"
:loading="wasmRuntimeLimitExtensionsLoading"
>
<q-list style="min-width: 280px; max-width: min(420px, 90vw)">
<q-item-label header>
Select an extension from the list to customize
</q-item-label>
<q-item
v-if="
!wasmRuntimeLimitExtensionsLoading &&
wasmRuntimeLimitExtensionOptions.length === 0
"
>
<q-item-section>
<q-item-label>No installed WASM extensions found.</q-item-label>
</q-item-section>
</q-item>
<q-item
v-for="extension in wasmRuntimeLimitExtensionOptions"
:key="extension.value"
clickable
v-close-popup
@click="openWasmExtensionLimit(extension.value)"
>
<q-item-section>
<q-item-label v-text="extension.label"></q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<template v-if="!isExtensionLimitRoute">
<div
v-for="(group, index) in wasmRuntimeLimitGroups"
:key="group.title"
class="q-mb-md"
>
<q-expansion-item
expand-separator
:default-opened="group.title === 'Execution'"
:label="group.title"
header-class="text-subtitle2 text-weight-medium"
>
<div class="row q-col-gutter-md q-pt-md">
<div
v-for="field in group.fields"
:key="field.name"
class="col-12 col-md-6"
>
<q-input
filled
dense
type="number"
min="0"
v-model.number="formData[field.name]"
:label="field.label"
:hint="field.description"
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="info"
color="primary"
@click.stop.prevent="showWasmLimitInfo(field)"
>
<q-tooltip max-width="360px">
<span v-text="field.details"></span>
</q-tooltip>
</q-btn>
</template>
</q-input>
</div>
</div>
</q-expansion-item>
<q-separator
v-if="index < wasmRuntimeLimitGroups.length - 1"
class="q-mt-md"
></q-separator>
</div>
</template>
<div
v-if="isExtensionLimitRoute"
class="row items-center justify-between q-mb-md"
>
<div>
<div class="text-subtitle1 text-weight-medium">
Extension overrides
</div>
<div class="text-caption text-grey-7">
Empty values inherit the global defaults. A saved 0 disables that
limit for the selected extension.
</div>
</div>
<div v-if="selectedWasmRuntimeLimitExtension" class="col-12 col-md-7">
<div class="row items-center q-gutter-sm full-height">
<q-chip
dense
:color="
selectedWasmRuntimeLimitExtension.active ? 'positive' : 'grey-7'
"
text-color="white"
:label="
selectedWasmRuntimeLimitExtension.active ? 'Active' : 'Inactive'
"
></q-chip>
<q-chip
dense
outline
color="primary"
:label="customWasmLimitCount + ' custom overrides'"
></q-chip>
</div>
</div>
</div>
<q-banner
v-if="
isExtensionLimitRoute &&
!wasmRuntimeLimitExtensionsLoading &&
!selectedWasmRuntimeLimitExtension
"
rounded
class="bg-grey-2 text-grey-8 q-mb-lg"
>
WASM extension not found.
</q-banner>
<div v-if="isExtensionLimitRoute && selectedWasmRuntimeLimitExtension">
<div
v-for="(group, index) in wasmRuntimeLimitGroups"
:key="'extension-' + group.title"
class="q-mb-md"
>
<q-expansion-item
expand-separator
:default-opened="group.title === 'Execution'"
:label="group.title"
header-class="text-subtitle2 text-weight-medium"
>
<div class="row q-col-gutter-md q-pt-md">
<div
v-for="field in group.fields"
:key="'extension-' + field.name"
class="col-12 col-md-6"
>
<q-input
filled
dense
type="number"
min="0"
v-model="wasmExtensionLimitDraft[field.name]"
:label="field.label"
:hint="wasmExtensionLimitHint(field)"
:placeholder="wasmExtensionLimitPlaceholder(field)"
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="info"
color="primary"
@click.stop.prevent="showWasmLimitInfo(field)"
>
<q-tooltip max-width="360px">
<span v-text="field.details"></span>
</q-tooltip>
</q-btn>
</template>
</q-input>
</div>
</div>
</q-expansion-item>
<q-separator
v-if="index < wasmRuntimeLimitGroups.length - 1"
class="q-mt-md"
></q-separator>
</div>
<div class="row justify-end q-gutter-sm q-mt-md">
<q-btn
flat
color="primary"
icon="restart_alt"
label="Clear Overrides"
:disable="wasmExtensionLimitsSaving"
@click="clearWasmExtensionLimits"
></q-btn>
<q-btn
unelevated
color="primary"
icon="save"
label="Save Extension Limits"
:loading="wasmExtensionLimitsSaving"
@click="saveWasmExtensionLimits"
></q-btn>
</div>
</div>
<q-dialog v-model="wasmLimitInfoDialog.show">
<q-card style="width: min(560px, calc(100vw - 32px)); max-width: 560px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6" v-text="wasmLimitInfoDialog.title"></div>
<q-space></q-space>
<q-btn v-close-popup flat round dense icon="close"></q-btn>
</q-card-section>
<q-card-section>
<div
class="text-body1"
style="line-height: 1.6"
v-text="wasmLimitInfoDialog.details"
></div>
</q-card-section>
</q-card>
</q-dialog>
</div>
</q-card-section>
</template>
@@ -0,0 +1,245 @@
<template id="lnbits-admin-wasm-runtime">
<q-card-section class="q-pa-none">
<div>
<div class="row items-center justify-between q-mb-md">
<div class="row items-center q-gutter-sm">
<q-btn
flat
dense
round
icon="arrow_back"
:to="
wasmExtensionId ? '/admin/extensions/wasm' : '/admin#extensions'
"
>
<q-tooltip
v-text="
wasmExtensionId ? 'Global WASM Runtime' : 'Extensions Settings'
"
></q-tooltip>
</q-btn>
<div>
<h6 class="q-my-none">WASM Runtime</h6>
<div
v-if="wasmExtensionId"
class="text-caption text-grey-7"
v-text="`Extension: ${wasmExtensionId}`"
></div>
</div>
</div>
<div>
<q-btn
flat
dense
icon="refresh"
:loading="wasmRuntimeLoading"
@click="fetchWasmRuntime"
>
<q-tooltip>Refresh runtime data</q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
<div
v-for="item in wasmStatItems"
:key="item.key"
class="col-6 col-sm-4 col-md-2"
>
<q-card flat bordered class="full-height">
<q-card-section class="q-pa-sm">
<div class="row items-center no-wrap q-gutter-sm">
<q-avatar
rounded
size="34px"
:color="item.color"
text-color="white"
:icon="item.icon"
></q-avatar>
<div class="col">
<div
class="text-caption text-grey-7"
v-text="item.label"
></div>
<div
class="text-h6 text-weight-bold"
v-text="formatWasmStat(item.key)"
></div>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<q-table
class="q-mb-lg"
dense
flat
wrap-cells
:rows="wasmCurrentInvocations"
:columns="wasmCurrentColumns"
row-key="id"
:loading="wasmRuntimeLoading"
:pagination="{rowsPerPage: 5}"
table-style="table-layout: fixed; width: 100%"
title="Current Invocations"
>
<template v-slot:body-cell-extension_id="props">
<q-td :props="props">
<q-btn
dense
flat
no-caps
color="primary"
:label="props.row.extension_id"
:to="`/admin/extensions/wasm/${encodeURIComponent(props.row.extension_id)}`"
></q-btn>
</q-td>
</template>
<template v-slot:body-cell-trigger_type="props">
<q-td :props="props">
<q-badge
outline
:color="wasmTriggerColor(props.row.trigger_type)"
v-text="props.row.trigger_type"
></q-badge>
</q-td>
</template>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge
:color="wasmStatusColor(props.row.status)"
v-text="props.row.status"
></q-badge>
</q-td>
</template>
<template v-slot:body-cell-user_id="props">
<q-td :props="props">
<span v-if="props.row.user_id">
<span v-text="formatWasmUserId(props.row.user_id)"></span>
<q-tooltip v-text="props.row.user_id"></q-tooltip>
</span>
<span v-else>-</span>
</q-td>
</template>
<template v-slot:body-cell-started_at="props">
<q-td
:props="props"
v-text="formatWasmDate(props.row.started_at)"
></q-td>
</template>
<template v-slot:body-cell-duration_ms="props">
<q-td :props="props" v-text="formatWasmDuration(props.row)"></q-td>
</template>
<template v-slot:body-cell-context="props">
<q-td :props="props">
<div
style="white-space: normal; word-break: break-word"
v-text="formatWasmContext(props.row)"
></div>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<div class="row justify-end q-gutter-xs">
<q-btn
dense
flat
color="negative"
icon="stop_circle"
@click="stopWasmInvocation(props.row.id)"
>
<q-tooltip>Stop Invocation</q-tooltip>
</q-btn>
<q-btn
dense
unelevated
color="negative"
label="Deactivate Extension"
@click="deactivateWasmExtension(props.row.extension_id)"
></q-btn>
</div>
</q-td>
</template>
</q-table>
<q-table
dense
flat
wrap-cells
:rows="wasmInvocationHistory"
:columns="wasmHistoryColumns"
row-key="id"
:loading="wasmHistoryLoading"
:pagination="{rowsPerPage: 10}"
table-style="table-layout: fixed; width: 100%"
title="Recent Invocations"
>
<template v-slot:body-cell-extension_id="props">
<q-td :props="props">
<q-btn
dense
flat
no-caps
color="primary"
:label="props.row.extension_id"
:to="`/admin/extensions/wasm/${encodeURIComponent(props.row.extension_id)}`"
></q-btn>
</q-td>
</template>
<template v-slot:body-cell-trigger_type="props">
<q-td :props="props">
<q-badge
outline
:color="wasmTriggerColor(props.row.trigger_type)"
v-text="props.row.trigger_type"
></q-badge>
</q-td>
</template>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge
:color="wasmStatusColor(props.row.status)"
v-text="props.row.status"
></q-badge>
</q-td>
</template>
<template v-slot:body-cell-user_id="props">
<q-td :props="props">
<span v-if="props.row.user_id">
<span v-text="formatWasmUserId(props.row.user_id)"></span>
<q-tooltip v-text="props.row.user_id"></q-tooltip>
</span>
<span v-else>-</span>
</q-td>
</template>
<template v-slot:body-cell-started_at="props">
<q-td
:props="props"
v-text="formatWasmDate(props.row.started_at)"
></q-td>
</template>
<template v-slot:body-cell-duration_ms="props">
<q-td :props="props" v-text="formatWasmDuration(props.row)"></q-td>
</template>
<template v-slot:body-cell-calls="props">
<q-td :props="props" v-text="formatWasmCalls(props.row)"></q-td>
</template>
<template v-slot:body-cell-context="props">
<q-td :props="props">
<div
style="white-space: normal; word-break: break-word"
v-text="formatWasmContext(props.row)"
></div>
</q-td>
</template>
<template v-slot:body-cell-error_message="props">
<q-td :props="props">
<span
style="white-space: normal; word-break: break-word"
v-text="props.row.error_message || props.row.stop_reason || ''"
></span>
</q-td>
</template>
</q-table>
</div>
</q-card-section>
</template>
@@ -9,28 +9,31 @@
>
<template v-slot:header>
<q-item-section>
<q-item-label class="text-weight-medium">
<span v-text="permission.label"></span>
</q-item-label>
</q-item-section>
<q-item-section
v-if="permission.risk.level !== 'low' || permission.badges.length"
side
top
>
<div class="row items-center justify-end q-gutter-xs">
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
<div class="row items-center q-col-gutter-x-md q-row-gutter-sm">
<q-item-label
class="text-weight-medium col-auto"
style="max-width: 100%"
>
<span v-text="permission.label"></span>
</q-item-label>
<div
v-if="permission.risk.level !== 'low' || permission.badges.length"
class="row items-center q-gutter-xs col-auto q-mb-xs"
style="max-width: 100%"
>
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
</div>
</div>
</q-item-section>
</template>
@@ -57,7 +60,24 @@
></p>
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
<li v-for="group of permission.fieldGroups" :key="group.table">
<span v-text="group.table"></span>
<div class="row items-center q-gutter-xs">
<span v-text="group.table"></span>
<template v-if="group.sourceIdField">
<q-badge
color="warning"
text-color="dark"
v-text="group.sourceIdField"
></q-badge>
<span
class="text-caption text-grey"
v-text="
$t(
'extension_permission_ext_storage_read_public_source_required'
)
"
></span>
</template>
</div>
<ul v-if="group.fields.length" class="q-pl-md">
<li
v-for="field of group.fields"
@@ -67,6 +87,35 @@
</ul>
</li>
</ul>
<div v-if="permission.appendPolicies.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="
$t('extension_permission_ext_storage_append_public_sources')
"
></div>
<ul class="q-my-sm q-pl-md">
<li
v-for="policy of permission.appendPolicies"
:key="
policy.table +
':' +
policy.sourceTable +
':' +
policy.sourceIdField
"
>
<span v-text="publicAppendPolicySentence(policy)"></span>
<ul v-if="policy.allowedFields.length" class="q-pl-md">
<li
v-for="field of policy.allowedFields"
:key="policy.table + ':' + field"
v-text="field"
></li>
</ul>
</li>
</ul>
</div>
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
<div
class="text-caption text-grey"
+13 -1
View File
@@ -74,7 +74,13 @@
<div class="col q-gutter-y-md">
<q-card>
<!-- Mobile: Dropdown menu at top -->
<div v-if="$q.screen.lt.md" class="q-px-md q-pt-md">
<div
v-if="
$q.screen.lt.md &&
!['wasm-runtime', 'wasm-limit-config'].includes(tab)
"
class="q-px-md q-pt-md"
>
<q-select
v-model="tab"
:options="[
@@ -220,6 +226,12 @@
<q-tab-panel name="extensions">
<lnbits-admin-extensions :form-data="formData" />
</q-tab-panel>
<q-tab-panel name="wasm-runtime">
<lnbits-admin-wasm-runtime :form-data="formData" />
</q-tab-panel>
<q-tab-panel name="wasm-limit-config">
<lnbits-admin-wasm-limit-config :form-data="formData" />
</q-tab-panel>
<q-tab-panel name="notifications">
<lnbits-admin-notifications :form-data="formData" />
</q-tab-panel>
+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
View File
@@ -120,6 +120,8 @@
"js/components/admin/lnbits-admin-users.js",
"js/components/admin/lnbits-admin-server.js",
"js/components/admin/lnbits-admin-extensions.js",
"js/components/admin/lnbits-admin-wasm-runtime.js",
"js/components/admin/lnbits-admin-wasm-limit-config.js",
"js/components/admin/lnbits-admin-notifications.js",
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
+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"}],
}
],
}
+1 -1
View File
@@ -89,7 +89,7 @@ def run_before_and_after_tests(settings: Settings):
@pytest.fixture(scope="session")
async def app(settings: Settings):
app = create_app()
async with LifespanManager(app) as manager:
async with LifespanManager(app, startup_timeout=30) as manager:
settings.first_install = True
await first_install(
UpdateSuperuserPassword(
+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"
+10 -3
View File
@@ -1433,7 +1433,7 @@ def test_check_revolut_signature_multiple_v1_headers():
check_revolut_signature(payload, sig_header, timestamp, secret)
def test_check_revolut_signature_docs_vector():
def test_check_revolut_signature_docs_vector(mocker: MockerFixture):
payload = (
b'{"data":{"id":"645a7696-22f3-aa47-9c74-cbae0449cc46",'
b'"new_state":"completed","old_state":"pending",'
@@ -1445,9 +1445,16 @@ def test_check_revolut_signature_docs_vector():
secret = "wsk_r59a4HfWVAKycbCaNO1RvgCJec02gRd8"
sig = "v1=bca326fb378d0da7f7c490ad584a8106bab9723d8d9cdd0d50b4c5b3be3837c0"
check_revolut_signature(
payload, sig, timestamp, secret, tolerance_seconds=100000000
# This is a fixed vector straight from Revolut's docs, so its timestamp is
# necessarily in the past. Freeze time to it instead of growing
# tolerance_seconds indefinitely as real time marches on.
mocker.patch(
"lnbits.core.services.fiat_providers.time.time",
return_value=int(timestamp) / 1000,
)
check_revolut_signature(payload, sig, timestamp, secret)
check_revolut_signature(payload, sig, timestamp, secret)
@pytest.mark.anyio
+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"}],
)
+503
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,17 +14,25 @@ from lnbits.core.crud import (
)
from lnbits.core.models.extensions import (
Extension,
ExtensionPermission,
InstallableExtension,
ReleasePaymentInfo,
)
from lnbits.core.services import extensions as extension_services
from lnbits.core.services.extensions import (
activate_extension,
attach_wasm_invocation_runtime,
deactivate_extension,
finish_wasm_invocation,
get_current_wasm_invocations,
get_valid_extension,
get_valid_extensions,
install_extension,
record_wasm_invocation_host_call,
start_extension_background_work,
start_wasm_invocation,
stop_extension_background_work,
stop_wasm_invocation,
uninstall_extension,
)
from lnbits.settings import Settings
@@ -143,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
@@ -197,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,
@@ -219,6 +312,416 @@ async def test_stop_extension_background_work_handles_missing_and_async_stops(
assert called["stop"] is True
@pytest.mark.anyio
async def test_wasm_invocation_tracking_counts_and_stops(mocker: MockerFixture):
_reset_wasm_invocation_state()
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(),
)
invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
trigger_type="http",
method="POST",
path="/api/v1/ext/demoext/run",
context={"origin": "https://example.com"},
)
store = SimpleNamespace(deadline=None)
store.set_epoch_deadline = lambda deadline: setattr(store, "deadline", deadline)
engine = SimpleNamespace(increments=0)
def increment_epoch():
engine.increments += 1
engine.increment_epoch = increment_epoch
attach_wasm_invocation_runtime(invocation.id, engine=engine, store=store)
record_wasm_invocation_host_call(invocation.id, "http.request")
record_wasm_invocation_host_call(invocation.id, "storage.get")
assert await stop_wasm_invocation(invocation.id, reason="test stop") is True
current = get_current_wasm_invocations()
assert current[0].status == "stopping"
assert store.deadline == 1
assert engine.increments == 1
await finish_wasm_invocation(invocation.id, status="failed")
assert update_mock.await_args is not None
saved = update_mock.await_args.args[0]
assert saved.status == "stopped"
assert saved.stop_reason == "test stop"
assert saved.host_call_count == 2
assert saved.http_call_count == 1
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()
extension_services._wasm_invocations_marked_stale = False
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
try:
settings.wasm_runtime_max_execution_ms = 5_000
settings.wasm_runtime_max_memory_bytes = 64 * 1024 * 1024
extension = InstallableExtension(
id="wasm_demo",
name="WASM Demo",
version="1.0.0",
wasm_runtime_limits={
"wasm_runtime_max_execution_ms": 20_000,
"wasm_runtime_max_fuel": 0,
},
)
limits = extension_services.resolve_wasm_runtime_limits(extension)
assert limits["wasm_runtime_max_execution_ms"] == 20_000
assert limits["wasm_runtime_max_fuel"] == 0
assert limits["wasm_runtime_max_memory_bytes"] == 64 * 1024 * 1024
finally:
settings.wasm_runtime_max_execution_ms = original_execution_ms
settings.wasm_runtime_max_memory_bytes = original_memory_bytes
def test_wasm_runtime_limit_override_validation():
assert extension_services.validate_wasm_runtime_limit_overrides(
{
"wasm_runtime_max_execution_ms": "7000",
"wasm_runtime_max_fuel": 0,
"wasm_runtime_max_memory_bytes": "",
}
) == {
"wasm_runtime_max_execution_ms": 7000,
"wasm_runtime_max_fuel": 0,
}
with pytest.raises(ValueError, match="Unknown WASM runtime limit field"):
extension_services.validate_wasm_runtime_limit_overrides({"unknown": 1})
with pytest.raises(ValueError, match="cannot be negative"):
extension_services.validate_wasm_runtime_limit_overrides(
{"wasm_runtime_max_execution_ms": -1}
)
with pytest.raises(ValueError, match="must be an integer"):
extension_services.validate_wasm_runtime_limit_overrides(
{"wasm_runtime_max_execution_ms": True}
)
with pytest.raises(ValueError, match="must be an integer"):
extension_services.validate_wasm_runtime_limit_overrides(
{"wasm_runtime_max_execution_ms": 1.5}
)
@pytest.mark.anyio
async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides(
tmp_path,
settings: Settings,
mocker: MockerFixture,
):
ext_id = "wasm_demo"
original_extensions_path = settings.lnbits_extensions_path
try:
settings.lnbits_extensions_path = str(tmp_path)
config_dir = tmp_path / "extensions" / ext_id
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(
'{"extension_type": "wasm"}',
encoding="utf-8",
)
installed_extension = InstallableExtension(
id=ext_id,
name="WASM Demo",
version="1.0.0",
)
mocker.patch(
"lnbits.core.services.extensions.get_installed_extension",
mocker.AsyncMock(return_value=installed_extension),
)
update_mock = mocker.patch(
"lnbits.core.services.extensions."
"update_installed_extension_wasm_runtime_limits",
mocker.AsyncMock(),
)
saved_limits = await extension_services.update_wasm_extension_runtime_limits(
ext_id,
{
"wasm_runtime_max_execution_ms": "15000",
"wasm_runtime_max_fuel": 0,
"wasm_runtime_max_memory_bytes": "",
},
)
finally:
settings.lnbits_extensions_path = original_extensions_path
assert saved_limits == {
"wasm_runtime_max_execution_ms": 15000,
"wasm_runtime_max_fuel": 0,
}
update_mock.assert_awaited_once_with(ext_id=ext_id, limits=saved_limits)
@pytest.mark.anyio
async def test_wasm_invocation_concurrency_limits(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.update(
{
"wasm_runtime_max_concurrent_invocations": 1,
"wasm_runtime_max_concurrent_invocations_per_extension": 1,
"wasm_runtime_max_concurrent_invocations_per_user": 1,
}
)
invocation = await start_wasm_invocation(
extension_id="demoext",
export_name="render",
user_id="user-id",
runtime_limits=limits,
)
with pytest.raises(ValueError, match="too many active invocations"):
await start_wasm_invocation(
extension_id="demoext",
export_name="render",
user_id="user-id",
runtime_limits=limits,
)
await finish_wasm_invocation(invocation.id, status="completed")
@pytest.mark.anyio
async def test_wasm_invocation_host_call_limits(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"] = 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="host call limit"):
record_wasm_invocation_host_call(invocation.id, "storage.get")
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,64 @@
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_realtime_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 "extensionRoute(path)" in bridge
assert "Extension route must stay inside this extension." in bridge
assert "message.action === 'payment.subscribe'" in bridge
assert "message.action === 'payment.unsubscribe'" in bridge
assert "message.action === 'websocket.subscribe'" in bridge
assert "message.action === 'websocket.unsubscribe'" in bridge
assert "message.action === 'navigation.replace'" in bridge
assert "hasBridgePermission('websocket.subscribe')" in bridge
assert "ext:${this.bridge.extensionId}:${itemId}" 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
+684
View File
@@ -0,0 +1,684 @@
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,
StorageAppendPublicRequest,
StorageGetRequest,
StoragePublicPaginatedRequest,
WalletBalanceRequest,
WebsocketPublishRequest,
)
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_filters_public_paginated_storage_rows(
mocker: MockerFixture,
):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_paginated_rows",
mocker.AsyncMock(
return_value={
"data": [
{
"id": "message-1",
"thread_id": "thread-1",
"message": "Hello",
"admin_note": "secret",
}
],
"total": 1,
}
),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "thread_id", "message"],
}
],
)
],
)
response = await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
assert json.loads(response.rows_json) == [
{"id": "message-1", "thread_id": "thread-1", "message": "Hello"}
]
assert response.total == 1
storage_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_private_query_fields():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="non-public fields"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"admin_note": "secret"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_requires_source_policy():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="source ID field policy"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_conflicting_source_filter():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="does not match source_id"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"thread_id": "thread-2"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_websocket_publish_scopes_item_id(mocker: MockerFixture):
send_mock = mocker.patch(
"lnbits.core.services.websocket_manager.send",
mocker.AsyncMock(),
)
api = ExtensionHostAPI("demoext", ["websocket.publish"])
response = await api.websocket_publish(
WebsocketPublishRequest(
item_id="conversation:abc_123",
data={"message": "Hello"},
)
)
assert response.sent is True
send_mock.assert_awaited_once_with(
"ext:demoext:conversation:abc_123",
'{"message":"Hello"}',
)
@pytest.mark.anyio
async def test_host_api_websocket_publish_rejects_invalid_item_id():
api = ExtensionHostAPI("demoext", ["websocket.publish"])
with pytest.raises(ValueError, match="item ID"):
await api.websocket_publish(
WebsocketPublishRequest(
item_id="../other",
data={"message": "Hello"},
)
)
@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_public_append_uses_source_owner_and_allowed_fields(
mocker: MockerFixture,
):
owner_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
count_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_count_rows",
mocker.AsyncMock(return_value=0),
)
append_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_append_public_row",
mocker.AsyncMock(return_value="message-1"),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["name", "message"],
}
],
)
],
)
response = await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"name": "Alice", "message": "Hello"},
)
)
assert response.id == "message-1"
owner_mock.assert_awaited_once_with("demoext", "threads", "thread-1")
count_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
owner_id="owner-1",
)
append_mock.assert_awaited_once_with(
"demoext",
"messages",
{"name": "Alice", "message": "Hello", "thread_id": "thread-1"},
"owner-1",
)
@pytest.mark.anyio
async def test_host_api_public_append_rejects_disallowed_fields(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="disallowed fields"):
await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"message": "Hello", "admin": True},
)
)
@pytest.mark.anyio
async def test_host_api_public_append_enforces_row_limit(mocker: MockerFixture):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_count_rows",
mocker.AsyncMock(return_value=1),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 1,
}
],
)
],
)
with pytest.raises(PermissionError, match="limit reached"):
await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"message": "Hello"},
)
)
@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="",
)
)
+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
+223
View File
@@ -0,0 +1,223 @@
import json
from pathlib import Path
from typing import Any
import pytest
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.core.models.misc import WasmExtensionRegistry
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
from lnbits.core.wasm_ext.wasm.loader import WasmExtension, load_wasm_extension
from lnbits.settings import Settings
from tests.helpers import make_installable_extension
def test_load_wasm_extension_rejects_missing_config_id(
tmp_path: Path, settings: Settings
):
ext_id = "demoext"
_write_wasm_extension(settings, tmp_path, ext_id, config_id=None)
with pytest.raises(ValueError, match="config must define id"):
load_wasm_extension(ext_id)
def test_load_wasm_extension_rejects_mismatched_config_id(
tmp_path: Path, settings: Settings
):
ext_id = "demoext"
_write_wasm_extension(settings, tmp_path, ext_id, config_id="otherext")
with pytest.raises(ValueError, match="id mismatch"):
load_wasm_extension(ext_id)
def test_load_wasm_extension_uses_canonical_extension_id(
tmp_path: Path, settings: Settings
):
ext_id = "demoext"
_write_wasm_extension(settings, tmp_path, ext_id, config_id=ext_id)
extension = load_wasm_extension(ext_id)
assert extension.id == ext_id
def test_wasm_extension_config_ignores_unknown_fields():
config = _wasm_config("demoext")
config["unexpected"] = True
parsed = parse_wasm_extension_config("demoext", config)
assert not hasattr(parsed, "unexpected")
def test_wasm_extension_config_rejects_coerced_scalar_types():
config = _wasm_config("demoext")
config["wasm"] = {"module": 123}
with pytest.raises(ValueError, match="str type expected"):
parse_wasm_extension_config("demoext", config)
@pytest.mark.parametrize(
"config_update",
[
{"wasm": {"module": "extension.wasm", "host_api": "custom.HostAPI"}},
{
"wasm": {
"module": "extension.wasm",
"resource_limits": {"max_response_bytes": 1024},
}
},
{"build": {"source": "dev", "command": "npm run build"}},
],
)
def test_wasm_extension_config_ignores_removed_extension_control_fields(
config_update: dict[str, Any],
):
config = _wasm_config("demoext")
config.update(config_update)
parsed = parse_wasm_extension_config("demoext", config)
assert parsed.wasm.module == "extension.wasm"
assert not hasattr(parsed.wasm, "host_api")
assert not hasattr(parsed.wasm, "resource_limits")
assert not hasattr(parsed, "build")
def test_wasm_extension_config_accepts_supported_optional_sections():
config = _wasm_config("demoext")
config.update(
{
"tile": "static/icon.png",
"min_lnbits_version": "1.0.0",
"max_lnbits_version": "2.0.0",
"wasm": {
"module": "wasm/module.wasm",
"wit": "wasm/lnbits-extension.wit",
"world": "lnbits-extension",
"exports": [
{"name": "render", "visibility": "public"},
{"name": "on_invoice_paid", "visibility": "event"},
],
},
"events": {"onInvoicePaid": "on_invoice_paid"},
"ui": {"entrypoint": "static/index.html", "sandbox": True},
"sdk": {"frontend_js": "static/lnbits-extension-sdk.js"},
"ui_routes": [
{
"path": "/demo/{item_id}",
"entrypoint": "static/index.html",
"auth": "user",
"path_params": {"item_id": "str"},
}
],
"api_routes": [
{
"method": "GET",
"path": "/api/demo/{item_id}",
"export": "render",
"auth": "public",
"path_params": {"item_id": "str"},
}
],
"permissions": [{"id": "utils.basic", "description": "Basic utils"}],
}
)
parsed = parse_wasm_extension_config("demoext", config)
assert parsed.events.on_invoice_paid == "on_invoice_paid"
assert parsed.wasm.world == "lnbits-extension"
def test_wasm_extension_config_ignores_unknown_permission_fields():
config = _wasm_config("demoext")
config["permissions"] = [
{"id": "utils.basic", "label": "Basic utilities", "unknown": True}
]
parsed = parse_wasm_extension_config("demoext", config)
assert parsed.permissions == [ExtensionPermission(id="utils.basic")]
def test_install_time_permission_validation_rejects_config_id_mismatch():
ext_info = make_installable_extension("demoext")
extension_config = {
"id": "otherext",
"extension_type": "wasm",
"permissions": [{"id": "utils.basic"}],
}
with pytest.raises(ValueError, match="id mismatch"):
validate_wasm_extension_permissions(
ext_info,
[ExtensionPermission(id="utils.basic")],
extension_config,
)
def test_wasm_extension_registry_rejects_same_id_from_different_root(tmp_path: Path):
registry = WasmExtensionRegistry()
first = _wasm_extension("demoext", tmp_path / "one")
second_same_root = _wasm_extension("demoext", tmp_path / "one")
second_different_root = _wasm_extension("demoext", tmp_path / "two")
registry.register(first)
registry.register(second_same_root)
with pytest.raises(ValueError, match="already registered"):
registry.register(second_different_root)
def _write_wasm_extension(
settings: Settings,
tmp_path: Path,
ext_id: str,
*,
config_id: str | None,
) -> None:
settings.lnbits_extensions_path = str(tmp_path)
ext_dir = tmp_path / "extensions" / ext_id
ext_dir.mkdir(parents=True)
(ext_dir / "extension.wasm").write_bytes(b"\0asm")
config = {
"name": "Demo",
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
}
if config_id is not None:
config["id"] = config_id
(ext_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
def _wasm_extension(ext_id: str, root_path: Path) -> WasmExtension:
config = parse_wasm_extension_config(ext_id, _wasm_config(ext_id))
return WasmExtension(
id=ext_id,
name=ext_id,
version="1.0.0",
root_path=root_path,
module_path=root_path / "extension.wasm",
wit_path=None,
world="",
exports=[],
config=config,
)
def _wasm_config(ext_id: str) -> dict[str, Any]:
return {
"id": ext_id,
"name": ext_id,
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
}
@@ -0,0 +1,569 @@
from types import SimpleNamespace
from typing import Any, cast
import pytest
from pytest_mock.plugin import MockerFixture
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
from tests.helpers import make_installable_extension
def test_validate_wasm_permissions_rejects_broader_policy_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "http.request",
"policies": [{"host": "https://api.example.com"}],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="http.request",
policies=[
{"host": "https://api.example.com"},
{"host": "https://evil.example.com"},
],
)
],
extension_config,
)
def test_validate_wasm_permissions_stores_narrower_policy_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.read_public",
"description": "Read public storage.",
"policies": [
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title", "description"],
}
],
}
],
)
permissions = validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"],
}
],
)
],
extension_config,
)
assert permissions == [
ExtensionPermission(
id="ext.storage.read_public",
description="Read public storage.",
policies=[
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"],
}
],
)
]
def test_validate_wasm_permissions_rejects_public_read_source_field_omission():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.read_public",
"policies": [
{
"table_name": "messages",
"source_id_field": "conversation_id",
"public_fields": ["id", "body"],
}
],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "body"],
}
],
)
],
extension_config,
)
def test_validate_wasm_permissions_allows_narrower_public_append_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.append_public",
"description": "Append public messages.",
"policies": [
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["name", "message"],
"max_rows_per_source": 100,
}
],
}
],
)
permissions = validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 50,
}
],
)
],
extension_config,
)
assert permissions == [
ExtensionPermission(
id="ext.storage.append_public",
description="Append public messages.",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 50,
}
],
)
]
def test_validate_wasm_permissions_rejects_broader_public_append_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.append_public",
"policies": [
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 100,
}
],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message", "admin"],
"max_rows_per_source": 101,
}
],
)
],
extension_config,
)
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "extension.api.request",
"policies": [{"id": "targetext", "access": ["read"]}],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="extension.api.request",
policies=[{"id": "targetext", "access": ["read", "write"]}],
)
],
extension_config,
)
def test_validate_wasm_permissions_allows_empty_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "wallet.create_invoice_public",
"policies": [{"table": "tip_jars", "wallet_field": "wallet_id"}],
}
],
)
assert validate_wasm_extension_permissions(ext_info, [], extension_config) == []
def test_validate_wasm_permissions_rejects_unrequested_permission_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config("demoext", [{"id": "utils.basic"}])
with pytest.raises(ValueError, match="unrequested permissions"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(id="utils.basic"),
ExtensionPermission(id="wallet.list"),
],
extension_config,
)
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_validate_wasm_permissions_allows_websocket_permissions():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{"id": "websocket.publish"},
{"id": "websocket.subscribe"},
],
)
assert validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(id="websocket.publish"),
ExtensionPermission(id="websocket.subscribe"),
],
extension_config,
) == [
ExtensionPermission(id="websocket.publish"),
ExtensionPermission(id="websocket.subscribe"),
]
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,
):
extension = SimpleNamespace(
id="demoext",
config=_wasm_config(
"demoext",
[
{
"id": "wallet.create_invoice_public",
"policies": [
{"table": "requested_table", "wallet_field": "wallet_id"}
],
}
],
),
)
payment = SimpleNamespace(extra={"source_id": "source-1"})
installed_extension = SimpleNamespace(
permissions=[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "granted_table", "wallet_field": "wallet_id"}],
)
]
)
mocker.patch(
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
mocker.AsyncMock(return_value=installed_extension),
)
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.wasm.events.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
owner_id = await _wasm_invoice_paid_owner_id(extension, payment)
assert owner_id == "owner-1"
storage_mock.assert_awaited_once_with("demoext", "granted_table", "source-1")
@pytest.mark.anyio
async def test_wasm_invocation_requires_installed_active_extension(
mocker: MockerFixture,
):
extension = SimpleNamespace(id="demoext")
mocker.patch(
"lnbits.core.wasm_ext.wasm.invoke.get_installed_extension",
mocker.AsyncMock(return_value=None),
)
with pytest.raises(PermissionError, match="deactivated"):
await _active_installed_extension(cast(Any, extension))
def _wasm_config(ext_id: str, permissions: list[dict]) -> dict:
return {
"id": ext_id,
"name": ext_id,
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
"permissions": permissions,
}
+279
View File
@@ -0,0 +1,279 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from typing import cast
import pytest
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
async def test_wasm_json_reader_rejects_large_content_length_without_reading():
request = _FakeRequest([b"{}"], content_length="11")
with pytest.raises(WasmRequestBodyTooLargeError, match="11 bytes"):
await _read_json_object_with_size(cast(Request, request), max_body_bytes=10)
assert request.stream_started is False
@pytest.mark.anyio
async def test_wasm_json_reader_rejects_large_stream_without_content_length():
request = _FakeRequest([b'{"value":"', b"x" * 20, b'"}'])
with pytest.raises(WasmRequestBodyTooLargeError):
await _read_json_object_with_size(cast(Request, request), max_body_bytes=16)
assert request.stream_started is True
@pytest.mark.anyio
async def test_wasm_api_payload_records_actual_body_bytes():
body = b'{"amount":21}'
request = _FakeRequest(
[body],
path_params={"invoice_id": "abc"},
query_params={"include_paid": "true"},
)
payload = await _read_api_payload(
cast(Request, request),
{"invoice_id": "invoiceId"},
max_body_bytes=100,
)
assert payload.data == {
"invoiceId": "abc",
"includePaid": "true",
"amount": 21,
}
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"
def __init__(
self,
chunks: list[bytes],
*,
content_length: str | None = None,
path_params: dict[str, str] | None = None,
query_params: dict[str, str] | None = None,
) -> None:
self._chunks = chunks
self.headers: dict[str, str] = {}
if content_length is not None:
self.headers["content-length"] = content_length
self.path_params = path_params or {}
self.query_params = query_params or {}
self.stream_started = False
async def stream(self) -> AsyncIterator[bytes]:
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": [],
}
)
+487
View File
@@ -0,0 +1,487 @@
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Any, 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_append_public_row,
storage_count_rows,
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_public_append_generates_id_and_counts_by_owner(
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)
await migrate_wasm_extension_database(make_installable_extension(ext_id))
await storage_set_row(
ext_id,
"threads",
{"id": "thread-1", "title": "Support"},
"owner-1",
)
message_id = await storage_append_public_row(
ext_id,
"messages",
{"thread_id": "thread-1", "message": "Hello"},
"owner-1",
)
owner_count = await storage_count_rows(
ext_id,
"messages",
{"thread_id": "thread-1"},
owner_id="owner-1",
)
other_owner_count = await storage_count_rows(
ext_id,
"messages",
{"thread_id": "thread-1"},
owner_id="owner-2",
)
message = await storage_get_row(ext_id, "messages", message_id, "owner-1")
finally:
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_data_folder = original_data_folder
assert message_id
assert owner_count == 1
assert other_owner_count == 0
assert message is not None
assert message["id"] == message_id
assert message["thread_id"] == "thread-1"
assert message["message"] == "Hello"
@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: dict[str, Any] = {
"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"},
]
},
"threads": {
"fields": [
{"name": "id", "type": "string"},
{"name": "title", "type": "string"},
]
},
"messages": {
"fields": [
{"name": "id", "type": "string"},
{"name": "thread_id", "type": "string"},
{"name": "message", "type": "string"},
]
},
}
}
migration = {
"operations": [
{
"op": "create_table",
"table": "notes",
"fields": schema["tables"]["notes"]["fields"],
},
{
"op": "create_table",
"table": "threads",
"fields": schema["tables"]["threads"]["fields"],
},
{
"op": "create_table",
"table": "messages",
"fields": schema["tables"]["messages"]["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