tasks and docs

This commit is contained in:
Arc
2026-02-25 16:11:14 +00:00
parent 64ad19a65b
commit 3fd7aae10d
12 changed files with 1377 additions and 28 deletions
+59
View File
@@ -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/<ext_id>/
```
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/<ext_id>/` (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 `/<ext_id>/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 <ext_id>_start():
def <ext_id>_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 `/<ext_id>/...`.
- Background tasks start/stop cleanly.
+137
View File
@@ -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/<ext_id>/
```
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 doesnt exist, permissions wont save.
## Tag Watchers (Backend)
You can register tag watchers:
```
POST /<ext_id>/api/v1/watch_tag
{
"tag": "coinflip",
"wallet_id": "<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 /<ext_id>/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.
+9 -1
View File
@@ -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)
+9
View File
@@ -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", []),
),
)
+10
View File
@@ -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)
+5
View File
@@ -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):
+233
View File
@@ -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)],
+24
View File
@@ -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:
+87
View File
@@ -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",
},
],
}
+699 -21
View File
@@ -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]] = {}
+63 -5
View File
@@ -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)
})
+42 -1
View File
@@ -954,6 +954,31 @@
</q-item-section>
</q-item>
</q-list>
<div
v-if="
permissionsDialog.tagOptions &&
permissionsDialog.tagOptions.length
"
class="q-mt-md"
>
<div class="text-caption text-grey">
Allow this extension to listen for payment tags:
</div>
<q-list>
<q-item
v-for="tag in permissionsDialog.tagOptions"
:key="tag"
clickable
>
<q-item-section>
<q-item-label v-text="tag"></q-item-label>
</q-item-section>
<q-item-section side>
<q-checkbox v-model="permissionsDialog.tags" :val="tag" />
</q-item-section>
</q-item>
</q-list>
</div>
<div
v-if="
permissionsDialog.extension &&
@@ -974,6 +999,22 @@
class="q-mr-xs q-mt-xs"
/>
</div>
<div
v-if="permissionsDialog.missing && permissionsDialog.missing.length"
class="q-mt-md text-negative"
>
<div class="text-caption">
Missing API endpoints required by this extension:
</div>
<q-chip
v-for="perm in permissionsDialog.missing"
:key="perm"
:label="perm"
color="red-2"
text-color="black"
class="q-mr-xs q-mt-xs"
/>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
@@ -984,7 +1025,7 @@
></q-btn>
<q-btn
color="primary"
:disable="!permissionsAllChecked"
:disable="!permissionsAllChecked || permissionsHasMissingEndpoints"
label="Save"
@click="confirmPermissionsDialog"
></q-btn>