From 3fd7aae10da1c92b24f62ed78f490d9925eb63ad Mon Sep 17 00:00:00 2001 From: Arc Date: Wed, 25 Feb 2026 16:11:14 +0000 Subject: [PATCH] tasks and docs --- docs/devs/agents_extensions.md | 59 +++ docs/devs/agents_wasm_extensions.md | 137 +++++ lnbits/app.py | 10 +- lnbits/core/models/extensions.py | 9 + lnbits/core/services/extensions.py | 10 + lnbits/core/services/notifications.py | 5 + lnbits/core/views/extension_api.py | 233 +++++++++ lnbits/core/views/websocket_api.py | 24 + lnbits/core/wasm/__init__.py | 87 ++++ lnbits/core/wasm/extension_host.py | 720 +++++++++++++++++++++++++- lnbits/static/js/pages/extensions.js | 68 ++- lnbits/templates/pages/extensions.vue | 43 +- 12 files changed, 1377 insertions(+), 28 deletions(-) create mode 100644 docs/devs/agents_extensions.md create mode 100644 docs/devs/agents_wasm_extensions.md diff --git a/docs/devs/agents_extensions.md b/docs/devs/agents_extensions.md new file mode 100644 index 000000000..d53fc5dcd --- /dev/null +++ b/docs/devs/agents_extensions.md @@ -0,0 +1,59 @@ +--- +layout: default +parent: For developers +title: Agent Guide - Python Extensions +nav_order: 4 +--- + +# Agent Guide - Python Extensions + +This guide is for AI agents or developers using AI to build **traditional (Python) LNbits extensions**. It defines what to change, what not to change, and the expected structure. + +## Hard Rules (Non-Negotiable) +- Do **not** change core LNbits files. +- Only edit files inside your extension folder. +- Do **not** add new Python dependencies unless explicitly approved. + +## Extension Folder Layout (Python) +Your extension lives under: + +``` +lnbits/extensions// +``` + +Typical files to edit: +- `views.py` (HTML routes) +- `views_api.py` (API routes) +- `crud.py` / `models.py` (storage logic + models) +- `migrations.py` (DB schema) +- `templates//` (HTML) +- `static/` (JS/CSS/images) +- `config.json`, `manifest.json`, `README.md` + +## What You Can Do +Python extensions can: +- Define their own database schema via `migrations.py` +- Run long-running background tasks via `*_start()` and `*_stop()` hooks +- Access LNbits internal services directly in Python +- Expose custom API routes under `//api/v1/...` + +## What You Must Not Do +- Do not modify core services or routes. +- Do not patch LNbits internals for your extension. +- Avoid direct DB access outside your own schema. + +## Background Tasks +Implement background tasks by exposing: + +``` +def _start(): +def _stop(): +``` + +Use `register_invoice_listener` or `wait_for_paid_invoices` if you need to react to payments. + +## Testing Checklist +- Extension loads without errors. +- Migrations apply cleanly. +- Routes are registered under `//...`. +- Background tasks start/stop cleanly. diff --git a/docs/devs/agents_wasm_extensions.md b/docs/devs/agents_wasm_extensions.md new file mode 100644 index 000000000..9f277b0d7 --- /dev/null +++ b/docs/devs/agents_wasm_extensions.md @@ -0,0 +1,137 @@ +--- +layout: default +parent: For developers +title: Agent Guide - WASM Extensions +nav_order: 3 +--- + +# Agent Guide - WASM Extensions + +This guide is written for AI agents or developers using AI to build LNbits WASM extensions. It describes what to change, what not to change, and the available capabilities/limits. + +## Hard Rules (Non-Negotiable) +- Do **not** change core LNbits files. Only edit files inside your extension folder. +- Do **not** add new Python dependencies. +- Do **not** rely on long-running WASM processes. WASM runs per-call with timeouts. + +## Extension Folder Layout (WASM) +Your extension lives under: + +``` +lnbits/extensions// +``` + +You should only edit files under this folder, typically: +- `config.json` (metadata, permissions, tags, public handlers) +- `wasm/` (your `module.wasm` or `module.wat`) +- `static/` (frontend assets) +- `templates/` (HTML pages) +- `manifest.json`, `README.md`, `description.md` (docs and metadata) + +## Required Config Fields +In `config.json`: +- `id` / `name` +- `extension_type: "wasm"` +- `permissions` (required API permissions) +- `public_wasm_functions` (handlers callable from public routes) +- `public_kv_keys` (publicly readable KV keys) +- `payment_tags` (list of tags the user may grant for watcher access) + +Example: +```json +{ + "id": "myext", + "name": "MyExt", + "extension_type": "wasm", + "permissions": [ + {"id": "ext.db.read_write", "label": "DB access", "description": "..."}, + {"id": "api.POST:/api/v1/payments", "label": "Create invoices", "description": "..."}, + {"id": "ext.payments.watch", "label": "Watch payments", "description": "..."}, + {"id": "ext.tasks.schedule", "label": "Schedule tasks", "description": "..."}, + {"id": "ext.db.sql", "label": "SQL access", "description": "..."} + ], + "public_wasm_functions": ["public_create_invoice", "on_tag_payment", "on_schedule"], + "public_kv_keys": ["public_lists", "public_tasks"], + "payment_tags": ["coinflip", "myext"] +} +``` + +## What the WASM Host Can Do +WASM runs in a short-lived subprocess. It can: +- Read/write extension KV (`/api/v1/kv/*`) +- Read/write secret KV (`/api/v1/secret/*`) +- Call internal LNbits endpoints (only if declared + granted) +- Publish websockets (`ws_publish`) +- Run backend tag watchers and scheduled handlers (server-side triggers) + +## Permissions Model +Your extension can only call or access what is declared and granted: +- `api.METHOD:/path` for internal endpoints (core or other extensions) +- `ext.db.read_write` for KV access +- `ext.payments.watch` for payment watchers +- `ext.tasks.schedule` for scheduled jobs +- `ext.db.sql` for SQL interface + +If the endpoint doesn’t exist, permissions won’t save. + +## Tag Watchers (Backend) +You can register tag watchers: + +``` +POST //api/v1/watch_tag +{ + "tag": "coinflip", + "wallet_id": "", + "handler": "on_tag_payment", + "store_key": "tag:coinflip:last_payment" +} +``` + +Constraints: +- Tag must be in `payment_tags` and granted by the user. +- Watchers are persisted and restored on restart. + +## Scheduled Tasks (Backend) +You can schedule periodic handlers: + +``` +POST //api/v1/schedule +{ + "interval_seconds": 30, + "handler": "on_schedule", + "store_key": "schedule:last_run" +} +``` + +Constraints: +- Requires `ext.tasks.schedule` permission. +- Minimum interval is 5 seconds. +- Stored in extension KV and restored on restart. + +## SQL Interface (Limited) +You can run SQL within your extension schema: +- `/api/v1/sql/query` (SELECT only) +- `/api/v1/sql/exec` (limited DDL/DML) + +Rules: +- Single statement only +- No `PRAGMA`, no `sqlite_master` +- No cross-schema access + +## Public Pages (No Keys) +Public pages must not depend on `window.g` or wallet keys. +They can call: +- `/{ext_id}/api/v1/public/kv/{key}` +- `/{ext_id}/api/v1/public/call/{handler}` + +## What Not To Do +- Do not write to core routes or override existing LNbits paths. +- Do not add background threads; use watchers or scheduler instead. +- Do not assume the WASM process persists. + +## Testing Checklist +- Permissions show correctly in the extensions UI. +- Public handlers are in `public_wasm_functions`. +- Public KV keys are explicitly listed. +- Tag watchers only use allowed tags. +- Scheduled handlers run and update KV as expected. diff --git a/lnbits/app.py b/lnbits/app.py index 252dc1f11..2de12c69e 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -39,7 +39,11 @@ from lnbits.core.tasks import ( wait_for_paid_invoices, wait_notification_messages, ) -from lnbits.core.wasm.extension_host import register_wasm_ext_routes +from lnbits.core.wasm.extension_host import ( + handle_wasm_tag_payment, + register_wasm_ext_routes, + wasm_scheduler, +) from lnbits.exceptions import register_exception_handlers from lnbits.helpers import version_parse from lnbits.settings import settings @@ -502,6 +506,10 @@ def register_async_tasks() -> None: register_invoice_listener(invoice_queue, "core") create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue)) + # wasm tag watcher listener + create_permanent_task(wait_for_paid_invoices("wasm_tags", handle_wasm_tag_payment)) + create_permanent_task(wasm_scheduler) + create_permanent_task(run_by_the_minute_tasks) create_permanent_task(purge_audit_data) create_permanent_task(collect_exchange_rates_data) diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py index 1f10c7f14..0c609e14a 100644 --- a/lnbits/core/models/extensions.py +++ b/lnbits/core/models/extensions.py @@ -87,6 +87,7 @@ class ExtensionConfig(BaseModel): extension_type: str | None = "python" public_kv_keys: list[str] = [] public_wasm_functions: list[str] = [] + payment_tags: list[str] = [] def is_version_compatible(self) -> bool: return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) @@ -120,6 +121,7 @@ class UserExtensionInfo(BaseModel): paid_to_enable: bool | None = False payment_hash_to_enable: str | None = None granted_permissions: list[str] | None = None + granted_payment_tags: list[str] | None = None class ExtensionPermission(BaseModel): @@ -131,6 +133,7 @@ class ExtensionPermission(BaseModel): class ExtensionPermissionsGrant(BaseModel): permissions: list[str] = [] + payment_tags: list[str] = [] class UserExtension(BaseModel): @@ -166,6 +169,7 @@ class Extension(BaseModel): extension_type: str | None = None public_kv_keys: list[str] = [] public_wasm_functions: list[str] = [] + payment_tags: list[str] = [] @property def module_name(self) -> str: @@ -194,6 +198,7 @@ class Extension(BaseModel): public_wasm_functions=ext_info.meta.public_wasm_functions if ext_info.meta else [], + payment_tags=ext_info.meta.payment_tags if ext_info.meta else [], ) @@ -359,6 +364,7 @@ class ExtensionMeta(BaseModel): extension_type: str | None = "python" public_kv_keys: list[str] = [] public_wasm_functions: list[str] = [] + payment_tags: list[str] = [] archive: str | None = None featured: bool = False paid_features: str | None = None @@ -489,6 +495,7 @@ class InstallableExtension(BaseModel): self.meta.public_wasm_functions = config_json.get( "public_wasm_functions", [] ) + self.meta.payment_tags = config_json.get("payment_tags", []) if ( self.meta @@ -611,6 +618,7 @@ class InstallableExtension(BaseModel): extension_type=config.extension_type, public_kv_keys=config.public_kv_keys, public_wasm_functions=config.public_wasm_functions, + payment_tags=config.payment_tags, ), ) except Exception as e: @@ -663,6 +671,7 @@ class InstallableExtension(BaseModel): public_wasm_functions=config_json.get( "public_wasm_functions", [] ), + payment_tags=config_json.get("payment_tags", []), ), ) diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py index 7da80f8d6..c8f1e7608 100644 --- a/lnbits/core/services/extensions.py +++ b/lnbits/core/services/extensions.py @@ -17,6 +17,10 @@ from lnbits.core.crud.extensions import ( update_installed_extension, ) from lnbits.core.helpers import migrate_extension_database +from lnbits.core.wasm.extension_host import ( + clear_schedules_for_extension, + clear_tag_watches_for_extension, +) from lnbits.db import Connection, Database, COCKROACH, POSTGRES from lnbits.settings import settings @@ -75,6 +79,8 @@ async def uninstall_extension(ext_id: str): extension = await get_installed_extension(ext_id) if extension: if extension.meta and extension.meta.extension_type == "wasm": + await clear_tag_watches_for_extension(ext_id) + await clear_schedules_for_extension(ext_id) await _purge_wasm_extension_db(ext_id) extension.clean_extension_files() await delete_installed_extension(ext_id=ext_id) @@ -101,6 +107,10 @@ async def activate_extension(ext: Extension): async def deactivate_extension(ext_id: str): settings.deactivate_extension_paths(ext_id) await update_installed_extension_state(ext_id=ext_id, active=False) + extension = await get_installed_extension(ext_id) + if extension and extension.meta and extension.meta.extension_type == "wasm": + await clear_tag_watches_for_extension(ext_id) + await clear_schedules_for_extension(ext_id) await stop_extension_background_work(ext_id) diff --git a/lnbits/core/services/notifications.py b/lnbits/core/services/notifications.py index 8860fbd6a..7c711c40a 100644 --- a/lnbits/core/services/notifications.py +++ b/lnbits/core/services/notifications.py @@ -313,6 +313,11 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment): payment.payment_hash, json.dumps({"pending": payment.pending, "status": payment.status}), ) + if payment.tag and not payment.is_out: + await websocket_manager.send( + f"tag:{wallet.id}:{payment.tag}", + payment.json(), + ) async def send_chat_payment_notification(wallet: Wallet, payment: Payment): diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py index 58c950311..21154d99c 100644 --- a/lnbits/core/views/extension_api.py +++ b/lnbits/core/views/extension_api.py @@ -8,6 +8,7 @@ import httpx from bolt11 import decode as bolt11_decode from fastapi import APIRouter, Body, Depends, HTTPException from fastapi.requests import Request +from starlette.routing import Match from loguru import logger from lnbits.core.crud.extensions import get_user_extensions @@ -34,6 +35,7 @@ from lnbits.core.models.extensions import ( UserExtension, UserExtensionInfo, ) +from lnbits.core.wasm import WASM_HOST_MANIFEST from lnbits.core.models.users import Account, AccountId from lnbits.core.services import check_transaction_status, create_invoice from lnbits.core.services.extensions import ( @@ -44,6 +46,10 @@ from lnbits.core.services.extensions import ( install_extension, uninstall_extension, ) +from lnbits.core.wasm.extension_host import ( + clear_schedules_for_user, + clear_tag_watches_for_user, +) from lnbits.db import Page from lnbits.decorators import ( check_account_exists, @@ -179,6 +185,7 @@ async def api_update_pay_to_enable( @extension_router.put("/{ext_id}/enable") async def api_enable_extension( ext_id: str, + request: Request, account_id: AccountId = Depends(check_account_id_exists), grant: ExtensionPermissionsGrant | None = Body(default=None), ) -> SimpleStatus: @@ -190,10 +197,19 @@ async def api_enable_extension( required_permissions = _get_required_permissions(ext_id, ext) granted_permissions = _get_granted_permissions(grant, user_ext) + granted_tags = _get_granted_payment_tags(grant, user_ext) + required_tags = _get_required_payment_tags(ext_id, ext) + if _is_wasm_extension(ext_id, ext): + _ensure_api_permissions_available( + request, _merge_permissions(required_permissions, granted_permissions) + ) + _ensure_payment_tags_allowed(required_tags, granted_tags) _ensure_permissions(required_permissions, granted_permissions) if grant and grant.permissions: await _store_granted_permissions(user_ext, granted_permissions) + if grant and grant.payment_tags: + await _store_granted_payment_tags(user_ext, granted_tags) if account_id.is_admin_id or not ext.requires_payment: await _activate_user_extension(user_ext) @@ -245,6 +261,25 @@ def _get_granted_permissions( return [] +def _get_required_payment_tags(ext_id: str, ext: InstallableExtension) -> list[str]: + tags_source = ( + ext.meta.payment_tags + if ext.meta and ext.meta.payment_tags + else _load_payment_tags_from_config(ext_id) + ) + return tags_source if tags_source else [] + + +def _get_granted_payment_tags( + grant: ExtensionPermissionsGrant | None, user_ext: UserExtension +) -> list[str]: + if grant and grant.payment_tags: + return grant.payment_tags + if user_ext.extra and user_ext.extra.granted_payment_tags: + return user_ext.extra.granted_payment_tags + return [] + + def _ensure_permissions(required: list[str], granted: list[str]) -> None: if not required: return @@ -265,6 +300,15 @@ async def _store_granted_permissions( await update_user_extension(user_ext) +async def _store_granted_payment_tags( + user_ext: UserExtension, granted_tags: list[str] +) -> None: + user_ext_info = user_ext.extra or UserExtensionInfo() + user_ext_info.granted_payment_tags = granted_tags + user_ext.extra = user_ext_info + await update_user_extension(user_ext) + + async def _activate_user_extension(user_ext: UserExtension) -> None: user_ext.active = True await update_user_extension(user_ext) @@ -318,13 +362,20 @@ async def api_disable_extension( user_ext.active = False if user_ext.extra and user_ext.extra.granted_permissions: user_ext.extra.granted_permissions = [] + if user_ext.extra and user_ext.extra.granted_payment_tags: + user_ext.extra.granted_payment_tags = [] await update_user_extension(user_ext) + ext = await get_installed_extension(ext_id) + if ext and ext.meta and ext.meta.extension_type == "wasm": + await clear_tag_watches_for_user(ext_id, account_id.id) + await clear_schedules_for_user(ext_id, account_id.id) return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.") @extension_router.put("/{ext_id}/permissions") async def api_update_extension_permissions( ext_id: str, + request: Request, account_id: AccountId = Depends(check_account_id_exists), grant: ExtensionPermissionsGrant | None = Body(default=None), ) -> SimpleStatus: @@ -353,6 +404,14 @@ async def api_update_extension_permissions( [p.id for p in permissions_source] if permissions_source else [] ) granted_permissions = grant.permissions if grant and grant.permissions else [] + granted_tags = grant.payment_tags if grant and grant.payment_tags else [] + if _is_wasm_extension(ext_id, ext): + _ensure_api_permissions_available( + request, _merge_permissions(required_permissions, granted_permissions) + ) + _ensure_payment_tags_allowed( + _get_required_payment_tags(ext_id, ext), granted_tags + ) if required_permissions: missing = [p for p in required_permissions if p not in granted_permissions] @@ -364,12 +423,54 @@ async def api_update_extension_permissions( user_ext_info = user_ext.extra or UserExtensionInfo() user_ext_info.granted_permissions = granted_permissions + user_ext_info.granted_payment_tags = granted_tags user_ext.extra = user_ext_info await update_user_extension(user_ext) return SimpleStatus(success=True, message=f"Permissions saved for '{ext_id}'.") +@extension_router.get("/{ext_id}/capabilities") +async def api_extension_capabilities( + ext_id: str, + request: Request, + account_id: AccountId = Depends(check_account_id_exists), +) -> dict: + await _ensure_extension_exists(ext_id) + ext = await get_installed_extension(ext_id) + if not ext: + raise ValueError(f"Extension '{ext_id}' is not installed.") + + permissions_source = ( + ext.meta.permissions + if ext.meta and ext.meta.permissions + else _load_permissions_from_config(ext_id) + ) + permissions = [p.id for p in permissions_source] if permissions_source else [] + missing = ( + _missing_api_permissions(request, permissions) + if _is_wasm_extension(ext_id, ext) + else [] + ) + payment_tags = _get_required_payment_tags(ext_id, ext) + + return { + "ok": True, + "extension": ext_id, + "is_wasm": _is_wasm_extension(ext_id, ext), + "permissions": permissions, + "missing_permissions": missing, + "payment_tags": payment_tags, + } + + +@extension_router.get("/wasm/manifest") +async def api_wasm_manifest( + account_id: AccountId = Depends(check_account_id_exists), +) -> dict: + return WASM_HOST_MANIFEST + + @extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)]) async def api_activate_extension(ext_id: str) -> SimpleStatus: try: @@ -681,6 +782,8 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)): e.icon = installed_ext.icon if e.meta and not e.meta.permissions: e.meta.permissions = _load_permissions_from_config(e.id) + if e.meta and not e.meta.payment_tags: + e.meta.payment_tags = _load_payment_tags_from_config(e.id) extension_data = [ { @@ -703,12 +806,22 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)): if ext.meta and ext.meta.permissions else [dict(p) for p in _load_permissions_from_config(ext.id)] ), + "paymentTags": ( + ext.meta.payment_tags + if ext.meta and ext.meta.payment_tags + else _load_payment_tags_from_config(ext.id) + ), "kvSchema": _load_kv_schema_from_config(ext.id), "grantedPermissions": ( user_ext.extra.granted_permissions if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra else [] ), + "grantedPaymentTags": ( + user_ext.extra.granted_payment_tags + if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra + else [] + ), "latestRelease": ( dict(ext.meta.latest_release) if ext.meta and ext.meta.latest_release @@ -766,6 +879,126 @@ def _load_kv_schema_from_config(ext_id: str) -> dict: return {} +def _load_payment_tags_from_config(ext_id: str) -> list[str]: + try: + conf_path = Path( + settings.lnbits_extensions_path, "extensions", ext_id, "config.json" + ) + if not conf_path.is_file(): + return [] + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + tags = config_json.get("payment_tags", []) + return tags if isinstance(tags, list) else [] + except Exception: + return [] + + +def _merge_permissions(required: list[str], granted: list[str]) -> list[str]: + merged = set() + for perm in required or []: + merged.add(perm) + for perm in granted or []: + merged.add(perm) + return list(merged) + + +def _ensure_payment_tags_allowed(required: list[str], granted: list[str]) -> None: + if not required and granted: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "This extension does not declare any payment tags.", + ) + if not required or not granted: + return + invalid = [t for t in granted if t not in required] + if invalid: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"Invalid payment tags requested: {', '.join(invalid)}", + ) + + +def _ensure_api_permissions_available(request: Request, permissions: list[str]) -> None: + if not permissions: + return + missing = [] + for perm in permissions: + parsed = _parse_api_permission(perm) + if not parsed: + continue + method, path = parsed + if not _route_exists(request, method, path): + missing.append(perm) + if missing: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"Permissions reference missing endpoints: {', '.join(missing)}", + ) + + +def _parse_api_permission(perm: str) -> tuple[str, str] | None: + if not perm.startswith("api."): + return None + try: + method_part, path = perm.split(":", 1) + method = method_part.replace("api.", "").upper() + except ValueError: + return None + if not path.startswith("/"): + return None + return method, path + + +def _missing_api_permissions(request: Request, permissions: list[str]) -> list[str]: + missing = [] + for perm in permissions or []: + parsed = _parse_api_permission(perm) + if not parsed: + continue + method, path = parsed + if not _route_exists(request, method, path): + missing.append(perm) + return missing + + +def _route_exists(request: Request, method: str, path: str) -> bool: + scope = { + "type": "http", + "method": method, + "path": path, + "root_path": "", + "headers": [], + } + for route in request.app.router.routes: + methods = getattr(route, "methods", None) + if methods and method not in methods: + continue + try: + match, _ = route.matches(scope) + except Exception: + continue + if match == Match.FULL: + return True + return False + + +def _is_wasm_extension(ext_id: str, ext: InstallableExtension) -> bool: + if ext.meta and ext.meta.extension_type == "wasm": + return True + try: + conf_path = Path( + settings.lnbits_extensions_path, "extensions", ext_id, "config.json" + ) + if not conf_path.is_file(): + return False + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + return config_json.get("extension_type") == "wasm" + except Exception: + return False + + @extension_router.get( "/reviews/tags", dependencies=[Depends(check_account_exists)], diff --git a/lnbits/core/views/websocket_api.py b/lnbits/core/views/websocket_api.py index 956e2cbda..88d2f7cb8 100644 --- a/lnbits/core/views/websocket_api.py +++ b/lnbits/core/views/websocket_api.py @@ -1,5 +1,7 @@ from fastapi import APIRouter, WebSocket +from ..crud.wallets import get_wallet_for_key +from ..models import KeyType from ..services import websocket_manager websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"]) @@ -11,6 +13,28 @@ async def websocket_connect(websocket: WebSocket, item_id: str) -> None: await websocket_manager.listen(conn) +@websocket_router.websocket("/tag/{tag}") +async def websocket_connect_tag(websocket: WebSocket, tag: str) -> None: + api_key = websocket.headers.get("X-API-KEY") or websocket.query_params.get("api-key") + if not api_key: + await websocket.close(code=4401) + return + + wallet = await get_wallet_for_key(api_key) + if not wallet: + await websocket.close(code=4404) + return + + key_type = KeyType.admin if wallet.adminkey == api_key else KeyType.invoice + if key_type not in {KeyType.admin, KeyType.invoice}: + await websocket.close(code=4403) + return + + item_id = f"tag:{wallet.id}:{tag}" + conn = await websocket_manager.connect(item_id, websocket) + await websocket_manager.listen(conn) + + @websocket_router.post("/{item_id}") async def websocket_update_post(item_id: str, data: str): try: diff --git a/lnbits/core/wasm/__init__.py b/lnbits/core/wasm/__init__.py index e69de29bb..34ff3541f 100644 --- a/lnbits/core/wasm/__init__.py +++ b/lnbits/core/wasm/__init__.py @@ -0,0 +1,87 @@ +WASM_HOST_API_VERSION = "1.0" + +WASM_HOST_MANIFEST = { + "version": WASM_HOST_API_VERSION, + "host_functions": [ + "db_get", + "db_set", + "db_secret_get", + "db_secret_set", + "http_request", + "ws_publish", + ], + "routes": [ + {"method": "GET", "path": "/{ext_id}/", "public": False}, + {"method": "GET", "path": "/{ext_id}/public/{key}", "public": True}, + { + "method": "GET", + "path": "/{ext_id}/api/v1/kv/{key}", + "permission": "ext.db.read_write", + }, + { + "method": "POST", + "path": "/{ext_id}/api/v1/kv/{key}", + "permission": "ext.db.read_write", + }, + { + "method": "GET", + "path": "/{ext_id}/api/v1/public/kv/{key}", + "public": True, + }, + { + "method": "POST", + "path": "/{ext_id}/api/v1/secret/{key}", + "permission": "ext.db.read_write", + }, + { + "method": "DELETE", + "path": "/{ext_id}/api/v1/secret/{key}", + "permission": "ext.db.read_write", + }, + { + "method": "POST", + "path": "/{ext_id}/api/v1/public/call/{handler}", + "public": True, + }, + { + "method": "POST", + "path": "/{ext_id}/api/v1/watch", + "permission": "ext.payments.watch", + }, + { + "method": "POST", + "path": "/{ext_id}/api/v1/watch_tag", + "permission": "ext.payments.watch", + }, + { + "method": "DELETE", + "path": "/{ext_id}/api/v1/watch_tag", + "permission": "ext.payments.watch", + }, + { + "method": "POST", + "path": "/{ext_id}/api/v1/schedule", + "permission": "ext.tasks.schedule", + }, + { + "method": "DELETE", + "path": "/{ext_id}/api/v1/schedule", + "permission": "ext.tasks.schedule", + }, + { + "method": "POST", + "path": "/{ext_id}/api/v1/sql/query", + "permission": "ext.db.sql", + }, + { + "method": "POST", + "path": "/{ext_id}/api/v1/sql/exec", + "permission": "ext.db.sql", + }, + { + "method": "POST", + "path": "/{ext_id}/api/v1/proxy", + "permission": "api.METHOD:/path", + }, + ], +} diff --git a/lnbits/core/wasm/extension_host.py b/lnbits/core/wasm/extension_host.py index 4f9b7a6f5..0b55ed02c 100644 --- a/lnbits/core/wasm/extension_host.py +++ b/lnbits/core/wasm/extension_host.py @@ -5,6 +5,7 @@ import json import re import time from pathlib import Path +from dataclasses import dataclass from typing import Any import httpx @@ -14,6 +15,8 @@ from fastapi.staticfiles import StaticFiles from lnbits.core.crud.extensions import get_user_extension from lnbits.core.models import User +from lnbits.core.models.payments import Payment +from lnbits.core.crud.wallets import get_wallet from lnbits.core.crud.payments import get_standalone_payment, update_payment from lnbits.core.services import websocket_updater from loguru import logger @@ -26,6 +29,512 @@ from lnbits.tasks import register_invoice_listener, unregister_invoice_listener from .service import WasmExecutionError, wasm_call +@dataclass +class TagWatch: + ext_id: str + user_id: str + wallet_id: str + tag: str + handler: str + store_key: str + upgrade_hash: str | None + + +_tag_watchers: dict[tuple[str, str], list[TagWatch]] = {} +_TAG_WATCH_KV_KEY = "watch_tags" + + +@dataclass +class ScheduleTask: + ext_id: str + user_id: str + handler: str + interval_seconds: int + store_key: str + upgrade_hash: str | None + next_run: float + + +_scheduled_tasks: dict[str, list[ScheduleTask]] = {} +_SCHEDULE_KV_KEY = "scheduled_tasks" + + +def _register_tag_watch(watch: TagWatch) -> None: + key = (watch.wallet_id, watch.tag) + existing = _tag_watchers.get(key, []) + for item in existing: + if ( + item.ext_id == watch.ext_id + and item.user_id == watch.user_id + and item.handler == watch.handler + and item.store_key == watch.store_key + ): + return + existing.append(watch) + _tag_watchers[key] = existing + + +async def _load_schedule_list(db: Database, ext_id: str) -> list[dict]: + raw = await _kv_get(db, ext_id, _SCHEDULE_KV_KEY) + if not raw: + return [] + try: + data = json.loads(raw) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +async def _save_schedule_list(db: Database, ext_id: str, items: list[dict]) -> None: + await _kv_set(db, ext_id, _SCHEDULE_KV_KEY, json.dumps(items)) + + +def _register_schedule(task: ScheduleTask) -> None: + existing = _scheduled_tasks.get(task.ext_id, []) + for item in existing: + if ( + item.user_id == task.user_id + and item.handler == task.handler + and item.interval_seconds == task.interval_seconds + and item.store_key == task.store_key + ): + return + existing.append(task) + _scheduled_tasks[task.ext_id] = existing + + +async def _persist_schedule(ext_id: str, task: ScheduleTask) -> None: + db = Database(f"ext_{ext_id}") + items = await _load_schedule_list(db, ext_id) + for item in items: + if ( + item.get("user_id") == task.user_id + and item.get("handler") == task.handler + and item.get("interval_seconds") == task.interval_seconds + and item.get("store_key") == task.store_key + ): + return + items.append( + { + "user_id": task.user_id, + "handler": task.handler, + "interval_seconds": task.interval_seconds, + "store_key": task.store_key, + "upgrade_hash": task.upgrade_hash, + } + ) + await _save_schedule_list(db, ext_id, items) + + +async def _remove_persisted_schedule_entries( + ext_id: str, + user_id: str | None = None, + handler: str | None = None, + store_key: str | None = None, +) -> None: + try: + db = Database(f"ext_{ext_id}") + items = await _load_schedule_list(db, ext_id) + items = [ + item + for item in items + if not ( + (user_id is None or item.get("user_id") == user_id) + and (handler is None or item.get("handler") == handler) + and (store_key is None or item.get("store_key") == store_key) + ) + ] + await _save_schedule_list(db, ext_id, items) + except Exception: + return + + +def _remove_schedule_entries( + ext_id: str, + user_id: str | None = None, + handler: str | None = None, + store_key: str | None = None, +) -> None: + existing = _scheduled_tasks.get(ext_id, []) + if not existing: + return + _scheduled_tasks[ext_id] = [ + item + for item in existing + if not ( + (user_id is None or item.user_id == user_id) + and (handler is None or item.handler == handler) + and (store_key is None or item.store_key == store_key) + ) + ] + if not _scheduled_tasks[ext_id]: + _scheduled_tasks.pop(ext_id, None) + + +async def restore_schedules(ext_id: str, upgrade_hash: str | None) -> None: + try: + db = Database(f"ext_{ext_id}") + items = await _load_schedule_list(db, ext_id) + now = time.time() + for item in items: + user_id = item.get("user_id") + handler = item.get("handler") + interval_seconds = item.get("interval_seconds") + store_key = item.get("store_key") + if not user_id or not handler or not interval_seconds or not store_key: + continue + user_ext = await get_user_extension(user_id, ext_id) + if not user_ext or not user_ext.active: + continue + granted = user_ext.extra.granted_permissions if user_ext.extra else [] + if "ext.tasks.schedule" not in (granted or []): + continue + _register_schedule( + ScheduleTask( + ext_id=ext_id, + user_id=user_id, + handler=handler, + interval_seconds=int(interval_seconds), + store_key=store_key, + upgrade_hash=upgrade_hash, + next_run=now + int(interval_seconds), + ) + ) + except Exception: + return + + +def _matches_watch( + watch: TagWatch, + ext_id: str, + user_id: str, + wallet_id: str, + tag: str, + handler: str | None, + store_key: str | None, +) -> bool: + if watch.ext_id != ext_id: + return False + if watch.user_id != user_id: + return False + if watch.wallet_id != wallet_id: + return False + if watch.tag != tag: + return False + if handler is not None and watch.handler != handler: + return False + if store_key is not None and watch.store_key != store_key: + return False + return True + + +async def _load_tag_watch_list(db: Database, ext_id: str) -> list[dict]: + raw = await _kv_get(db, ext_id, _TAG_WATCH_KV_KEY) + if not raw: + return [] + try: + data = json.loads(raw) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +async def _save_tag_watch_list(db: Database, ext_id: str, items: list[dict]) -> None: + await _kv_set(db, ext_id, _TAG_WATCH_KV_KEY, json.dumps(items)) + + +async def _persist_tag_watch(ext_id: str, watch: TagWatch) -> None: + db = Database(f"ext_{ext_id}") + items = await _load_tag_watch_list(db, ext_id) + for item in items: + if ( + item.get("user_id") == watch.user_id + and item.get("wallet_id") == watch.wallet_id + and item.get("tag") == watch.tag + and item.get("handler") == watch.handler + and item.get("store_key") == watch.store_key + ): + return + items.append( + { + "user_id": watch.user_id, + "wallet_id": watch.wallet_id, + "tag": watch.tag, + "handler": watch.handler, + "store_key": watch.store_key, + "upgrade_hash": watch.upgrade_hash, + } + ) + await _save_tag_watch_list(db, ext_id, items) + + +async def _remove_persisted_tag_watch(ext_id: str, watch: TagWatch) -> None: + try: + db = Database(f"ext_{ext_id}") + items = await _load_tag_watch_list(db, ext_id) + items = [ + item + for item in items + if not ( + item.get("user_id") == watch.user_id + and item.get("wallet_id") == watch.wallet_id + and item.get("tag") == watch.tag + and item.get("handler") == watch.handler + and item.get("store_key") == watch.store_key + ) + ] + await _save_tag_watch_list(db, ext_id, items) + except Exception: + return + + +async def _remove_persisted_tag_watch_entries( + ext_id: str, + user_id: str, + wallet_id: str, + tag: str, + handler: str | None, + store_key: str | None, +) -> None: + try: + db = Database(f"ext_{ext_id}") + items = await _load_tag_watch_list(db, ext_id) + items = [ + item + for item in items + if not ( + item.get("user_id") == user_id + and item.get("wallet_id") == wallet_id + and item.get("tag") == tag + and (handler is None or item.get("handler") == handler) + and (store_key is None or item.get("store_key") == store_key) + ) + ] + await _save_tag_watch_list(db, ext_id, items) + except Exception: + return + + +async def handle_wasm_tag_payment(payment: Payment) -> None: + if not payment.tag or not payment.is_in: + return + key = (payment.wallet_id, payment.tag) + watchers = list(_tag_watchers.get(key, [])) + if not watchers: + return + for watch in watchers: + asyncio.create_task(_dispatch_tag_watch(payment, watch)) + + +async def _dispatch_tag_watch(payment: Payment, watch: TagWatch) -> None: + try: + user_ext = await get_user_extension(watch.user_id, watch.ext_id) + if not user_ext or not user_ext.active: + _remove_tag_watch(payment.wallet_id, payment.tag, watch) + await _remove_persisted_tag_watch(watch.ext_id, watch) + return + granted_tags = user_ext.extra.granted_payment_tags if user_ext.extra else [] + if watch.tag not in (granted_tags or []): + _remove_tag_watch(payment.wallet_id, payment.tag, watch) + await _remove_persisted_tag_watch(watch.ext_id, watch) + return + + _check_quota( + watch.user_id, watch.ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min + ) + + db = Database(f"ext_{watch.ext_id}") + payload_json = json.dumps(payment.dict(exclude={"preimage"})) + await _kv_set(db, watch.ext_id, watch.store_key, payload_json) + await websocket_updater(f"{watch.ext_id}:{watch.store_key}", payload_json) + + watch_payload = { + "payment_hash": payment.payment_hash, + "store_key": watch.store_key, + "tag": watch.tag, + } + await _kv_set(db, watch.ext_id, "watch_request", json.dumps(watch_payload)) + await _kv_set(db, watch.ext_id, "public_request", watch.tag) + await wasm_call( + watch.ext_id, + watch.handler, + [], + upgrade_hash=watch.upgrade_hash, + ) + except Exception: + return + + +def _remove_tag_watch(wallet_id: str, tag: str, watch: TagWatch) -> None: + key = (wallet_id, tag) + existing = _tag_watchers.get(key, []) + if not existing: + return + _tag_watchers[key] = [ + item + for item in existing + if not ( + item.ext_id == watch.ext_id + and item.user_id == watch.user_id + and item.handler == watch.handler + and item.store_key == watch.store_key + ) + ] + if not _tag_watchers[key]: + _tag_watchers.pop(key, None) + + +def _remove_tag_watch_entries( + ext_id: str, + user_id: str, + wallet_id: str, + tag: str, + handler: str | None, + store_key: str | None, +) -> None: + key = (wallet_id, tag) + existing = _tag_watchers.get(key, []) + if not existing: + return + _tag_watchers[key] = [ + item + for item in existing + if not _matches_watch(item, ext_id, user_id, wallet_id, tag, handler, store_key) + ] + if not _tag_watchers[key]: + _tag_watchers.pop(key, None) + + +async def clear_tag_watches_for_user(ext_id: str, user_id: str) -> None: + keys = list(_tag_watchers.keys()) + for wallet_id, tag in keys: + existing = _tag_watchers.get((wallet_id, tag), []) + remaining = [w for w in existing if not (w.ext_id == ext_id and w.user_id == user_id)] + if remaining: + _tag_watchers[(wallet_id, tag)] = remaining + else: + _tag_watchers.pop((wallet_id, tag), None) + try: + db = Database(f"ext_{ext_id}") + items = await _load_tag_watch_list(db, ext_id) + items = [item for item in items if item.get("user_id") != user_id] + await _save_tag_watch_list(db, ext_id, items) + except Exception: + return + + +async def clear_tag_watches_for_extension(ext_id: str) -> None: + keys = list(_tag_watchers.keys()) + for wallet_id, tag in keys: + existing = _tag_watchers.get((wallet_id, tag), []) + remaining = [w for w in existing if w.ext_id != ext_id] + if remaining: + _tag_watchers[(wallet_id, tag)] = remaining + else: + _tag_watchers.pop((wallet_id, tag), None) + try: + db = Database(f"ext_{ext_id}") + await _save_tag_watch_list(db, ext_id, []) + except Exception: + return + + +async def clear_schedules_for_user(ext_id: str, user_id: str) -> None: + _remove_schedule_entries(ext_id, user_id=user_id) + await _remove_persisted_schedule_entries(ext_id, user_id=user_id) + + +async def clear_schedules_for_extension(ext_id: str) -> None: + _remove_schedule_entries(ext_id) + await _remove_persisted_schedule_entries(ext_id) + + +async def restore_tag_watches(ext_id: str, upgrade_hash: str | None) -> None: + try: + db = Database(f"ext_{ext_id}") + items = await _load_tag_watch_list(db, ext_id) + for item in items: + user_id = item.get("user_id") + wallet_id = item.get("wallet_id") + tag = item.get("tag") + handler = item.get("handler") + store_key = item.get("store_key") + if not user_id or not wallet_id or not tag or not handler or not store_key: + continue + user_ext = await get_user_extension(user_id, ext_id) + if not user_ext or not user_ext.active: + continue + granted_tags = user_ext.extra.granted_payment_tags if user_ext.extra else [] + if tag not in (granted_tags or []): + continue + wallet = await get_wallet(wallet_id) + if not wallet or wallet.user != user_id: + continue + _register_tag_watch( + TagWatch( + ext_id=ext_id, + user_id=user_id, + wallet_id=wallet_id, + tag=tag, + handler=handler, + store_key=store_key, + upgrade_hash=upgrade_hash, + ) + ) + except Exception: + return + + +async def wasm_scheduler() -> None: + while settings.lnbits_running: + now = time.time() + for ext_id, tasks in list(_scheduled_tasks.items()): + for task in list(tasks): + if task.next_run > now: + continue + asyncio.create_task(_dispatch_schedule_task(task)) + task.next_run = now + max(1, task.interval_seconds) + await asyncio.sleep(1) + + +async def _dispatch_schedule_task(task: ScheduleTask) -> None: + try: + user_ext = await get_user_extension(task.user_id, task.ext_id) + if not user_ext or not user_ext.active: + _remove_schedule_entries(task.ext_id, task.user_id, task.handler, task.store_key) + await _remove_persisted_schedule_entries( + task.ext_id, task.user_id, task.handler, task.store_key + ) + return + granted = user_ext.extra.granted_permissions if user_ext.extra else [] + if "ext.tasks.schedule" not in (granted or []): + _remove_schedule_entries(task.ext_id, task.user_id, task.handler, task.store_key) + await _remove_persisted_schedule_entries( + task.ext_id, task.user_id, task.handler, task.store_key + ) + return + + _check_quota( + task.user_id, + task.ext_id, + "db", + settings.lnbits_wasm_max_db_ops_per_min, + ) + + db = Database(f"ext_{task.ext_id}") + payload = {"ts": time.time(), "interval_seconds": task.interval_seconds} + await _kv_set(db, task.ext_id, task.store_key, json.dumps(payload)) + await websocket_updater(f"{task.ext_id}:{task.store_key}", json.dumps(payload)) + await wasm_call( + task.ext_id, + task.handler, + [], + upgrade_hash=task.upgrade_hash, + ) + except Exception: + return + + def _renderer(ext_id: str): return template_renderer([f"{ext_id}/templates"]) @@ -129,6 +638,32 @@ def _schema_for_key(schema: dict, key: str) -> dict | None: return entry if isinstance(entry, dict) else None +def _validate_sql(ext_id: str, sql: str, read_only: bool) -> None: + statement = sql.strip().strip(";").lower() + if ";" in statement: + raise HTTPException(400, "Only single-statement SQL is allowed") + if read_only: + if not statement.startswith("select "): + raise HTTPException(400, "Only SELECT is allowed in read-only queries") + else: + allowed = ( + "create table", + "alter table", + "insert", + "update", + "delete", + "drop table", + ) + if not any(statement.startswith(prefix) for prefix in allowed): + raise HTTPException(400, "Statement not allowed") + if "sqlite_master" in statement or "pragma" in statement: + raise HTTPException(400, "Statement not allowed") + for match in re.finditer(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.", statement): + schema = match.group(1) + if schema != ext_id: + raise HTTPException(400, "Cross-schema access is not allowed") + + def _coerce_schema_value(schema_entry: dict, value): value_type = schema_entry.get("type", "string") if value_type == "int": @@ -241,6 +776,15 @@ async def _require_permission(user_id: str, ext_id: str, permission: str) -> Non raise HTTPException(403, f"Missing permission: {permission}") +async def _require_payment_tag(user_id: str, ext_id: str, tag: str) -> None: + user_ext = await get_user_extension(user_id, ext_id) + if not user_ext or not user_ext.active: + raise HTTPException(403, "Extension not enabled.") + granted = user_ext.extra.granted_payment_tags if user_ext.extra else [] + if tag not in (granted or []): + raise HTTPException(403, f"Missing payment tag permission: {tag}") + + def _register_pages_routes(router: APIRouter, ext_id: str) -> None: @router.get("/", response_class=HTMLResponse) async def index(req: Request, user: User = Depends(check_user_exists)): @@ -266,7 +810,6 @@ def _register_pages_routes(router: APIRouter, ext_id: str) -> None: def _register_kv_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None: _register_kv_read_routes(router, ext_id, db, ext) _register_kv_write_routes(router, ext_id, db) - _register_kv_increment_route(router, ext_id, db, ext) _register_secret_routes(router, ext_id, db) @@ -393,26 +936,6 @@ def _register_kv_write_routes(router: APIRouter, ext_id: str, db: Database) -> N return {"key": key, "value": value} -def _register_kv_increment_route( - router: APIRouter, ext_id: str, db: Database, ext -) -> None: - @router.post("/api/v1/kv/{key}/increment") - async def api_kv_increment(key: str, user: User = Depends(check_user_exists)): - await _require_permission(user.id, ext_id, "ext.db.read_write") - _check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min) - if key != "counter": - raise HTTPException(400, "Only 'counter' is supported in this example.") - try: - new_value = await wasm_call( - ext_id, "increment_counter", [], upgrade_hash=ext.upgrade_hash - ) - except WasmExecutionError as exc: - raise HTTPException(500, str(exc)) from exc - await _kv_set(db, ext_id, key, str(new_value)) - await websocket_updater(f"{ext_id}:{key}", str(new_value)) - return {"key": key, "value": new_value} - - def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None: @router.post("/api/v1/watch") async def api_watch_payment(payload: dict, user: User = Depends(check_user_exists)): @@ -423,12 +946,165 @@ def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) -> if not payment_hash: raise HTTPException(400, "Missing payment_hash") await _require_permission(user.id, ext_id, "ext.payments.watch") + if tag: + await _require_payment_tag(user.id, ext_id, tag) await _require_permission(user.id, ext_id, "ext.db.read_write") task = _start_payment_watch( ext_id, db, payment_hash, handler, tag, store_key, ext.upgrade_hash ) return {"ok": True, "task_id": id(task)} + @router.post("/api/v1/watch_tag") + async def api_watch_tag(payload: dict, user: User = Depends(check_user_exists)): + tag = payload.get("tag") + handler = payload.get("handler") or "on_tag_payment" + store_key = payload.get("store_key") + wallet_id = payload.get("wallet_id") + if not tag: + raise HTTPException(400, "Missing tag") + if not wallet_id: + raise HTTPException(400, "Missing wallet_id") + await _require_permission(user.id, ext_id, "ext.payments.watch") + await _require_permission(user.id, ext_id, "ext.db.read_write") + await _require_payment_tag(user.id, ext_id, tag) + wallet = await get_wallet(wallet_id) + if not wallet or wallet.user != user.id: + raise HTTPException(403, "Wallet not found or not owned by user") + + funcs = getattr(ext, "public_wasm_functions", None) or _load_public_wasm_functions( + ext_id + ) + if handler not in funcs: + raise HTTPException(400, "Handler not allowed") + + if not store_key: + store_key = f"tag:{tag}:last_payment" + + _register_tag_watch( + TagWatch( + ext_id=ext_id, + user_id=user.id, + wallet_id=wallet_id, + tag=tag, + handler=handler, + store_key=store_key, + upgrade_hash=ext.upgrade_hash, + ) + ) + await _persist_tag_watch( + ext_id, + TagWatch( + ext_id=ext_id, + user_id=user.id, + wallet_id=wallet_id, + tag=tag, + handler=handler, + store_key=store_key, + upgrade_hash=ext.upgrade_hash, + ), + ) + + return {"ok": True} + + @router.delete("/api/v1/watch_tag") + async def api_watch_tag_delete( + payload: dict, user: User = Depends(check_user_exists) + ): + tag = payload.get("tag") + wallet_id = payload.get("wallet_id") + handler = payload.get("handler") + store_key = payload.get("store_key") + if not tag: + raise HTTPException(400, "Missing tag") + if not wallet_id: + raise HTTPException(400, "Missing wallet_id") + await _require_permission(user.id, ext_id, "ext.payments.watch") + wallet = await get_wallet(wallet_id) + if not wallet or wallet.user != user.id: + raise HTTPException(403, "Wallet not found or not owned by user") + _remove_tag_watch_entries( + ext_id, user.id, wallet_id, tag, handler, store_key + ) + await _remove_persisted_tag_watch_entries( + ext_id, user.id, wallet_id, tag, handler, store_key + ) + return {"ok": True} + + @router.post("/api/v1/schedule") + async def api_schedule_task(payload: dict, user: User = Depends(check_user_exists)): + interval_seconds = payload.get("interval_seconds") + handler = payload.get("handler") or "on_schedule" + store_key = payload.get("store_key") or "schedule:last_run" + if not interval_seconds: + raise HTTPException(400, "Missing interval_seconds") + try: + interval_seconds = int(interval_seconds) + except Exception: + raise HTTPException(400, "Invalid interval_seconds") + if interval_seconds < 5: + raise HTTPException(400, "Minimum interval is 5 seconds") + + await _require_permission(user.id, ext_id, "ext.tasks.schedule") + await _require_permission(user.id, ext_id, "ext.db.read_write") + + funcs = getattr(ext, "public_wasm_functions", None) or _load_public_wasm_functions( + ext_id + ) + if handler not in funcs: + raise HTTPException(400, "Handler not allowed") + + task = ScheduleTask( + ext_id=ext_id, + user_id=user.id, + handler=handler, + interval_seconds=interval_seconds, + store_key=store_key, + upgrade_hash=ext.upgrade_hash, + next_run=time.time() + interval_seconds, + ) + _register_schedule(task) + await _persist_schedule(ext_id, task) + return {"ok": True} + + @router.delete("/api/v1/schedule") + async def api_schedule_task_delete( + payload: dict, user: User = Depends(check_user_exists) + ): + handler = payload.get("handler") + store_key = payload.get("store_key") + await _require_permission(user.id, ext_id, "ext.tasks.schedule") + _remove_schedule_entries(ext_id, user.id, handler, store_key) + await _remove_persisted_schedule_entries(ext_id, user.id, handler, store_key) + return {"ok": True} + + @router.post("/api/v1/sql/query") + async def api_sql_query( + payload: dict, user: User = Depends(check_user_exists) + ) -> dict: + sql = payload.get("sql") + params = payload.get("params") or {} + if not sql: + raise HTTPException(400, "Missing sql") + await _require_permission(user.id, ext_id, "ext.db.sql") + _check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min) + _validate_sql(ext_id, sql, read_only=True) + rows = await db.fetchall(sql, params) # noqa: S608 + return {"rows": rows} + + @router.post("/api/v1/sql/exec") + async def api_sql_exec( + payload: dict, user: User = Depends(check_user_exists) + ) -> dict: + sql = payload.get("sql") + params = payload.get("params") or {} + if not sql: + raise HTTPException(400, "Missing sql") + await _require_permission(user.id, ext_id, "ext.db.sql") + _check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min) + _validate_sql(ext_id, sql, read_only=False) + await db.execute(sql, params) # noqa: S608 + return {"ok": True} + def _start_payment_watch( ext_id: str, @@ -575,6 +1251,8 @@ def register_wasm_ext_routes(app, ext) -> None: prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else "" app.include_router(router, prefix=prefix) + asyncio.create_task(restore_tag_watches(ext_id, ext.upgrade_hash)) + asyncio.create_task(restore_schedules(ext_id, ext.upgrade_hash)) _quota_events: dict[tuple[str, str, str], list[float]] = {} diff --git a/lnbits/static/js/pages/extensions.js b/lnbits/static/js/pages/extensions.js index 52ab9a497..490050549 100644 --- a/lnbits/static/js/pages/extensions.js +++ b/lnbits/static/js/pages/extensions.js @@ -31,7 +31,10 @@ window.PageExtensions = { permissionsDialog: { show: false, extension: null, - checked: [] + checked: [], + missing: [], + tags: [], + tagOptions: [] }, reviewsDialog: { show: false, @@ -103,6 +106,12 @@ window.PageExtensions = { if (!ext || !Array.isArray(ext.permissions)) return true const required = ext.permissions.map(p => p.id) return required.every(p => this.permissionsDialog.checked.includes(p)) + }, + permissionsHasMissingEndpoints() { + return ( + this.permissionsDialog.missing && + this.permissionsDialog.missing.length > 0 + ) } }, methods: { @@ -274,12 +283,17 @@ window.PageExtensions = { permissions || extension._grantedPermissions || (extension.permissions ? extension.permissions.map(p => p.id) : []) + const tags = + extension._grantedPaymentTags || + (extension.paymentTags ? extension.paymentTags.slice() : []) LNbits.api .request( 'PUT', `/api/v1/extension/${extension.id}/enable`, this.g.user.wallets[0].adminkey, - granted.length ? {permissions: granted} : null + granted.length || tags.length + ? {permissions: granted, payment_tags: tags} + : null ) .then(response => { this.g.user.extensions = this.g.user.extensions.concat([extension.id]) @@ -326,15 +340,25 @@ window.PageExtensions = { }, openPermissionsDialog(extension) { this.permissionsDialog.extension = extension - this.permissionsDialog.checked = [] + this.permissionsDialog.checked = extension._grantedPermissions + ? extension._grantedPermissions.slice() + : [] + this.permissionsDialog.missing = [] + this.permissionsDialog.tags = extension._grantedPaymentTags + ? extension._grantedPaymentTags.slice() + : [] + this.permissionsDialog.tagOptions = [] this.permissionsDialog.show = true }, cancelPermissionsDialog() { this.permissionsDialog.show = false this.permissionsDialog.extension = null this.permissionsDialog.checked = [] + this.permissionsDialog.missing = [] + this.permissionsDialog.tags = [] + this.permissionsDialog.tagOptions = [] }, - openPermissionsForExtension(extension) { + async openPermissionsForExtension(extension) { if (!extension.permissions || !extension.permissions.length) { Quasar.Notify.create({ type: 'warning', @@ -344,22 +368,53 @@ window.PageExtensions = { } this.permissionsDialog.extension = extension this.permissionsDialog.checked = [] + this.permissionsDialog.missing = [] + this.permissionsDialog.tags = [] + this.permissionsDialog.tagOptions = [] + try { + const {data} = await LNbits.api.request( + 'GET', + `/api/v1/extension/${extension.id}/capabilities`, + this.g.user.wallets[0].adminkey + ) + if (data && Array.isArray(data.missing_permissions)) { + this.permissionsDialog.missing = data.missing_permissions + } + if (data && Array.isArray(data.payment_tags)) { + this.permissionsDialog.tagOptions = data.payment_tags + } + } catch (err) { + LNbits.utils.notifyApiError(err) + } this.permissionsDialog.show = true }, async confirmPermissionsDialog() { const ext = this.permissionsDialog.extension const granted = this.permissionsDialog.checked.slice() + const tags = this.permissionsDialog.tags.slice() this.permissionsDialog.show = false this.permissionsDialog.extension = null this.permissionsDialog.checked = [] + const missing = this.permissionsDialog.missing || [] + this.permissionsDialog.missing = [] + this.permissionsDialog.tags = [] + this.permissionsDialog.tagOptions = [] + if (missing.length) { + Quasar.Notify.create({ + type: 'negative', + message: 'Missing API endpoints for one or more permissions.' + }) + return + } if (!ext) return ext._grantedPermissions = granted + ext._grantedPaymentTags = tags try { await LNbits.api.request( 'PUT', `/api/v1/extension/${ext.id}/permissions`, this.g.user.wallets[0].adminkey, - {permissions: granted} + {permissions: granted, payment_tags: tags} ) Quasar.Notify.create({ type: 'positive', @@ -911,6 +966,9 @@ window.PageExtensions = { if (ext.grantedPermissions && ext.grantedPermissions.length) { ext._grantedPermissions = ext.grantedPermissions } + if (ext.grantedPaymentTags && ext.grantedPaymentTags.length) { + ext._grantedPaymentTags = ext.grantedPaymentTags + } const schema = ext.kvSchema || ext.kv_schema || {} ext.kvSchemaKeys = Object.keys(schema) }) diff --git a/lnbits/templates/pages/extensions.vue b/lnbits/templates/pages/extensions.vue index 83e64cda9..2dbc2f5d9 100644 --- a/lnbits/templates/pages/extensions.vue +++ b/lnbits/templates/pages/extensions.vue @@ -954,6 +954,31 @@ +
+
+ Allow this extension to listen for payment tags: +
+ + + + + + + + + + +
+
+
+ Missing API endpoints required by this extension: +
+ +