Compare commits

..
Author SHA1 Message Date
Vlad Stan 9b6f453388 chore: make all 2026-07-09 16:49:18 +03:00
Vlad Stan 307100fabe Merge branch 'dev' into extensions_for_all_merge_01 2026-07-09 16:47:44 +03:00
Vlad Stan 32379687ab fix: dependencies 2026-07-09 09:26:13 +03:00
Vlad Stan aeb7735e51 refactor: fix deps 2026-07-08 15:32:22 +03:00
Vlad Stan 2ef3894f74 refactor: fix some imports 2026-07-08 15:12:35 +03:00
Vlad Stan 73f2c863eb refactor: fix imports 2026-07-08 15:04:17 +03:00
Vlad Stan d1be79052f refactor: extract import 2026-07-08 14:54:56 +03:00
Vlad Stan 28341dedfa refactor: extract permissions component 2026-07-08 14:41:59 +03:00
Vlad Stan 0b89c75386 chore: clean-up i18n 2026-07-08 14:20:41 +03:00
Vlad Stan e1827e33ad refactor: separate function 2026-07-08 14:15:31 +03:00
Vlad Stan ff109aab87 refactor: move function 2026-07-08 14:09:52 +03:00
Vlad Stan 84dfb5b3e9 refactor: extract 2026-07-08 14:04:14 +03:00
Vlad Stan ef5ac2af64 refactor: is_upgrade_extension 2026-07-08 12:24:27 +03:00
Vlad Stan b13b3b9b92 chore: make bundle 2026-07-08 11:58:07 +03:00
Vlad Stan 053671b10f feat: http.request permissions 2026-07-08 11:55:22 +03:00
Vlad Stan bf146c82b3 chore: lint 2026-07-08 11:55:20 +03:00
Vlad Stan 44dd0e1570 refactor: remove redundant check 2026-07-08 11:55:20 +03:00
Vlad Stan 9d6069a043 fix: policies 2026-07-08 11:55:20 +03:00
Vlad Stan e0fc3c89d1 refactor: checks 2026-07-08 11:55:18 +03:00
Vlad Stan 77606a9fc1 refactor: move logic out from host.py 2026-07-08 11:55:18 +03:00
Vlad Stan 1d5c6ac601 fea: nicer permissions 2026-07-08 11:55:18 +03:00
Vlad Stan 95adf7bfbb feat: ui polish 2026-07-08 11:55:16 +03:00
Vlad Stan d13046efbf fix: allow no param 2026-07-08 11:55:15 +03:00
Vlad Stan 0d969ec624 fix: start/stop background work 2026-07-08 11:55:15 +03:00
Vlad Stan 3150ae7be1 refactor: structure 2026-07-08 11:55:15 +03:00
Vlad Stan 4b8fca43cf refactor: extract functions 2026-07-08 11:55:15 +03:00
Vlad Stan a0901a33ca refactor: extract permissions logic 2026-07-08 11:55:15 +03:00
Vlad Stan 0e30dbc2af refactor: extract function 2026-07-08 11:55:15 +03:00
Vlad Stan 5d231b0c9e chore: clean-up 2026-07-08 11:55:15 +03:00
Vlad Stan 90600da5a5 fix: lint 2026-07-08 11:55:15 +03:00
Vlad Stan 8fee8425a8 feat: add camera permissions 2026-07-08 11:55:15 +03:00
Vlad Stan 28024e0608 feat: pay_invoice 2026-07-08 11:55:13 +03:00
Vlad Stan 236f8e44b6 fix: icon 2026-07-08 11:55:13 +03:00
Vlad Stan f3cdf85f3b refactor: components 2026-07-08 11:55:11 +03:00
Vlad Stan da412a2321 refactor: better namespace 2026-07-08 11:55:08 +03:00
Vlad Stan 385b3920d4 feat: add utils 2026-07-08 11:55:08 +03:00
Vlad Stan 6887d949c7 fix: access to extensions 2026-07-08 11:55:08 +03:00
Vlad Stan ab9e68741d feat: call extensions 2026-07-08 11:55:08 +03:00
Vlad Stan 17a7b5abab Revert "feat: public page http calls"
This reverts commit e7c359c1e0e21a8aeea391fd5b2cd6316526d42c.
2026-07-08 11:55:06 +03:00
Vlad Stan 9abced4eee feat: public page http calls 2026-07-08 11:55:06 +03:00
Vlad Stan 2a8a878fea fix: user required 2026-07-08 11:55:06 +03:00
Vlad Stan d1ebfdddac fix: loop issue 2026-07-08 11:55:06 +03:00
Vlad Stan 118fbd46f3 refactor: better imports 2026-07-08 11:55:06 +03:00
Vlad Stan 233d2c5e71 refactor: split loader from routes 2026-07-08 11:55:06 +03:00
Vlad Stan daf088b4ec fix: typing 2026-07-08 11:55:06 +03:00
alanandVlad Stan 1b03d8195d chore: make bundle [skip ci] 2026-07-08 11:55:06 +03:00
Vlad Stan 84e68e0fc3 fix: simplify user check 2026-07-08 11:55:03 +03:00
Vlad Stan c3fb049fa6 chore: update poetry.lock 2026-07-08 11:55:03 +03:00
Vlad Stan 487af32ccb feat: ext http request 2026-07-08 11:55:03 +03:00
Vlad Stan f20bd17321 chore: make bundle 2026-07-08 11:55:03 +03:00
Vlad Stan 368dcf1231 refactor: move code generator to tools 2026-07-08 11:54:58 +03:00
Vlad Stan e1437c0c0f refactor: remove unused param 2026-07-08 11:54:58 +03:00
Vlad Stan 8135b15404 refactor: move out dispatch_wasm_invoice_paid from tasks 2026-07-08 11:54:58 +03:00
Vlad Stan 62c651b3f2 refactor: remove extra param 2026-07-08 11:54:58 +03:00
Vlad Stan d5ee044454 refactor: app usage 2026-07-08 11:54:58 +03:00
Vlad Stan 55f244ed4e refactor: simplify 2026-07-08 11:54:58 +03:00
Vlad Stan 727d5fec8d fix: i18n 2026-07-08 11:54:58 +03:00
Vlad Stan feb90088ec feat: extra extra 2026-07-08 11:54:58 +03:00
Vlad Stan 63050dbbbd fix: i18n 2026-07-08 11:54:58 +03:00
Vlad Stan 4cba73c183 fix: owner id 2026-07-08 11:54:58 +03:00
Vlad Stan d1b795556e fix: create row on event 2026-07-08 11:54:58 +03:00
Vlad Stan 21f88d15e8 fix: create public invoice 2026-07-08 11:54:58 +03:00
Vlad Stan 46e31e35cd feat: create invoice public 2026-07-08 11:54:57 +03:00
Vlad Stan 6cd2746078 feat: public get 2026-07-08 11:54:57 +03:00
Vlad Stan b4ebbc36fe feat: finer grain permissions 2026-07-08 11:54:57 +03:00
Vlad Stan c519d86e57 fix: permissions 2026-07-08 11:54:57 +03:00
Vlad Stan b28d2bd9b1 fix: permissions 2026-07-08 11:54:57 +03:00
Vlad Stan 2b791d1c23 chore: clean-up 2026-07-08 11:54:57 +03:00
Vlad Stan b74b75d303 fix: navigation 2026-07-08 11:54:57 +03:00
Vlad Stan f7991e5231 fix: activate wasm extension 2026-07-08 11:54:57 +03:00
Vlad Stan e5e14ca2ad fix: ui 2026-07-08 11:54:57 +03:00
Vlad Stan 97a1a0b303 feat: ask permission 2026-07-08 11:54:57 +03:00
Vlad Stan 9c0035c01d feat: permissions 2026-07-08 11:54:57 +03:00
Vlad Stan 7614c83fe8 feat: ws bridge 2026-07-08 11:54:57 +03:00
Vlad Stan 21a517e34f chore: clean-up 2026-07-08 11:54:57 +03:00
Vlad Stan f42f973032 fix: icons 2026-07-08 11:54:57 +03:00
Vlad Stan 49a0bd9a1d fix: import lnbits resources 2026-07-08 11:54:57 +03:00
Vlad Stan 8a726ddaa0 feat: pagination 2026-07-08 11:54:57 +03:00
Vlad Stan c4ee44ed4b feat: store data 2026-07-08 11:54:57 +03:00
Vlad Stan 5fe4752ebf feat: db operations 2026-07-08 11:54:57 +03:00
Vlad Stan faa2b930ba fix: wallets 2026-07-08 11:54:57 +03:00
Vlad Stan fbc39bf423 feat: create unsafe real invoice 2026-07-08 11:54:57 +03:00
Vlad Stan d6236964be fix: user bound frame_token 2026-07-08 11:54:57 +03:00
Vlad Stan 60e4952b7f fix: links 2026-07-08 11:54:57 +03:00
Vlad Stan 412736631c feat: add wallet list 2026-07-08 11:54:57 +03:00
Vlad Stan 5187731c76 fix: postMessage uses '*' 2026-07-08 11:54:57 +03:00
Vlad Stan 4a3d019660 fix: frame in frame 2026-07-08 11:54:57 +03:00
Vlad Stan 6e2099cb4f fix: stricter asset loading 2026-07-08 11:54:57 +03:00
Vlad Stan 07327a5b57 fix: better error page 2026-07-08 11:54:57 +03:00
Vlad Stan 24b76f67aa fix: allow clipboard copy 2026-07-08 11:54:57 +03:00
Vlad Stan 7a071163cc fix: allow clipboard copy 2026-07-08 11:54:57 +03:00
Vlad Stan 1a5dc0da67 fix: external iframe access 2026-07-08 11:54:57 +03:00
Vlad Stan 4444f7a897 chore: code format 2026-07-08 11:54:57 +03:00
Vlad Stan 516b01c6eb fix: stricter headers for non-iframe usage 2026-07-08 11:54:57 +03:00
Vlad Stan 73bc5ddc16 fix: iframe communication 2026-07-08 11:54:56 +03:00
Vlad Stan 814d7bf45b fix: iframe style 2026-07-08 11:54:56 +03:00
Vlad Stan 5e17e5e13d fix: link 2026-07-08 11:54:56 +03:00
Vlad Stan 6223100591 feat: serve in iframe 2026-07-08 11:54:56 +03:00
Vlad Stan 989cd08614 chore: lint 2026-07-08 11:54:56 +03:00
Vlad Stan c426c23567 perf: cache wasm component 2026-07-08 11:54:56 +03:00
Vlad Stan 23b22e89c7 fix: REST paths 2026-07-08 11:54:56 +03:00
Vlad Stan 8425e7467e feat: call ext logic 2026-07-08 11:54:56 +03:00
Vlad Stan 42afbc0576 basic extension loading 2026-07-08 11:54:56 +03:00
Vlad Stan 937b9cb5f1 refactor: reorder 2026-07-08 11:54:56 +03:00
Vlad Stan 73c4d1b409 fix: remove public context 2026-07-08 11:54:56 +03:00
Vlad Stan 04a6ed5ba1 feat: simpler permissions 2026-07-08 11:54:56 +03:00
Vlad Stan e39384b3c8 feat: dumb 2026-07-08 11:54:56 +03:00
75 changed files with 258 additions and 8623 deletions
+1 -1
View File
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "uv --offline run 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)
task_manager.register_invoice_listener(dispatch_extension_invoice_paid, "core_wasm")
core_app_extra.dispatch_extension_invoice_paid = dispatch_extension_invoice_paid
# server logs for websocket
if settings.lnbits_admin_ui:
+2 -2
View File
@@ -18,13 +18,13 @@ 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 (
DateTrunc,
check_internal,
create_payment,
delete_expired_invoices,
delete_wallet_payment,
get_latest_payments_by_extension,
get_payment,
@@ -102,6 +102,7 @@ __all__ = [
"delete_accounts_no_wallets",
"delete_admin_settings",
"delete_dbversion",
"delete_expired_invoices",
"delete_installed_extension",
"delete_tinyurl",
"delete_unused_wallets",
@@ -155,7 +156,6 @@ __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,12 +1,7 @@
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
@@ -16,11 +11,6 @@ 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(
@@ -28,11 +18,6 @@ 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(
@@ -46,33 +31,6 @@ 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:
@@ -186,158 +144,3 @@ 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)},
)
+26
View File
@@ -238,6 +238,32 @@ async def get_payments_status_count() -> PaymentsStatusCount:
)
async def delete_expired_invoices(
conn: Connection | None = None,
) -> None:
# first we delete all invoices older than one month
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = :status AND amount > 0
AND time < {db.timestamp_placeholder("delta")}
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
)
# then we delete all invoices whose expiry date is in the past
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = :status AND amount > 0
AND expiry < {db.timestamp_placeholder("now")}
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "now": int(time())},
)
async def create_payment(
checking_id: str,
data: CreatePayment,
-66
View File
@@ -824,69 +824,3 @@ 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 '{}'")
+4 -139
View File
@@ -7,15 +7,12 @@ 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, StrictStr
from pydantic import BaseModel, Field
from lnbits.helpers import (
download_url,
@@ -82,13 +79,11 @@ class GitHubRepo(BaseModel):
class ExtensionPermission(BaseModel):
id: StrictStr
description: StrictStr | None = None
id: str
label: str | None = None
description: str | None = None
policies: list[Any] | None = None
class Config:
extra = "ignore"
@staticmethod
def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]:
return [
@@ -141,83 +136,11 @@ 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:
@@ -273,63 +196,6 @@ 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
@@ -505,7 +371,6 @@ 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:
+8 -17
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from collections.abc import Awaitable, Callable
from typing import Any
from pydantic import BaseModel
@@ -11,10 +10,17 @@ 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()
@@ -25,16 +31,8 @@ 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)
@@ -42,13 +40,6 @@ 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
+1 -546
View File
@@ -1,12 +1,5 @@
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
@@ -20,168 +13,22 @@ 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 WasmRuntimeLimits, settings
from lnbits.settings import 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,
@@ -200,8 +47,6 @@ 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)
@@ -245,394 +90,6 @@ 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)
@@ -656,8 +113,6 @@ 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)
+3 -9
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, daystart_timestamp
from lnbits.helpers import check_callback_url
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
@@ -374,13 +374,6 @@ async def update_pending_payments(wallet_id: str):
async def update_pending_payment(
payment: Payment, conn: Connection | None = None
) -> Payment:
if payment.is_in and payment.is_expired:
payment.status = PaymentState.FAILED
payment.labels.append("expired")
await update_payment(payment, conn=conn)
logger.info(f"invoice {payment.checking_id} expired, marked as failed")
return payment
status = await check_payment_status(payment)
if status.failed:
payment.status = PaymentState.FAILED
@@ -564,9 +557,10 @@ async def check_wallet_daily_withdraw_limit(
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments(
since=daystart_timestamp(),
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
+1 -507
View File
@@ -1,4 +1,3 @@
import json
import sys
import traceback
from http import HTTPStatus
@@ -10,7 +9,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_wallet, get_wallets_ids
from lnbits.core.crud.wallets import get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import (
SimpleStatus,
@@ -19,30 +18,17 @@ 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
@@ -50,17 +36,10 @@ 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
@@ -89,9 +68,6 @@ 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):
@@ -156,106 +132,6 @@ 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,
@@ -379,213 +255,6 @@ 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:
@@ -1003,178 +672,3 @@ 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
)
+1 -6
View File
@@ -189,9 +189,6 @@ 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)]
)
@@ -199,9 +196,7 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request,
ext_id: str | None = None,
user: User = Depends(check_user_exists),
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
return template_renderer().TemplateResponse(
request,
+4 -5
View File
@@ -158,6 +158,10 @@ 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,
@@ -169,11 +173,6 @@ 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.")
@@ -1,124 +0,0 @@
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)
+23 -159
View File
@@ -17,11 +17,6 @@ from ..storage.crud import (
storage_get_row,
storage_set_row,
)
from .background_payments import (
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
background_payment_extra,
invoice_amount_msat,
)
from .models import (
CreateInvoicePublicRequest,
CreateInvoiceRequest,
@@ -36,7 +31,6 @@ from .models import (
NowResponse,
PayInvoiceRequest,
PayInvoiceResponse,
PayLnurlRequest,
RandomIdRequest,
RandomIdResponse,
StorageDeleteRequest,
@@ -66,8 +60,6 @@ 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)
@@ -75,15 +67,9 @@ 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,
authenticated=self.has_authenticated_context(),
)
self.utils = ExtensionAPIUtils(self.extension_id, self.permissions)
@extension_api_method(
method_id="storage.get",
@@ -372,6 +358,7 @@ 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
@@ -380,135 +367,37 @@ 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:
if wallet is None or wallet.user != self.user_id:
raise PermissionError("Paying invoices 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 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 _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),
payment_request=request.payment_request,
max_sat=request.max_sat,
extra=extra,
description=request.description or lnurl_pay_response_text(res),
extra={"tag": self.extension_id, **request.extra},
description=request.description,
tag=self.extension_id,
)
except (
LnAddressError,
LnurlResponseException,
PaymentError,
PermissionError,
ValueError,
) as exc:
except (PaymentError, ValueError) as exc:
return PayInvoiceResponse(ok=False, error=str(exc))
return _pay_invoice_response(payment)
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,
)
@extension_api_method(
method_id="http.request",
@@ -524,15 +413,7 @@ 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,
timeout_ms=self.runtime_limits.get("wasm_runtime_http_timeout_ms"),
max_response_bytes=self.runtime_limits.get(
"wasm_runtime_max_http_response_bytes"
),
)
return await send_extension_http_request(self.extension_id, policies, request)
@extension_api_method(
method_id="extension.api.request",
@@ -553,10 +434,6 @@ 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(
@@ -703,16 +580,3 @@ 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
@@ -1,70 +0,0 @@
from __future__ import annotations
import json
from typing import Any
from lnurl import LnAddress, Lnurl
def normalize_lnurl(value: str) -> str:
normalized = value.strip()
if normalized.lower().startswith("lightning:"):
normalized = normalized[len("lightning:") :]
if "@" in normalized:
normalized = normalized.lower()
if not normalized:
raise ValueError("LNURL is required.")
return normalized
def lnurl_for_core(value: str) -> Lnurl | LnAddress:
normalized = normalize_lnurl(value)
if "@" in normalized:
return LnAddress(normalized)
return Lnurl(normalized)
def lnurl_payment_amount_for_core(amount: float) -> int:
if amount <= 0:
raise ValueError("Amount must be greater than zero.")
return round(amount * 1000)
def lnurl_payment_unit_for_core(currency: str) -> str:
unit = currency.strip().lower()
if not unit:
raise ValueError("Currency is required.")
if unit in {"sat", "sats"}:
return "sat"
return unit.upper()
def lnurl_pay_response_metadata_json(response: Any) -> str:
metadata = getattr(response, "metadata", None)
if metadata is None:
return "[]"
metadata_list = getattr(metadata, "list", None)
try:
if callable(metadata_list):
return json.dumps(metadata_list())
return json.dumps(metadata)
except TypeError:
return json.dumps(str(metadata))
def lnurl_pay_response_text(response: Any) -> str:
description = getattr(response, "description", None)
if description is not None:
return str(description)
metadata = getattr(response, "metadata", None)
text = getattr(metadata, "text", None)
return str(text) if text is not None else ""
def lnurl_pay_response_int(response: Any, snake_name: str, camel_name: str) -> int:
value = getattr(response, snake_name, None)
if value is None:
value = getattr(response, camel_name, 0)
return int(value or 0)
-27
View File
@@ -181,17 +181,6 @@ 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
@@ -293,22 +282,6 @@ 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
+10 -201
View File
@@ -3,18 +3,6 @@ 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.read_public",
"extension.api.request",
"http.request",
"wallet.create_invoice_public",
}
def validate_extension_permissions(
@@ -32,7 +20,7 @@ def validate_extension_permissions(
unknown_ids.append(permission.id)
if strict:
continue
normalized_permissions.append(permission.copy())
normalized_permissions.append(permission.copy(update={"label": None}))
if unknown_ids and strict:
raise ValueError(
@@ -46,17 +34,14 @@ def validate_extension_permissions(
def validate_wasm_extension_permissions(
ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None,
extension_config: dict[str, Any] | WasmExtensionConfig,
extension_config: dict[str, Any],
) -> list[ExtensionPermission]:
if isinstance(extension_config, WasmExtensionConfig):
config = extension_config
elif extension_config.get("extension_type") != "wasm":
if 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, config.permissions
ext_info.id,
ExtensionPermission.list_from_config(extension_config),
)
if not requested_permissions:
return []
@@ -64,187 +49,11 @@ def validate_wasm_extension_permissions(
if granted_permissions is None:
raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
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:
requested_ids = {permission.id for permission in requested_permissions}
granted_ids = {permission.id for permission in granted_permissions}
if requested_ids != granted_ids:
raise ValueError(
f"Extension '{ext_info.id}' was granted unrequested permissions: "
+ ", ".join(extra_granted_ids)
f"Extension '{ext_info.id}' was not granted all 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.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_fields in granted_tables.items():
requested_fields = requested_tables.get(table_name)
if requested_fields is None or not granted_fields.issubset(requested_fields):
return False
return True
def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]:
tables: dict[str, set[str]] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table_name = policy.get("table_name")
public_fields = policy.get("public_fields")
if (
not isinstance(table_name, str)
or table_name in tables
or not isinstance(public_fields, list)
):
continue
fields = {field for field in public_fields if isinstance(field, str) and field}
if fields:
tables[table_name] = fields
return tables
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
return requested_permissions
+1 -6
View File
@@ -10,12 +10,7 @@ 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",
"wallet.pay_invoice",
"wallet.pay_invoice_background",
"wallet.payments.watch",
}
_EXTENSION_RUNTIME_PERMISSION_IDS = {"ui.camera.scan_qr"}
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
-3
View File
@@ -31,9 +31,6 @@ 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)
+6 -94
View File
@@ -5,9 +5,6 @@ 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
@@ -18,12 +15,6 @@ 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,
@@ -39,8 +30,6 @@ from .models import (
InvoiceExpiryResponse,
InvoiceMemoResponse,
InvoicePaymentHashResponse,
LnurlResolveRequest,
LnurlResolveResponse,
RandomSecretAndHashRequest,
RandomSecretAndHashResponse,
SatsToFiatRequest,
@@ -54,39 +43,17 @@ from .registry import extension_api_method
class ExtensionAPIUtils:
def __init__(
self,
extension_id: str,
permissions: Iterable[str],
*,
authenticated: bool = False,
) -> None:
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
permission_set = set(permissions)
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
)
self.currencies = ExtensionCurrencyUtils(extension_id, permission_set)
self.server = ExtensionServerUtils(extension_id, permission_set)
self.lightning = ExtensionLightningUtils(extension_id, permission_set)
class _ExtensionAPIUtilsGroup:
def __init__(
self,
extension_id: str,
permissions: Iterable[str],
*,
authenticated: bool = False,
) -> None:
def __init__(self, extension_id: str, permissions: Iterable[str]) -> 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:
@@ -95,7 +62,7 @@ class _ExtensionAPIUtilsGroup:
)
def has_authenticated_context(self) -> bool:
return self.authenticated
return False
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
@@ -226,60 +193,6 @@ 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",
@@ -422,7 +335,6 @@ def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGro
return {
"utils.currencies": ExtensionCurrencyUtils,
"utils.server": ExtensionServerUtils,
"utils.lnurl": ExtensionLnurlUtils,
"utils.lightning": ExtensionLightningUtils,
}
+4 -27
View File
@@ -35,9 +35,6 @@ 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.")
@@ -58,7 +55,7 @@ async def send_extension_api_request(
try:
async with httpx.AsyncClient(
follow_redirects=False,
timeout=_timeout_seconds(timeout_ms, EXTENSION_API_TIMEOUT_SECONDS),
timeout=EXTENSION_API_TIMEOUT_SECONDS,
trust_env=False,
) as client:
async with client.stream(
@@ -67,10 +64,7 @@ async def send_extension_api_request(
headers={"Authorization": f"Bearer {access_token}"},
content=body,
) as response:
response_body = await _read_limited_response(
response,
max_response_bytes=max_response_bytes,
)
response_body = await _read_limited_response(response)
return HttpResponse(
status_code=response.status_code,
headers=_response_headers(dict(response.headers)),
@@ -183,34 +177,17 @@ def _extension_api_path(path: str) -> str:
return urlunsplit(("", "", parts.path, parts.query, ""))
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
)
async def _read_limited_response(response: httpx.Response) -> bytes:
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if limit > 0 and size > limit:
if size > EXTENSION_API_MAX_RESPONSE_BYTES:
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
+4 -25
View File
@@ -32,9 +32,6 @@ 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)
@@ -52,7 +49,7 @@ async def send_extension_http_request(
try:
async with httpx.AsyncClient(
follow_redirects=False,
timeout=_timeout_seconds(timeout_ms, HTTP_REQUEST_TIMEOUT_SECONDS),
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
trust_env=False,
) as client:
async with client.stream(
@@ -61,10 +58,7 @@ async def send_extension_http_request(
headers=headers,
content=body,
) as response:
response_body = await _read_limited_response(
response,
max_response_bytes=max_response_bytes,
)
response_body = await _read_limited_response(response)
return HttpResponse(
status_code=response.status_code,
headers=_response_headers(dict(response.headers)),
@@ -170,32 +164,17 @@ def _request_headers(headers: dict[str, str]) -> dict[str, str]:
return clean
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
)
async def _read_limited_response(response: httpx.Response) -> bytes:
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if limit > 0 and size > limit:
if size > HTTP_MAX_RESPONSE_BYTES:
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
+18 -111
View File
@@ -2,46 +2,32 @@ 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.api_routes:
for route_config in extension.config.get("api_routes") or []:
_add_wasm_extension_api_route(app, extension, route_config)
def _add_wasm_extension_api_route(
app: FastAPI,
extension: WasmExtension,
route_config: WasmAPIRouteConfig,
route_config: dict[str, Any],
) -> None:
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)
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"))
if _has_route(app, route_path, method):
return
@@ -52,27 +38,14 @@ def _add_wasm_extension_api_route(
access_token: str | None = None,
) -> dict[str, Any]:
try:
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"],
)
payload = await _read_api_payload(request, path_params)
return await invoke_wasm_extension_export(
extension.id,
export_name,
payload.data,
payload,
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:
@@ -106,70 +79,22 @@ def _add_wasm_extension_api_route(
async def _read_api_payload(
request: Request,
path_params: dict[str, str],
*,
max_body_bytes: int,
) -> WasmRoutePayload:
) -> dict[str, Any]:
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"}:
body, request_bytes = await _read_json_object_with_size(
request,
max_body_bytes=max_body_bytes,
)
payload.update(body)
return WasmRoutePayload(payload, request_bytes)
payload.update(await _read_json_object(request))
return payload
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)
async def _read_json_object(request: Request) -> dict[str, Any]:
body = await request.body()
if not body:
return {}, 0
return {}
value = json.loads(body)
if not isinstance(value, dict):
raise TypeError("WASM extension API payload must be a JSON object.")
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)
return value
def _read_api_path_params(
@@ -187,32 +112,14 @@ 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.name != export_name:
if export.get("name") != export_name:
continue
if export.visibility in {"public", "authenticated"}:
if export.get("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,7 +15,6 @@ 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)
+15 -16
View File
@@ -17,7 +17,6 @@ from lnbits.decorators import (
from ..wasm.loader import WasmExtension
from .api import (
WasmRequestBodyTooLargeError,
_has_route,
_path_template_pattern,
_read_json_object,
@@ -38,11 +37,13 @@ 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.ui_routes):
route_path = _wasm_extension_ui_route_path(extension, route_config.path)
entrypoint = _wasm_extension_entrypoint(extension, route_config.entrypoint)
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")
)
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.auth)
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
_add_wasm_extension_wrapper_route(
app,
@@ -67,8 +68,6 @@ 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
@@ -189,13 +188,13 @@ def _wasm_extension_bridge_api_routes(
public: bool,
) -> list[dict[str, str]]:
routes: list[dict[str, str]] = []
for route_config in extension.config.api_routes:
auth = _wasm_extension_route_auth(extension, route_config.auth)
for route_config in extension.config.get("api_routes") or []:
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
if public and auth != "public":
continue
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)
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"))
routes.append(
{
"method": method,
@@ -217,16 +216,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.ui_routes):
route_path = _wasm_extension_ui_route_path(extension, route_config.path)
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"))
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.auth),
"path_params": route_config.path_params,
"auth": _wasm_extension_route_auth(extension, route_config.get("auth")),
"path_params": route_config.get("path_params") or {},
"route_params": route_params,
}
+9 -30
View File
@@ -12,7 +12,7 @@ from lnbits.core.crud import update_migration_version
from lnbits.core.db import db as core_db
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import POSTGRES, SQLITE, Compat, Connection, Database
from lnbits.db import POSTGRES, SQLITE, Connection, Database
from lnbits.settings import settings
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
@@ -76,16 +76,9 @@ 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)
columns = list(clean_data.keys())
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}")
columns = list(clean_data.keys())
placeholders = [f":{column}" for column in columns]
updates = [
f"{column} = excluded.{column}"
for column in columns
@@ -94,19 +87,19 @@ async def storage_set_row(
conflict_sql = (
"DO UPDATE SET "
+ ", ".join(updates)
+ f" WHERE storage_row.{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
+ f" WHERE {OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
if updates
else "DO NOTHING"
)
query = f"""
INSERT INTO {_table_ref_for_schema(ext_id, table)} AS storage_row
INSERT INTO {_table_ref_for_schema(ext_id, table)}
({", ".join(columns)})
VALUES
({", ".join(placeholders)})
ON CONFLICT (id) {conflict_sql}
""" # noqa: S608
async with database.connect() as conn:
async with Database(f"ext_{ext_id}").connect() as conn:
await conn.execute(query, clean_data)
@@ -124,10 +117,7 @@ async def storage_get_paginated_rows(
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
)
where_sql, values = _where_sql(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)
@@ -147,7 +137,7 @@ async def storage_get_paginated_rows(
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
async with Database(f"ext_{ext_id}").connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
@@ -413,25 +403,14 @@ 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)
fields = _fields_by_name(table_schema)
clauses = [
f"{field} = {_value_placeholder(db, fields[field], f'filter_{field}')}"
for field in clean_filters
]
clauses = [f"{field} = :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 ""
+4 -27
View File
@@ -5,8 +5,6 @@ from typing import Any
from wasmtime import Config, Engine
from lnbits.settings import settings
from .loader import WasmExtension
@@ -14,37 +12,19 @@ def warm_wasm_extension(extension: WasmExtension) -> None:
_wasm_component(extension)
@lru_cache(maxsize=8)
def _wasm_engine(max_wasm_stack_bytes: int | None = None) -> Any:
@lru_cache(maxsize=1)
def _wasm_engine() -> 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,
limits: dict[str, int] | None = None,
) -> Any:
def _wasm_component(extension: WasmExtension) -> 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,
)
@@ -53,10 +33,7 @@ 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(max_wasm_stack_bytes), module_path
)
return component.Component.from_file(_wasm_engine(), module_path)
-115
View File
@@ -1,115 +0,0 @@
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
+19 -153
View File
@@ -1,56 +1,22 @@
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 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),
)
if not extension_id:
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)
extension = core_app_extra.wasm_extension_registry.get(extension_id)
if not extension:
return
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
@@ -68,12 +34,7 @@ async def _dispatch_wasm_invoice_paid_to_extension(
export_name,
_wasm_invoice_paid_payload(payment),
context="event",
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,
owner_id=await _wasm_invoice_paid_owner_id(extension, payment),
)
except Exception as exc:
logger.warning(
@@ -82,86 +43,6 @@ async def _dispatch_wasm_invoice_paid_to_extension(
)
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
@@ -173,7 +54,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 = await _wasm_public_invoice_source_tables(extension.id)
source_tables = _wasm_public_invoice_source_tables(extension.config)
if not source_id or not source_tables:
return None
@@ -190,31 +71,14 @@ def _payment_source_id(payment: Any) -> str | None:
return source_id if isinstance(source_id, str) and source_id else None
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]:
def _wasm_public_invoice_source_tables(config: dict[str, Any]) -> list[str]:
permissions = config.get("permissions") or []
for permission in permissions:
permission_id = (
permission.get("id")
if isinstance(permission, dict)
else getattr(permission, "id", None)
)
if permission_id != "wallet.create_invoice_public":
if not isinstance(permission, dict):
continue
policies = (
permission.get("policies")
if isinstance(permission, dict)
else getattr(permission, "policies", None)
)
if permission.get("id") != "wallet.create_invoice_public":
continue
policies = permission.get("policies")
if not isinstance(policies, list):
return []
return [
@@ -227,14 +91,16 @@ def _wasm_public_invoice_source_tables_from_permissions(
return []
def _wasm_invoice_paid_export(config: Any) -> str | None:
return config.events.on_invoice_paid
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 _is_wasm_event_export(extension: Any, export_name: str) -> bool:
for export in extension.exports:
if export.name == export_name:
return export.visibility == "event"
if export.get("name") == export_name:
return export.get("visibility") == "event"
return False
+23 -182
View File
@@ -9,7 +9,6 @@ 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
@@ -17,9 +16,6 @@ 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,
@@ -30,52 +26,9 @@ 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)
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,
)
permissions = await _extension_permissions(extension)
api = ExtensionHostAPI(
extension.id,
permissions,
@@ -83,90 +36,17 @@ 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()
thread_task = asyncio.create_task(
asyncio.to_thread(
_invoke_wasm_extension_export_sync,
extension,
export_name,
payload,
api,
event_loop,
invocation.id,
limits,
)
return await asyncio.to_thread(
_invoke_wasm_extension_export_sync,
extension,
export_name,
payload or {},
api,
event_loop,
)
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(
@@ -175,24 +55,17 @@ 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(limits["wasm_runtime_max_wasm_stack_bytes"])
engine = _wasm_engine()
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, limits)
wasm_component = _wasm_component(extension)
instance = linker.instantiate(store, wasm_component)
function = instance.get_func(store, export_name)
if not function:
@@ -202,17 +75,21 @@ def _invoke_wasm_extension_export_sync(
result = function(store, json.dumps(payload))
function.post_return(store)
return _parse_wasm_export_result(result, limits)
return _parse_wasm_export_result(extension, result)
def _parse_wasm_export_result(value: Any, limits: dict[str, int]) -> dict[str, Any]:
def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]:
if isinstance(value, bytes):
value = value.decode()
if not isinstance(value, str):
return {"ok": True, "data": value}
max_response_bytes = limits["wasm_runtime_max_response_bytes"]
if max_response_bytes > 0:
max_response_bytes = (
(extension.config.get("wasm") or {})
.get("resource_limits", {})
.get("max_response_bytes")
)
if isinstance(max_response_bytes, int):
response_size = len(value.encode())
if response_size > max_response_bytes:
raise ValueError(
@@ -232,48 +109,12 @@ def _get_registered_extension(ext_id: str) -> WasmExtension:
raise RuntimeError(f"WASM extension '{ext_id}' is not registered.")
async def _active_installed_extension(extension: WasmExtension) -> Any:
async def _extension_permissions(extension: WasmExtension) -> list[Any]:
installed_extension = await get_installed_extension(extension.id)
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
if not installed_extension:
return []
return installed_extension.permissions
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())
+18 -25
View File
@@ -5,11 +5,6 @@ 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
@@ -22,13 +17,13 @@ class WasmExtension:
module_path: Path
wit_path: Path | None
world: str
exports: list[WasmExtensionExport]
config: WasmExtensionConfig
host_api: str
exports: list[dict[str, Any]]
config: dict[str, Any]
def is_wasm_extension_id(ext_id: str) -> bool:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
config = _load_json(ext_dir / "config.json")
config = load_wasm_extension_config(ext_id)
return bool(config and config.get("extension_type") == "wasm")
@@ -37,38 +32,36 @@ 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) -> WasmExtensionConfig | None:
def load_wasm_extension_config(ext_id: str) -> dict[str, Any] | None:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
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)
return _load_json(ext_dir / "config.json")
def load_wasm_extension(ext_id: str) -> WasmExtension:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
raw_config = _load_json(ext_dir / "config.json")
if not raw_config:
config = load_wasm_extension_config(ext_id)
if not config:
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
if raw_config.get("extension_type") != "wasm":
if 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)
module_path = _extension_path(ext_dir, config.wasm.module)
wit_path = _optional_extension_path(ext_dir, config.wasm.wit)
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"))
_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.id,
name=config.name,
version=config.version,
id=config.get("id") or ext_id,
name=config.get("name") or ext_id,
version=config.get("version") or "0.0",
root_path=ext_dir,
module_path=module_path,
wit_path=wit_path,
world=config.wasm.world,
exports=config.wasm.exports,
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 [],
config=config,
)
+9 -17
View File
@@ -497,7 +497,7 @@ class Filter(BaseModel, Generic[TFilterModel]):
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
values[f"{field}__{i}_{index}"] = validated
values[f"{field}__{index}"] = validated
else:
raise ValueError("Unknown filter field")
@@ -510,12 +510,11 @@ class Filter(BaseModel, Generic[TFilterModel]):
for key in self.values.keys() if self.values else []:
if self.model and self.model.__fields__[self.field].type_ == datetime:
placeholder = compat_timestamp_placeholder(key)
else:
placeholder = f":{key}"
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
stmt.append(placeholder)
else:
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
stmt.append(f":{key}")
else:
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
@@ -726,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 _is_subclass(type_, BaseModel) else v
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
for v in _items
]
continue
if _is_subclass(type_, bool):
if issubclass(type_, bool):
_dict[key] = bool(value)
continue
if _is_subclass(type_, datetime):
if issubclass(type_, datetime):
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value.replace(tzinfo=timezone.utc)
continue
if _is_subclass(type_, BaseModel):
if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
@@ -764,13 +763,6 @@ 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,13 +372,3 @@ 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,8 +27,6 @@ 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,
@@ -36,8 +34,6 @@ 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"""
@@ -62,8 +58,6 @@ 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,7 +60,6 @@ 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="""
@@ -82,33 +81,6 @@ 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
@@ -524,11 +496,6 @@ 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")
@@ -1039,7 +1006,6 @@ class AuditSettings(LNbitsSettings):
class EditableSettings(
UsersSettings,
ExtensionsSettings,
WasmRuntimeLimits,
ThemesSettings,
OpsSettings,
AssetSettings,
@@ -1329,7 +1295,6 @@ 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")
@@ -1394,7 +1359,6 @@ 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
+1 -16
View File
@@ -116,7 +116,6 @@ window.localisation.en = {
read: 'Read',
write: 'Write',
pay: 'Pay',
sending: 'Sending',
memo: 'Memo',
date: 'Date',
path: 'Path',
@@ -521,14 +520,6 @@ 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.',
@@ -537,10 +528,6 @@ 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',
@@ -555,15 +542,13 @@ 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_wallet_payments_watch: 'Watch wallet payments',
extension_permission_payments_watch: 'Watch 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',
@@ -1,380 +0,0 @@
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
}
}
}
})
@@ -1,380 +0,0 @@
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, warningKey) {
function mediumRisk(translateFn) {
return {
level: 'medium',
color: 'warning',
label: translate(translateFn, 'extension_permission_risk_medium'),
warning: warningKey ? translate(translateFn, warningKey) : ''
warning: ''
}
}
@@ -77,16 +77,10 @@
}
function permissionRiskForPermission(permission, extensions, translateFn) {
if (
['wallet.pay_invoice', 'wallet.pay_invoice_background'].includes(
permission.id
)
) {
if (permission.id === 'wallet.pay_invoice') {
return highRisk(
translateFn,
permission.id === 'wallet.pay_invoice_background'
? 'extension_permission_warning_wallet_pay_invoice_background'
: 'extension_permission_warning_wallet_pay_invoice'
'extension_permission_warning_wallet_pay_invoice'
)
}
if (permission.id === 'extension.api.request') {
@@ -104,18 +98,13 @@
if (permission.id === 'http.request') {
return mediumRisk(translateFn)
}
if (permission.id === 'wallet.payments.watch') {
return mediumRisk(
translateFn,
'extension_permission_warning_wallet_payments_watch'
)
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.read_public'
'ext.storage.read_public',
'payments.watch'
].includes(permission.id)
) {
return mediumRisk(translateFn)
@@ -135,8 +124,6 @@
function permissionOrderIndex(permissionId) {
const order = [
'wallet.pay_invoice',
'wallet.pay_invoice_background',
'wallet.payments.watch',
'wallet.list',
'wallet.balance.read',
'extension.api.request',
-20
View File
@@ -99,26 +99,6 @@ 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',
+6 -35
View File
@@ -16,26 +16,18 @@ window.PageAdmin = {
},
watch: {
tab(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)
}
this.$router.push(`/admin#${tab}`)
},
$route(to) {
const tab = this.adminTabFromRoute(to)
if (this.tab !== tab) {
this.tab = tab
if (to.hash.length > 1) {
this.tab = to.hash.replace('#', '')
}
}
},
async created() {
this.tab = this.adminTabFromRoute(this.$route)
if (this.$route.hash.length > 1) {
this.tab = this.$route.hash.replace('#', '')
}
await this.getSettings()
},
computed: {
@@ -44,27 +36,6 @@ 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
+1 -243
View File
@@ -29,23 +29,6 @@ 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,
@@ -115,32 +98,6 @@ 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
@@ -207,11 +164,6 @@ 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)
@@ -400,18 +352,8 @@ window.PageExtensions = {
this.selectedExtension = extension
this.selectedRelease = null
this.selectedExtensionRepos = null
this.resetManagedExtensionPermissions()
this.manageExtensionTab = this.g.user.admin
? 'releases'
: 'extension-permissions'
this.manageExtensionTab = 'releases'
this.showManageExtensionDialog = true
if (this.canManageExtensionPermissions(extension)) {
this.loadManagedExtensionPermissions(extension)
}
if (!this.g.user.admin) {
return
}
try {
const {data} = await LNbits.api.request(
@@ -449,61 +391,6 @@ 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) {
@@ -750,135 +637,6 @@ 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
+2 -15
View File
@@ -7,7 +7,6 @@ window.PageWallet = {
invoice: null,
lnurlpay: null,
lnurlauth: null,
sending: false,
data: {
request: '',
amount: 0,
@@ -132,7 +131,6 @@ 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
},
@@ -364,9 +362,6 @@ 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')
@@ -379,7 +374,6 @@ window.PageWallet = {
this.parse.data.internalMemo
)
.then(response => {
this.parse.sending = false
dismissPaymentMsg()
this.g.updatePayments = !this.g.updatePayments
this.parse.show = false
@@ -397,16 +391,13 @@ 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,
@@ -417,7 +408,6 @@ 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)
@@ -474,10 +464,7 @@ window.PageWallet = {
}
}
})
.catch(err => {
this.parse.sending = false
LNbits.utils.notifyApiError(err)
})
.catch(LNbits.utils.notifyApiError)
},
authLnurl() {
const dismissAuthMsg = Quasar.Notify.create({
@@ -48,85 +48,6 @@ 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() {
@@ -147,36 +68,6 @@ 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: '',
@@ -193,12 +84,6 @@ 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: {
@@ -234,20 +119,6 @@ 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: '',
@@ -256,16 +127,6 @@ window.WasmExtensionComponent = {
show: false
}
},
emptyWalletPaymentWatchPrompt() {
return {
extensionName: '',
reject: null,
resolve: null,
show: false,
walletId: '',
walletName: ''
}
},
plainValue(value) {
try {
return JSON.parse(JSON.stringify(value))
@@ -282,12 +143,6 @@ 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 {
@@ -455,320 +310,6 @@ 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) {
@@ -974,30 +515,6 @@ 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, {
-2
View File
@@ -67,8 +67,6 @@
"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,8 +8,6 @@ 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,11 +1,9 @@
<template id="lnbits-admin-extensions">
<q-card-section class="q-pa-none">
<div>
<div class="row items-center justify-between q-mb-md">
<h6 class="q-my-none">
<span v-text="$t('extensions')"></span>
</h6>
</div>
<h6 class="q-my-none">
<span v-text="$t('extensions')"></span>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12 q-mb-md">
<p>
@@ -35,27 +33,6 @@
</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>
@@ -147,15 +124,6 @@
/>
</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">
@@ -1,242 +0,0 @@
<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>
@@ -1,245 +0,0 @@
<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>
+1 -13
View File
@@ -74,13 +74,7 @@
<div class="col q-gutter-y-md">
<q-card>
<!-- Mobile: Dropdown menu at top -->
<div
v-if="
$q.screen.lt.md &&
!['wasm-runtime', 'wasm-limit-config'].includes(tab)
"
class="q-px-md q-pt-md"
>
<div v-if="$q.screen.lt.md" class="q-px-md q-pt-md">
<q-select
v-model="tab"
:options="[
@@ -226,12 +220,6 @@
<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>
+6 -167
View File
@@ -356,7 +356,7 @@
@click="showManageExtension(extension)"
flat
color="primary"
v-if="canShowManageExtensionButton(extension)"
v-if="g.user.admin"
:label="$t('manage')"
><q-tooltip
><span v-text="$t('manage_extension_details')"></span
@@ -531,43 +531,20 @@
</div>
</div>
</q-card>
<q-card
v-else
class="q-pa-lg lnbits__dialog-card"
style="width: min(95vw, 920px); max-width: 95vw"
>
<q-card v-else class="q-pa-lg lnbits__dialog-card">
<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="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
"
v-if="selectedExtension && selectedExtension.isInstalled"
name="sell"
:label="$t('sell')"
@update="val => (manageExtensionTab = val.name)"
@@ -577,7 +554,7 @@
<div
v-show="manageExtensionTab === 'releases'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
v-if="canShowAdminManageTabs() && selectedExtensionRepos"
v-if="selectedExtensionRepos"
>
<q-card
flat
@@ -838,147 +815,9 @@
></q-btn>
</div>
</div>
<q-spinner-bars
v-else-if="
canShowAdminManageTabs() && manageExtensionTab === 'releases'
"
color="primary"
size="2.55em"
></q-spinner-bars>
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
<div
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-if="selectedExtension"
v-show="manageExtensionTab === 'sell'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
+2 -9
View File
@@ -664,8 +664,7 @@
unelevated
color="primary"
@click="payInvoice"
:disable="parse.sending"
:label="parse.sending ? $t('sending') + '...' : $t('pay')"
:label="$t('pay')"
></q-btn>
<q-btn
v-close-popup
@@ -837,13 +836,7 @@
</div>
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:disable="parse.sending"
:label="parse.sending ? $t('sending') + '...' : $t('send')"
></q-btn>
<q-btn unelevated color="primary" type="submit">Send</q-btn>
<q-btn
:label="$t('cancel')"
v-close-popup
+5
View File
@@ -1,3 +1,5 @@
from collections.abc import AsyncGenerator
from loguru import logger
from .base import (
@@ -38,3 +40,6 @@ 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,8 +120,6 @@
"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",
+1 -2
View File
@@ -83,8 +83,7 @@ async def test_admin_audit_monitor_and_test_email(
)
assert monitor.status_code == 200
task_names = [t["name"] for t in monitor.json()]
assert "core_invoice_listener" in task_names
assert "core_wasm_invoice_listener" in task_names
assert any("invoice_listener" in name for name in task_names)
test_email = await client.get(
"/admin/api/v1/testemail",
-187
View File
@@ -1,6 +1,3 @@
import json
import zipfile
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
@@ -11,7 +8,6 @@ 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,
)
@@ -23,14 +19,11 @@ 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
@@ -42,11 +35,9 @@ 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,
@@ -171,147 +162,6 @@ 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(
@@ -578,40 +428,3 @@ 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, startup_timeout=30) as manager:
async with LifespanManager(app) as manager:
settings.first_install = True
await first_install(
UpdateSuperuserPassword(
+3 -35
View File
@@ -1,4 +1,4 @@
from datetime import date, datetime, timezone
from datetime import date, timezone
import pytest
@@ -9,41 +9,9 @@ from lnbits.core.crud import (
get_wallet_for_key,
)
from lnbits.core.crud.payments import get_payment
from lnbits.core.models import CreateInvoice, PaymentFilters
from lnbits.core.models import CreateInvoice
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.db import POSTGRES, SQLITE, Filter, Filters
@pytest.mark.parametrize(
("db_type", "lower_statement", "upper_statement"),
[
(
POSTGRES,
"(time >= to_timestamp(:time__0_0))",
"(time <= to_timestamp(:time__1_0))",
),
(SQLITE, "(time >= :time__0_0)", "(time <= :time__1_0)"),
],
)
def test_datetime_filter_uses_database_timestamp_placeholder(
monkeypatch, db_type, lower_statement, upper_statement
):
monkeypatch.setattr("lnbits.db.DB_TYPE", db_type)
lower_bound = Filter.parse_query(
"time[ge]", ["2026-06-16T00:00:00"], PaymentFilters, 0
)
upper_bound = Filter.parse_query(
"time[le]", ["2026-06-23T23:59:59"], PaymentFilters, 1
)
filters = Filters(filters=[lower_bound, upper_bound], model=PaymentFilters)
values = filters.values()
assert isinstance(values["time__0_0"], datetime)
assert values["time__0_0"] == datetime(2026, 6, 16)
assert values["time__1_0"] == datetime(2026, 6, 23, 23, 59, 59)
assert filters.where() == f"WHERE {lower_statement} AND {upper_statement}"
assert lower_bound.statement == lower_statement
assert upper_bound.statement == upper_statement
from lnbits.db import POSTGRES
@pytest.mark.anyio
-10
View File
@@ -12,7 +12,6 @@ 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,
@@ -226,12 +225,3 @@ 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"
+3 -10
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(mocker: MockerFixture):
def test_check_revolut_signature_docs_vector():
payload = (
b'{"data":{"id":"645a7696-22f3-aa47-9c74-cbae0449cc46",'
b'"new_state":"completed","old_state":"pending",'
@@ -1445,16 +1445,9 @@ def test_check_revolut_signature_docs_vector(mocker: MockerFixture):
secret = "wsk_r59a4HfWVAKycbCaNO1RvgCJec02gRd8"
sig = "v1=bca326fb378d0da7f7c490ad584a8106bab9723d8d9cdd0d50b4c5b3be3837c0"
# 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, tolerance_seconds=100000000
)
check_revolut_signature(payload, sig, timestamp, secret)
check_revolut_signature(payload, sig, timestamp, secret)
@pytest.mark.anyio
-17
View File
@@ -2,7 +2,6 @@ import json
import pytest
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.db import (
dict_to_model,
dict_to_submodel,
@@ -104,19 +103,3 @@ 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,6 +1,3 @@
import json
import zipfile
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from uuid import uuid4
@@ -14,25 +11,17 @@ 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
@@ -154,59 +143,6 @@ 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
@@ -261,35 +197,6 @@ 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,
@@ -312,416 +219,6 @@ 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,
-52
View File
@@ -27,7 +27,6 @@ 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,
@@ -111,36 +110,6 @@ async def test_update_pending_payment_and_bulk_pending_updates(mocker: MockerFix
assert bulk_statuses == {PaymentState.FAILED, PaymentState.SUCCESS}
@pytest.mark.anyio
async def test_update_pending_payment_marks_expired_incoming_invoice_failed(
app,
mocker: MockerFixture,
):
wallet = await _create_wallet()
checking_id = await _create_payment(
wallet,
expiry=datetime.now(timezone.utc) - timedelta(seconds=1),
labels=["test"],
)
payment = await get_payment(checking_id)
check_status_mock = mocker.patch(
"lnbits.core.services.payments.check_payment_status",
mocker.AsyncMock(
side_effect=AssertionError("expired invoices should not be checked")
),
)
updated_payment = await update_pending_payment(payment)
assert updated_payment.status == PaymentState.FAILED
assert updated_payment.labels == ["test", "expired"]
check_status_mock.assert_not_awaited()
stored_payment = await get_payment(checking_id)
assert stored_payment.status == PaymentState.FAILED
assert stored_payment.labels == ["test", "expired"]
@pytest.mark.anyio
async def test_check_pending_payments_skips_voidwallet_and_updates_recent_items(
mocker: MockerFixture,
@@ -279,23 +248,6 @@ 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,
@@ -481,8 +433,6 @@ async def _create_payment(
payment_hash: str | None = None,
fee: int = 0,
time: datetime | None = None,
expiry: datetime | None = None,
labels: list[str] | None = None,
) -> str:
checking_id = checking_id or f"checking_{uuid4().hex[:8]}"
payment_hash = payment_hash or uuid4().hex
@@ -494,9 +444,7 @@ async def _create_payment(
bolt11=f"bolt11-{checking_id}",
amount_msat=amount_msat,
memo="memo",
expiry=expiry,
fee=fee,
labels=labels,
),
status=status,
)
@@ -1,301 +0,0 @@
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
@@ -1,295 +0,0 @@
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"}),
)
@@ -1,57 +0,0 @@
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_wasm_frontend_assets_are_registered_in_component_bundle():
package_json = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
components = package_json["bundle"]["components"]
assert "js/wasm-extension-component.js" in components
assert "js/components/lnbits-extension-permissions.js" in components
assert "js/components/admin/lnbits-admin-wasm-runtime.js" in components
assert "js/components/admin/lnbits-admin-wasm-limit-config.js" in components
def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
encoding="utf-8"
)
assert "allowedApiRoute(method, path)" in bridge
assert "url.origin !== window.location.origin" in bridge
assert "Extension API route is not allowed." in bridge
assert "message.action === 'payment.subscribe'" in bridge
assert "message.action === 'payment.unsubscribe'" in bridge
assert "message.action === 'ui.scan_qr'" in bridge
def test_wasm_extension_install_ui_requests_permissions_before_install_paths():
extensions_page = (ROOT / "lnbits/static/js/pages/extensions.js").read_text(
encoding="utf-8"
)
wasm_bulk_update_skip_message = (
"Skipping ${ext.id}; this extension update requires permission approval."
)
assert "await this.resolveExtensionPermissionGrant(release)" in extensions_page
assert "permissions: grantedPermissions" in extensions_page
assert "release.extension_type === 'wasm'" in extensions_page
assert "this.selectedExtension?.isWasm === true" in extensions_page
assert wasm_bulk_update_skip_message in extensions_page
def test_wasm_admin_frontend_calls_runtime_limit_and_invocation_endpoints():
runtime = (
ROOT / "lnbits/static/js/components/admin/lnbits-admin-wasm-runtime.js"
).read_text(encoding="utf-8")
limits = (
ROOT / "lnbits/static/js/components/admin/lnbits-admin-wasm-limit-config.js"
).read_text(encoding="utf-8")
assert "/api/v1/extension/wasm/invocations/current" in runtime
assert "/api/v1/extension/wasm/invocations?" in runtime
assert "/api/v1/extension/wasm/invocations/stats?" in runtime
assert "/api/v1/extension/wasm/runtime-limits/extensions" in limits
assert "/api/v1/extension/wasm/runtime-limits/${encodeURIComponent(" in limits
-348
View File
@@ -1,348 +0,0 @@
import json
from types import SimpleNamespace
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.extensions import ExtensionPermission
from lnbits.core.wasm_ext.api.host import ExtensionHostAPI
from lnbits.core.wasm_ext.api.models import (
CreateInvoicePublicRequest,
EmptyRequest,
PayInvoiceRequest,
StorageGetRequest,
WalletBalanceRequest,
)
from lnbits.exceptions import PaymentError
from lnbits.helpers import sha256s
@pytest.mark.anyio
async def test_host_api_filters_public_storage_fields(mocker: MockerFixture):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
mocker.AsyncMock(
return_value={
"id": "tip-1",
"title": "Tip jar",
"wallet_id": "secret-wallet",
}
),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[{"table_name": "tips", "public_fields": ["id", "title"]}],
)
],
)
response = await api.storage_get_public(StorageGetRequest(table="tips", id="tip-1"))
assert json.loads(response.data_json or "{}") == {
"id": "tip-1",
"title": "Tip jar",
}
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
@pytest.mark.anyio
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
mocker: MockerFixture,
):
api_without_owner = ExtensionHostAPI(
"demoext",
["ext.storage.read"],
context="event",
)
with pytest.raises(PermissionError, match="owner context"):
await api_without_owner.storage_get(StorageGetRequest(table="notes", id="1"))
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row",
mocker.AsyncMock(return_value={"id": "1", "title": "Note"}),
)
api = ExtensionHostAPI("demoext", ["ext.storage.read"], user_id="user-1")
response = await api.storage_get(StorageGetRequest(table="notes", id="1"))
assert json.loads(response.data_json or "{}") == {"id": "1", "title": "Note"}
storage_mock.assert_awaited_once_with(
"demoext",
"notes",
"1",
sha256s("user-1"),
)
@pytest.mark.anyio
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
mocker: MockerFixture,
):
api_without_permission = ExtensionHostAPI("demoext", [], user_id="user-1")
with pytest.raises(PermissionError, match="wallet.list"):
await api_without_permission.wallet_list_user_wallets(EmptyRequest())
wallet = SimpleNamespace(id="wallet-1", name="Wallet", currency="USD")
mocker.patch(
"lnbits.core.crud.wallets.get_wallets",
mocker.AsyncMock(return_value=[wallet]),
)
api = ExtensionHostAPI("demoext", ["wallet.list"], user_id="user-1")
response = await api.wallet_list_user_wallets(EmptyRequest())
assert response.wallets[0].id == "wallet-1"
assert response.wallets[0].name == "Wallet"
@pytest.mark.anyio
async def test_host_api_wallet_balance_rejects_other_users_wallets(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="other")),
)
api = ExtensionHostAPI(
"demoext",
["wallet.balance.read"],
user_id="user-1",
)
with pytest.raises(PermissionError, match="not allowed"):
await api.wallet_balance(WalletBalanceRequest(wallet_id="wallet-1"))
@pytest.mark.anyio
async def test_host_api_wallet_balance_returns_safe_summary(mocker: MockerFixture):
wallet = SimpleNamespace(
id="wallet-1",
user="user-1",
name="Wallet",
currency="USD",
balance_msat=10_000,
balance=10,
withdrawable_balance=8_000,
can_send_payments=True,
)
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
api = ExtensionHostAPI(
"demoext",
["wallet.balance.read"],
user_id="user-1",
)
response = await api.wallet_balance(WalletBalanceRequest(wallet_id="wallet-1"))
assert response.wallet_id == "wallet-1"
assert response.balance_msat == 10_000
assert response.withdrawable_msat == 8_000
assert response.fee_reserve_msat == 2_000
assert response.can_send_payments is True
@pytest.mark.anyio
async def test_host_api_pay_invoice_checks_wallet_owner_and_returns_payment_errors(
mocker: MockerFixture,
):
wallet = SimpleNamespace(id="wallet-1", user="user-1")
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
mocker.patch(
"lnbits.core.services.payments.pay_invoice",
mocker.AsyncMock(side_effect=PaymentError("insufficient balance")),
)
api = ExtensionHostAPI("demoext", ["wallet.pay_invoice"], user_id="user-1")
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet-1",
payment_request="lnbc1demo",
max_sat=None,
description="Demo",
extra={"source": "test"},
)
)
assert response.ok is False
assert response.error == "insufficient balance"
@pytest.mark.anyio
async def test_host_api_background_pay_invoice_requires_background_permission(
mocker: MockerFixture,
):
wallet = SimpleNamespace(id="wallet-1", user="user-1")
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
api = ExtensionHostAPI("demoext", [], context="event")
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet-1",
payment_request="lnbc1demo",
max_sat=None,
description="Demo",
extra={},
)
)
assert response.ok is False
assert "wallet.pay_invoice_background" in (response.error or "")
@pytest.mark.anyio
async def test_host_api_background_pay_invoice_uses_background_grant_metadata(
mocker: MockerFixture,
):
wallet = SimpleNamespace(id="wallet-1", user="user-1")
mocker.patch(
"lnbits.core.crud.wallets.get_wallet",
mocker.AsyncMock(return_value=wallet),
)
mocker.patch(
"lnbits.core.wasm_ext.api.host.invoice_amount_msat",
return_value=21_000,
)
background_extra_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.background_payment_extra",
mocker.AsyncMock(
return_value={
"tag": "demoext",
"background_payment": True,
"background_permission": "wallet.pay_invoice_background",
"background_wallet_id": "wallet-1",
}
),
)
payment = SimpleNamespace(
checking_id="checking-id",
payment_hash="payment-hash",
status="success",
amount=-21_000,
fee=0,
pending=False,
success=True,
)
pay_mock = mocker.patch(
"lnbits.core.services.payments.pay_invoice",
mocker.AsyncMock(return_value=payment),
)
api = ExtensionHostAPI(
"demoext",
["wallet.pay_invoice_background"],
context="event",
)
response = await api.wallet_pay_invoice(
PayInvoiceRequest(
wallet_id="wallet-1",
payment_request="lnbc1demo",
max_sat=None,
description="Demo",
extra={"source": "test"},
)
)
assert response.ok is True
background_extra_mock.assert_awaited_once()
pay_mock.assert_awaited_once()
assert pay_mock.await_args is not None
assert pay_mock.await_args.kwargs["extra"] == {
"source": "test",
"tag": "demoext",
"background_payment": True,
"background_permission": "wallet.pay_invoice_background",
"background_wallet_id": "wallet-1",
}
@pytest.mark.anyio
async def test_host_api_public_invoice_uses_granted_source_policy(
mocker: MockerFixture,
):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
mocker.AsyncMock(return_value={"id": "tip-1", "wallet_id": "wallet-1"}),
)
payment = SimpleNamespace(
payment_hash="hash",
payment_request="lnbc1demo",
bolt11="lnbc1fallback",
checking_id="checking-id",
)
create_mock = mocker.patch(
"lnbits.core.services.payments.create_payment_request",
mocker.AsyncMock(return_value=payment),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "tips", "wallet_field": "wallet_id"}],
)
],
)
response = await api.wallet_create_invoice_public(
CreateInvoicePublicRequest(
source_id="tip-1",
amount=21,
currency="sat",
memo="Tip",
extra={"note": "thanks"},
)
)
assert response.payment_hash == "hash"
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
create_mock.assert_awaited_once()
assert create_mock.await_args is not None
wallet_id, invoice = create_mock.await_args.args
assert wallet_id == "wallet-1"
assert invoice.extra == {
"tag": "demoext",
"source_id": "tip-1",
"extra_demoext": {"note": "thanks"},
}
assert invoice.extension == "demoext"
@pytest.mark.anyio
async def test_host_api_public_invoice_rejects_missing_source_wallet(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
mocker.AsyncMock(return_value={"id": "tip-1"}),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="wallet.create_invoice_public",
policies=[{"table": "tips", "wallet_field": "wallet_id"}],
)
],
)
with pytest.raises(PermissionError, match="no valid wallet"):
await api.wallet_create_invoice_public(
CreateInvoicePublicRequest(
source_id="tip-1",
amount=21,
currency="sat",
memo="",
)
)
-214
View File
@@ -1,214 +0,0 @@
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
@@ -1,223 +0,0 @@
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"},
}
@@ -1,409 +0,0 @@
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",
"public_fields": ["id", "title", "description"],
}
],
}
],
)
permissions = validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "tip_jars",
"public_fields": ["id", "title"],
}
],
)
],
extension_config,
)
assert permissions == [
ExtensionPermission(
id="ext.storage.read_public",
description="Read public storage.",
policies=[
{
"table_name": "tip_jars",
"public_fields": ["id", "title"],
}
],
)
]
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_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
@@ -1,279 +0,0 @@
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": [],
}
)
-409
View File
@@ -1,409 +0,0 @@
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import extensions as extension_crud
from lnbits.core.crud import update_migration_version
from lnbits.core.db import db as core_db
from lnbits.core.migrations import (
m010_create_installed_extensions_table,
m046_add_permissions_to_installed_extensions,
m047_create_wasm_invocations_table,
m048_add_wasm_runtime_limits_to_installed_extensions,
)
from lnbits.core.models.extensions import ExtensionPermission, WasmInvocation
from lnbits.core.wasm_ext.storage import crud as storage_crud
from lnbits.core.wasm_ext.storage.crud import (
OWNER_ID_FIELD,
migrate_wasm_extension_database,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_set_row,
)
from lnbits.db import DB_TYPE, SQLITE, Compat, Connection, Database
from lnbits.settings import Settings
from tests.helpers import make_installable_extension
@pytest.mark.anyio
async def test_core_wasm_migrations_create_persistent_columns(
tmp_path: Path, settings: Settings
):
if DB_TYPE != SQLITE:
pytest.skip("temporary core databases are SQLite-only")
db = _temporary_database(tmp_path, settings, "wasm_core_migrations")
async with db.connect() as conn:
await m010_create_installed_extensions_table(conn)
await m046_add_permissions_to_installed_extensions(conn)
await m047_create_wasm_invocations_table(conn)
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
installed_columns = {
row["name"]
for row in await conn.fetchall("PRAGMA table_info(installed_extensions)")
}
invocation_columns = {
row["name"]
for row in await conn.fetchall("PRAGMA table_info(wasm_invocations)")
}
assert {"permissions", "wasm_runtime_limits"}.issubset(installed_columns)
assert {
"extension_id",
"status",
"request_bytes",
"response_bytes",
"host_call_count",
"http_call_count",
"storage_call_count",
"wallet_call_count",
"context",
}.issubset(invocation_columns)
@pytest.mark.anyio
async def test_installed_extension_permissions_and_wasm_limits_round_trip(
app,
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
):
db = await _temporary_core_crud_database(tmp_path, settings)
mocker.patch.object(extension_crud, "db", db)
ext_id = f"wasmlimits_{uuid4().hex[:8]}"
extension = make_installable_extension(ext_id)
extension.permissions = [ExtensionPermission(id="utils.basic")]
extension.wasm_runtime_limits = {"wasm_runtime_max_execution_ms": 1234}
await extension_crud.create_installed_extension(extension)
stored = await extension_crud.get_installed_extension(ext_id)
assert stored is not None
assert stored.permissions == [ExtensionPermission(id="utils.basic")]
assert stored.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
stored.wasm_runtime_limits = {"wasm_runtime_max_fuel": 0}
await extension_crud.update_installed_extension(stored)
updated = await extension_crud.get_installed_extension(ext_id)
assert updated is not None
assert updated.wasm_runtime_limits == {"wasm_runtime_max_fuel": 0}
@pytest.mark.anyio
async def test_wasm_invocation_crud_stats_and_cleanup_are_isolated(
app,
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
):
db = await _temporary_core_crud_database(tmp_path, settings)
mocker.patch.object(extension_crud, "db", db)
now = datetime.now(timezone.utc)
old = now - timedelta(days=10)
ext_id = f"wasminv_{uuid4().hex[:8]}"
other_ext_id = f"wasminv_{uuid4().hex[:8]}"
invocations = [
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=ext_id,
export_name="render",
status="completed",
started_at=old,
finished_at=old + timedelta(milliseconds=20),
duration_ms=20,
host_call_count=2,
http_call_count=1,
),
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=ext_id,
export_name="render",
status="failed",
started_at=now,
finished_at=now,
duration_ms=40,
host_call_count=3,
storage_call_count=2,
),
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=ext_id,
export_name="render",
status="running",
started_at=old,
),
WasmInvocation(
id=f"inv_{uuid4().hex}",
extension_id=other_ext_id,
export_name="render",
status="completed",
started_at=now,
duration_ms=100,
),
]
for invocation in invocations:
await extension_crud.create_wasm_invocation(invocation)
failed = await extension_crud.get_wasm_invocations(
extension_id=ext_id,
status="failed",
)
stats = await extension_crud.get_wasm_invocation_stats(
extension_id=ext_id,
since=now - timedelta(days=30),
)
deleted = await extension_crud.delete_old_wasm_invocations(retention_days=7)
remaining_running = await extension_crud.get_wasm_invocations(
extension_id=ext_id,
status="running",
)
assert [invocation.id for invocation in failed] == [invocations[1].id]
assert stats.total == 3
assert stats.completed == 1
assert stats.failed == 1
assert stats.running == 1
assert stats.host_call_count == 5
assert stats.http_call_count == 1
assert stats.storage_call_count == 2
assert deleted == 1
assert [invocation.id for invocation in remaining_running] == [invocations[2].id]
@pytest.mark.anyio
async def test_wasm_datetime_queries_use_postgres_placeholders(
mocker: MockerFixture,
):
db = mocker.Mock()
db.timestamp_placeholder.side_effect = lambda key: f"to_timestamp(:{key})"
db.fetchone = mocker.AsyncMock(return_value=None)
db.execute = mocker.AsyncMock(return_value=SimpleNamespace(rowcount=0))
field = {"name": "created_at", "type": "datetime"}
assert (
storage_crud._value_placeholder(cast(Compat, db), field, "created_at")
== "to_timestamp(:created_at)"
)
where_sql, _ = storage_crud._where_sql(
cast(Compat, db), {"fields": [field]}, {"created_at": 0}, None, []
)
assert "created_at = to_timestamp(:filter_created_at)" in where_sql
conn = cast(Connection, db)
await extension_crud.get_wasm_invocation_stats(
since=datetime.now(timezone.utc), conn=conn
)
await extension_crud.delete_old_wasm_invocations(1, conn=conn)
await extension_crud.mark_stale_wasm_invocations(conn=conn)
queries = [
db.fetchone.call_args.args[0],
*[c.args[0] for c in db.execute.call_args_list],
]
assert all("to_timestamp(:" in query for query in queries)
@pytest.mark.anyio
async def test_wasm_storage_migration_and_owner_scoped_crud(
tmp_path: Path,
settings: Settings,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
ext_dir = _write_storage_extension(settings, ext_id)
await migrate_wasm_extension_database(make_installable_extension(ext_id))
await storage_set_row(
ext_id,
"notes",
{
"id": "note-1",
"title": "First",
"count": 1,
"published": True,
"tags": ["alpha", "beta"],
"created_at": 1_700_000_000,
},
"owner-1",
)
await storage_set_row(
ext_id,
"notes",
{
"id": "note-1",
"title": "Other owner attempt",
"count": 2,
"published": False,
"tags": ["gamma"],
"created_at": 1_700_000_001,
},
"owner-2",
)
owner_row = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
other_owner_row = await storage_get_row(ext_id, "notes", "note-1", "owner-2")
public_row = await storage_get_public_row(ext_id, "notes", "note-1")
page = await storage_get_paginated_rows(
ext_id,
"notes",
{"published": True},
owner_id="owner-1",
search="fir",
search_fields=["title"],
sort_by="count",
descending=True,
limit=50,
offset=0,
)
await storage_delete_row(ext_id, "notes", "note-1", "owner-2")
still_owned = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
await storage_delete_row(ext_id, "notes", "note-1", "owner-1")
deleted = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
finally:
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_data_folder = original_data_folder
assert ext_dir.is_dir()
assert owner_row is not None
assert owner_row["title"] == "First"
assert owner_row["tags"] == ["alpha", "beta"]
assert owner_row["created_at"] == 1_700_000_000
assert other_owner_row is None
assert public_row is not None
assert public_row["title"] == "First"
assert page["total"] == 1
assert page["data"][0]["id"] == "note-1"
assert still_owned is not None
assert deleted is None
@pytest.mark.anyio
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
tmp_path: Path,
settings: Settings,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
_write_storage_extension(settings, ext_id)
with pytest.raises(ValueError, match="Invalid WASM storage SQL identifier"):
await storage_get_row(ext_id, "notes;DROP", "note-1", "owner-1")
with pytest.raises(ValueError, match="reserved owner field"):
await storage_set_row(
ext_id,
"notes",
{"id": "note-1", OWNER_ID_FIELD: "owner-2"},
"owner-1",
)
schema_path = (
Path(settings.lnbits_extensions_path)
/ "extensions"
/ ext_id
/ "storage"
/ "schema.json"
)
schema = json.loads(schema_path.read_text(encoding="utf-8"))
schema["tables"]["notes"]["fields"].append(
{"name": OWNER_ID_FIELD, "type": "string"}
)
schema_path.write_text(json.dumps(schema), encoding="utf-8")
with pytest.raises(ValueError, match="reserved field"):
await storage_get_row(ext_id, "notes", "note-1", "owner-1")
finally:
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_data_folder = original_data_folder
def _temporary_database(
tmp_path: Path,
settings: Settings,
name: str,
) -> Database:
settings.lnbits_data_folder = str(tmp_path / "data")
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
return Database(name)
async def _temporary_core_crud_database(
tmp_path: Path,
settings: Settings,
) -> Database:
if DB_TYPE != SQLITE:
return core_db
db = _temporary_database(tmp_path, settings, f"core_{uuid4().hex[:8]}")
async with db.connect() as conn:
await conn.execute("""
CREATE TABLE dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
""")
await update_migration_version(conn, "core", 48)
await m010_create_installed_extensions_table(conn)
await m046_add_permissions_to_installed_extensions(conn)
await m047_create_wasm_invocations_table(conn)
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
return db
def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
storage_dir = ext_dir / "storage"
migrations_dir = storage_dir / "migrations"
migrations_dir.mkdir(parents=True)
schema = {
"tables": {
"notes": {
"fields": [
{"name": "id", "type": "string"},
{"name": "title", "type": "string"},
{"name": "count", "type": "integer", "default": 0},
{"name": "published", "type": "boolean", "default": False},
{"name": "tags", "type": "string", "list": True},
{"name": "created_at", "type": "datetime"},
]
}
}
}
migration = {
"operations": [
{
"op": "create_table",
"table": "notes",
"fields": schema["tables"]["notes"]["fields"],
}
]
}
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")
(migrations_dir / "001_init.json").write_text(
json.dumps(migration),
encoding="utf-8",
)
return ext_dir