Compare commits

..
Author SHA1 Message Date
Vlad Stan 53c1662eda chore: poetry lock 2026-07-07 18:38:47 +03:00
Vlad Stan f98b78f436 fix: lint 2026-07-07 18:32:54 +03:00
Vlad Stan 92570c80ed test: CI integration 2026-07-07 18:32:54 +03:00
Vlad Stan 120cf735c8 test: more tests 2026-07-07 18:32:54 +03:00
Vlad Stan 4f072a6247 test: add tests 2026-07-07 18:32:54 +03:00
Vlad Stan 15d26093a5 feat: http.request permissions 2026-07-07 18:22:29 +03:00
Vlad Stan 62acfd2ea4 chore: lint 2026-07-07 18:22:15 +03:00
Vlad Stan 81c2d89e93 refactor: remove redundant check 2026-07-07 18:06:31 +03:00
Vlad Stan e23c945025 fix: policies 2026-07-07 14:26:34 +03:00
Vlad Stan b5558d0867 refactor: checks 2026-07-07 13:36:17 +03:00
Vlad Stan 40fc9d44f6 refactor: move logic out from host.py 2026-07-07 12:38:59 +03:00
Vlad Stan cd068f029b fea: nicer permissions 2026-07-07 12:18:21 +03:00
Vlad Stan 22eb50543a feat: ui polish 2026-07-07 11:38:44 +03:00
Vlad Stan 757271bb95 fix: allow no param 2026-07-07 10:10:25 +03:00
Vlad Stan be8094fb9b fix: start/stop background work 2026-07-06 15:15:12 +03:00
Vlad Stan a8629a0550 refactor: structure 2026-07-06 15:12:30 +03:00
Vlad Stan 695aad0ddc refactor: extract functions 2026-07-06 12:28:23 +03:00
Vlad Stan 0af4351380 refactor: extract permissions logic 2026-07-06 12:14:07 +03:00
Vlad Stan 9ec0a1232c refactor: extract function 2026-07-06 11:49:45 +03:00
Vlad Stan 76a8e5acfc chore: clean-up 2026-07-06 11:18:23 +03:00
Vlad Stan ca55b07467 fix: lint 2026-07-06 11:14:14 +03:00
Vlad Stan a2e0b39a28 feat: add camera permissions 2026-07-06 10:57:00 +03:00
Vlad Stan 57fc2e54f3 feat: pay_invoice 2026-07-02 15:46:10 +03:00
Vlad Stan 101620f682 fix: icon 2026-07-02 13:57:44 +03:00
Vlad Stan 3b3ad4c7f8 refactor: components 2026-07-02 13:03:42 +03:00
Vlad Stan 6ad5cca4f4 refactor: better namespace 2026-07-02 11:21:39 +03:00
Vlad Stan 1c9a416994 feat: add utils 2026-07-02 10:34:06 +03:00
Vlad Stan bfe1da5fc8 fix: access to extensions 2026-07-01 17:44:24 +03:00
64 changed files with 4407 additions and 1667 deletions
+8 -1
View File
@@ -64,6 +64,13 @@ jobs:
with:
make: openapi
test-wasm-e2e:
needs: [ lint ]
uses: ./.github/workflows/make.yml
with:
make: test-wasm-e2e
playwright-browser: chromium
regtest:
needs: [ lint ]
uses: ./.github/workflows/regtest.yml
@@ -95,5 +102,5 @@ jobs:
python-version: ${{ matrix.python-version }}
bundle:
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, test-wasm-e2e, regtest, jmeter ]
uses: ./.github/workflows/bundle.yml
+7
View File
@@ -15,6 +15,10 @@ on:
description: "python version"
type: string
default: "3.12"
playwright-browser:
description: "Playwright browser to install before running make"
default: ""
type: string
jobs:
make:
@@ -31,4 +35,7 @@ jobs:
python-version: ${{ inputs.python-version }}
node-version: ${{ matrix.node-version }}
npm: ${{ inputs.npm }}
- name: Install Playwright browser
if: ${{ inputs.playwright-browser != '' }}
run: uv run playwright install --with-deps ${{ inputs.playwright-browser }}
- run: make ${{ inputs.make }}
+2
View File
@@ -16,3 +16,5 @@
flake.lock
.venv
tests/fixtures/lnbits-wasm-test-extension/static/html-like.js
+6
View File
@@ -62,6 +62,12 @@ test-api:
DEBUG=true \
uv run pytest tests/api
test-wasm-e2e:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest tests/wasm_ext --browser chromium
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
+7 -9
View File
@@ -24,12 +24,6 @@ from lnbits.core.crud import (
update_installed_extension_state,
)
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.extensions.events import dispatch_wasm_invoice_paid
from lnbits.core.extensions.loader import (
is_wasm_extension_dir,
is_wasm_extension_id,
)
from lnbits.core.extensions.routes import register_wasm_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
@@ -44,6 +38,11 @@ from lnbits.core.tasks import (
wait_for_paid_invoices,
wait_notification_messages,
)
from lnbits.core.wasm_ext.routes.register import register_wasm_extension
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
from lnbits.core.wasm_ext.wasm.loader import (
is_wasm_extension_id,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
@@ -317,9 +316,8 @@ async def build_all_installed_extensions_list( # noqa: C901
installed_extensions.append(ext_info)
await create_installed_extension(ext_info)
if not is_wasm_extension_dir(ext_dir):
current_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, current_version)
current_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, current_version)
except Exception as e:
logger.warning(e)
-28
View File
@@ -1,28 +0,0 @@
"""Extension runtime contracts."""
from .api import (
ExtensionAPI,
ExtensionAPIMethod,
extension_api_contract,
extension_api_method,
get_extension_api_method,
list_extension_api_methods,
)
from .loader import WasmExtension, load_wasm_extension
from .routes import register_wasm_extension
from .runtime import ExtensionAPIHost
from .wasm import invoke_wasm_extension_export
__all__ = [
"ExtensionAPI",
"ExtensionAPIHost",
"ExtensionAPIMethod",
"WasmExtension",
"extension_api_contract",
"extension_api_method",
"get_extension_api_method",
"invoke_wasm_extension_export",
"list_extension_api_methods",
"load_wasm_extension",
"register_wasm_extension",
]
-635
View File
@@ -1,635 +0,0 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Annotated, Any, NoReturn
from uuid import uuid4
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from loguru import logger
from pydantic import UUID4
from starlette.staticfiles import PathLike as StaticFilesPathLike
from starlette.types import Scope
from lnbits.core.crud import get_user_from_account
from lnbits.core.db import core_app_extra
from lnbits.core.models import Account
from lnbits.decorators import (
check_access_token,
check_account_exists,
optional_user_id,
)
from lnbits.helpers import template_renderer
from lnbits.settings import settings
from lnbits.utils.cache import cache
from .loader import WasmExtension, load_wasm_extension
from .wasm import invoke_wasm_extension_export, warm_wasm_extension
WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60
WASM_EXTENSION_CORE_ASSET_PREFIX = "_lnbits"
WASM_EXTENSION_CORE_STATIC_ASSETS = {
"bundle.min.css": ("static/bundle.min.css", "text/css; charset=utf-8"),
"material-icons-v50.woff2": (
"static/fonts/material-icons-v50.woff2",
"font/woff2",
),
"quasar.css": ("static/vendor/quasar.css", "text/css; charset=utf-8"),
"quasar.umd.prod.js": (
"static/vendor/quasar.umd.prod.js",
"text/javascript; charset=utf-8",
),
"qrcode.vue.browser.js": (
"static/vendor/qrcode.vue.browser.js",
"text/javascript; charset=utf-8",
),
"vue.global.prod.js": (
"static/vendor/vue.global.prod.js",
"text/javascript; charset=utf-8",
),
}
WASM_EXTENSION_GENERATED_CORE_ASSETS = {
"material-icons.css": (
"""
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url('./material-icons-v50.woff2') format('woff2');
}
""",
"text/css; charset=utf-8",
)
}
WASM_EXTENSION_STATIC_MIME_TYPES = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".png": "image/png",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
}
WASM_EXTENSION_TEXT_STATIC_EXTENSIONS = {".css", ".js"}
WASM_EXTENSION_HTML_PREFIXES = (b"<!doctype", b"<html", b"<script")
class GuardedWasmExtensionStaticFiles(StaticFiles):
async def get_response(self, path: str, scope: Scope) -> Response:
if path.startswith(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/"):
return _wasm_extension_core_asset_response(path)
if Path(path).suffix.lower() not in WASM_EXTENSION_STATIC_MIME_TYPES:
raise HTTPException(status_code=404)
return await super().get_response(path, scope)
def file_response(
self,
full_path: StaticFilesPathLike,
stat_result: os.stat_result,
scope: Scope,
status_code: int = 200,
) -> Response:
suffix = Path(full_path).suffix.lower()
if suffix in WASM_EXTENSION_TEXT_STATIC_EXTENSIONS:
_reject_html_like_wasm_static_asset(Path(full_path))
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["Content-Type"] = WASM_EXTENSION_STATIC_MIME_TYPES[suffix]
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
loaded = load_wasm_extension(ext_id)
warm_wasm_extension(loaded)
_mount_wasm_extension_static(app, loaded)
_register_wasm_extension_ui_routes(app, loaded)
_register_wasm_extension_api_routes(app, loaded)
core_app_extra.wasm_extension_registry.register(loaded)
settings.activate_extension_paths(ext_id, "", [])
logger.info(
f"Loaded WASM extension '{loaded.id}' "
f"({loaded.module_path.stat().st_size} bytes)."
)
return loaded
def _mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None:
static_path = extension.root_path / "static"
mount_path = f"/ext-assets/{extension.id}"
if any(getattr(route, "path", None) == mount_path for route in app.routes):
return
app.mount(
mount_path,
GuardedWasmExtensionStaticFiles(directory=static_path, check_dir=False),
name=f"{extension.id}-static",
)
def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
entrypoint = _wasm_extension_entrypoint(
extension, route_config.get("entrypoint")
)
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
path_params = route_config.get("path_params") or {}
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
_add_wasm_extension_wrapper_route(
app,
extension,
route_path,
frame_path,
auth,
path_params,
)
def _register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_config in extension.config.get("api_routes") or []:
_add_wasm_extension_api_route(app, extension, route_config)
def _add_wasm_extension_api_route(
app: FastAPI,
extension: WasmExtension,
route_config: dict[str, Any],
) -> None:
method = _wasm_extension_api_method(extension, route_config.get("method"))
route_path = _wasm_extension_api_path(extension, route_config.get("path"))
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
path_params = route_config.get("path_params") or {}
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
if _has_route(app, route_path, method):
return
async def invoke_wasm_api_request(
request: Request, account: Account | None = None
) -> dict[str, Any]:
try:
payload = await _read_api_payload(request, path_params)
return await invoke_wasm_extension_export(
extension.id,
export_name,
payload,
user=account,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async def invoke_private_wasm_extension_export(
request: Request,
account: Account = Depends(check_account_exists),
) -> dict[str, Any]:
return await invoke_wasm_api_request(request, account)
async def invoke_public_wasm_extension_export(request: Request) -> dict[str, Any]:
return await invoke_wasm_api_request(request)
app.add_api_route(
route_path,
(
invoke_public_wasm_extension_export
if auth == "public"
else invoke_private_wasm_extension_export
),
methods=[method],
name=f"{extension.id}:{method}:{route_path}",
include_in_schema=False,
)
async def _read_api_payload(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
payload = _read_api_path_params(request, path_params)
payload.update(_read_api_query_params(request))
if request.method in {"POST", "PUT", "PATCH"}:
payload.update(await _read_json_object(request))
return payload
async def _read_json_object(request: Request) -> dict[str, Any]:
body = await request.body()
if not body:
return {}
value = json.loads(body)
if not isinstance(value, dict):
raise TypeError("WASM extension API payload must be a JSON object.")
return value
def _read_api_path_params(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
payload: dict[str, Any] = {}
for key, value in request.path_params.items():
target = path_params.get(key) or _snake_to_camel(key)
payload[target] = value
return payload
def _read_api_query_params(request: Request) -> dict[str, Any]:
return {_snake_to_camel(key): value for key, value in request.query_params.items()}
def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str:
if not isinstance(export_name, str) or not export_name:
raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.")
for export in extension.exports:
if export.get("name") != export_name:
continue
if export.get("visibility") in {"public", "authenticated"}:
return export_name
raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.")
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str:
if not isinstance(method, str):
raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.")
method = method.upper()
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.")
return method
def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.")
if path == "/":
return f"/api/v1/ext/{extension.id}"
return f"/api/v1/ext/{extension.id}{path}"
def _has_route(app: FastAPI, route_path: str, method: str) -> bool:
for route in app.routes:
if getattr(route, "path", None) != route_path:
continue
methods = getattr(route, "methods", set()) or set()
if method in methods:
return True
return False
def _snake_to_camel(value: str) -> str:
head, *tail = value.split("_")
return head + "".join(part.capitalize() for part in tail)
def _add_wasm_extension_wrapper_route(
app: FastAPI,
extension: WasmExtension,
route_path: str,
frame_path: str,
auth: str,
path_params: dict[str, str],
) -> None:
if _has_route(app, route_path, "GET"):
return
async def serve_private_wasm_extension_page(
request: Request,
account: Account = Depends(check_account_exists),
) -> Any:
user = await get_user_from_account(account)
return _wasm_extension_wrapper_response(
request,
extension,
frame_path,
auth,
path_params,
user.json() if user else None,
account.id,
)
async def serve_public_wasm_extension_page(
request: Request,
user_id: str | None = Depends(_optional_wasm_user_id),
) -> Any:
return _wasm_extension_wrapper_response(
request,
extension,
frame_path,
auth,
path_params,
None,
user_id,
)
app.add_api_route(
route_path,
(
serve_public_wasm_extension_page
if auth == "public"
else serve_private_wasm_extension_page
),
methods=["GET"],
name=f"{extension.id}:{route_path}",
include_in_schema=False,
)
def _add_wasm_extension_frame_route(
app: FastAPI,
extension: WasmExtension,
frame_path: str,
entrypoint: Path,
) -> None:
if _has_route(app, frame_path, "GET"):
return
async def serve_wasm_extension_frame(
request: Request,
user_id: str | None = Depends(_optional_wasm_user_id),
) -> FileResponse:
_consume_wasm_extension_frame_token(request, extension, frame_path, user_id)
response = FileResponse(entrypoint)
response.headers["Content-Security-Policy"] = _wasm_extension_frame_csp(
request, extension
)
response.headers["Cache-Control"] = "no-store"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
# Extension access goes through the parent bridge.
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=(), "
"clipboard-read=(), usb=()"
)
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
app.add_api_route(
frame_path,
serve_wasm_extension_frame,
methods=["GET"],
name=f"{extension.id}:frame:{frame_path}",
include_in_schema=False,
)
def _wasm_extension_wrapper_response(
request: Request,
extension: WasmExtension,
frame_path: str,
auth: str,
path_params: dict[str, str],
user_json: str | None,
user_id: str | None,
) -> Any:
public = auth == "public"
response = template_renderer().TemplateResponse(
request,
"wasm_extension.html",
{
"extension": extension,
"frame_url": _wasm_extension_frame_url(extension, frame_path, user_id),
"bridge": {
"extensionId": extension.id,
"public": public,
"routeParams": _read_api_path_params(request, path_params),
"query": _read_api_query_params(request),
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
},
"public": public,
"user": user_json,
},
)
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
return response
def _wasm_extension_frame_csp(request: Request, extension: WasmExtension) -> str:
origin = str(request.base_url).rstrip("/")
extension_assets = f"{origin}/ext-assets/{extension.id}/"
return (
"sandbox allow-scripts; "
"default-src 'none'; "
f"script-src {extension_assets}; "
"script-src-attr 'none'; "
f"style-src {extension_assets}; "
"style-src-attr 'none'; "
f"img-src {extension_assets} data:; "
f"font-src {extension_assets}; "
"connect-src 'none'; "
"form-action 'none'; "
"object-src 'none'; "
"base-uri 'none'; "
"frame-src 'none'; "
"worker-src 'none'; "
"media-src 'none'; "
"manifest-src 'none'; "
"frame-ancestors 'self'"
)
def _wasm_extension_frame_url(
extension: WasmExtension, frame_path: str, user_id: str | None
) -> str:
token = _create_wasm_extension_frame_token(extension, frame_path, user_id)
return f"{frame_path}?frame_token={token}"
def _create_wasm_extension_frame_token(
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> str:
token = uuid4().hex
cache.set(
_wasm_extension_frame_token_cache_key(token),
{
"extension_id": extension.id,
"frame_path": frame_path,
"user_id": user_id,
},
expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS,
)
return token
def _consume_wasm_extension_frame_token(
request: Request,
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> None:
token = request.query_params.get("frame_token")
if not token:
_raise_wasm_extension_frame_not_found(extension, frame_path, "missing")
cache_key = _wasm_extension_frame_token_cache_key(token)
token_data = cache.get(cache_key)
if (
not isinstance(token_data, dict)
or token_data.get("extension_id") != extension.id
or token_data.get("frame_path") != frame_path
):
_raise_wasm_extension_frame_not_found(
extension, frame_path, "unknown or expired"
)
token_user_id = token_data.get("user_id")
if token_user_id and token_user_id != user_id:
_raise_wasm_extension_frame_not_found(extension, frame_path, "wrong user")
cache.pop(cache_key)
def _wasm_extension_frame_token_cache_key(token: str) -> str:
return f"wasm-frame-token:{token}"
def _raise_wasm_extension_frame_not_found(
extension: WasmExtension,
frame_path: str,
reason: str,
) -> NoReturn:
logger.warning(
f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'."
)
raise HTTPException(status_code=404, detail="Not found")
def _wasm_extension_bridge_api_routes(
extension: WasmExtension,
public: bool,
) -> list[dict[str, str]]:
routes: list[dict[str, str]] = []
for route_config in extension.config.get("api_routes") or []:
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
if public and auth != "public":
continue
method = _wasm_extension_api_method(extension, route_config.get("method"))
path = _wasm_extension_api_path(extension, route_config.get("path"))
_wasm_extension_api_export(extension, route_config.get("export"))
routes.append(
{
"method": method,
"path": path,
"pattern": _path_template_pattern(path),
}
)
return routes
def _path_template_pattern(path: str) -> str:
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
return f"^{pattern}$"
async def _optional_wasm_user_id(
request: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> str | None:
try:
return await optional_user_id(request, access_token, usr)
except HTTPException:
return None
def _wasm_extension_route_auth(extension: WasmExtension, auth: Any) -> str:
if auth in {"public", "user"}:
return auth
raise ValueError(f"Invalid route auth for WASM extension '{extension.id}'.")
def _wasm_extension_ui_route_path(extension: WasmExtension, path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.")
if path == "/":
return "/ext"
return f"/ext{path}"
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path:
if not isinstance(entrypoint, str) or not entrypoint:
raise ValueError(
f"Invalid route entrypoint for WASM extension '{extension.id}'."
)
if entrypoint.startswith("/"):
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be a "
"relative extension path."
)
path = (extension.root_path / entrypoint).resolve()
root_path = extension.root_path.resolve()
if path != root_path and root_path not in path.parents:
raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}")
static_path = (extension.root_path / "static").resolve()
if path == static_path or static_path in path.parents:
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must not be "
"inside the static asset directory."
)
if path.suffix.lower() != ".html":
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be "
"an HTML file."
)
if not path.is_file():
raise FileNotFoundError(f"Route entrypoint not found: {path}")
return path
def _reject_html_like_wasm_static_asset(path: Path) -> None:
with path.open("rb") as asset_file:
prefix = asset_file.read(512).lstrip().lower()
if prefix.startswith(WASM_EXTENSION_HTML_PREFIXES):
raise HTTPException(status_code=404)
def _wasm_extension_core_asset_response(path: str) -> Response:
asset_name = path.removeprefix(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/")
if not asset_name or "/" in asset_name or "\\" in asset_name:
raise HTTPException(status_code=404)
generated_asset = WASM_EXTENSION_GENERATED_CORE_ASSETS.get(asset_name)
if generated_asset:
content, content_type = generated_asset
response = Response(content=content, media_type=content_type)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
asset_config = WASM_EXTENSION_CORE_STATIC_ASSETS.get(asset_name)
if not asset_config:
raise HTTPException(status_code=404)
relative_path, content_type = asset_config
asset_path = Path(settings.lnbits_path, relative_path)
if not asset_path.is_file():
raise HTTPException(status_code=404)
response = FileResponse(asset_path)
response.headers["Content-Type"] = content_type
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
+2 -2
View File
@@ -13,10 +13,10 @@ from lnbits.core.crud import (
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.core.extensions.loader import is_wasm_extension_id
from lnbits.core.extensions.storage import migrate_wasm_extension_database
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.wasm_ext.storage.crud import migrate_wasm_extension_database
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
-2
View File
@@ -7,7 +7,6 @@ from .misc import (
CoreAppExtra,
DbVersion,
SimpleStatus,
WasmExtensionRegistry,
)
from .payments import (
CancelInvoice,
@@ -103,6 +102,5 @@ __all__ = [
"Wallet",
"WalletInfo",
"WalletTypeInfo",
"WasmExtensionRegistry",
"WebPushSubscription",
]
+45 -8
View File
@@ -7,7 +7,8 @@ import os
import shutil
import zipfile
from asyncio.tasks import create_task
from pathlib import Path
from collections.abc import Mapping
from pathlib import Path, PurePosixPath
from typing import Any
import httpx
@@ -81,7 +82,15 @@ class ExtensionPermission(BaseModel):
id: str
label: str | None = None
description: str | None = None
policy: dict[str, Any] | None = None
policies: list[Any] | None = None
@staticmethod
def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]:
return [
ExtensionPermission.parse_obj(permission)
for permission in config_json.get("permissions") or []
if isinstance(permission, dict) and permission.get("id")
]
class ExtensionConfig(BaseModel):
@@ -180,11 +189,19 @@ class Extension(BaseModel):
is_wasm=ext_info.is_wasm,
name=ext_info.name,
short_description=ext_info.short_description,
tile=ext_info.icon,
tile=(
wasm_extension_icon_url(ext_info.id)
if ext_info.is_wasm
else ext_info.icon
),
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
)
def wasm_extension_icon_url(ext_id: str) -> str:
return f"/ext-assets/{ext_id}/assets/icon.png"
class ExtensionRelease(BaseModel):
name: str
version: str
@@ -456,6 +473,30 @@ class InstallableExtension(BaseModel):
os.remove(ext_zip_file)
raise AssertionError("File hash missmatch. Will not install.")
def load_archive_config(self) -> dict[str, Any]:
if not self.zip_path.is_file():
return {}
try:
with zipfile.ZipFile(self.zip_path, "r") as archive:
config_name = self._archive_config_name(archive.namelist())
if not config_name:
return {}
with archive.open(config_name) as config_file:
config = json.load(config_file)
except Exception as exc:
raise ValueError(f"Cannot read extension config for '{self.id}'.") from exc
return config if isinstance(config, dict) else {}
@staticmethod
def _archive_config_name(names: list[str]) -> str | None:
for name in names:
path = PurePosixPath(name)
if len(path.parts) == 2 and path.name == "config.json":
return name
return None
def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
@@ -634,11 +675,7 @@ class InstallableExtension(BaseModel):
version=version,
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
permissions=[
ExtensionPermission.parse_obj(permission)
for permission in config_json.get("permissions") or []
if isinstance(permission, dict) and permission.get("id")
],
permissions=ExtensionPermission.list_from_config(config_json),
meta=ExtensionMeta(
installed_release=ExtensionRelease(
name=ext_id,
+10 -91
View File
@@ -1,10 +1,5 @@
import asyncio
import importlib
import json
import zipfile
from collections.abc import Iterable
from pathlib import PurePosixPath
from typing import Any
from loguru import logger
@@ -21,8 +16,9 @@ from lnbits.core.crud.extensions import (
get_installed_extensions,
update_installed_extension,
)
from lnbits.core.extensions.api import extension_api_permission_ids
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
from lnbits.db import Connection
from lnbits.settings import settings
@@ -57,8 +53,8 @@ async def install_extension(
if not skip_download:
await ext_info.download_archive()
extension_config = _load_extension_archive_config(ext_info)
ext_info.permissions = _validate_extension_permissions(
extension_config = ext_info.load_archive_config()
ext_info.permissions = validate_wasm_extension_permissions(
ext_info, granted_permissions, extension_config
)
@@ -85,89 +81,6 @@ async def install_extension(
return extension
def validate_extension_permissions(
ext_id: str,
permissions: Iterable[ExtensionPermission],
*,
strict: bool = True,
) -> list[ExtensionPermission]:
known_permission_ids = extension_api_permission_ids()
normalized_permissions: list[ExtensionPermission] = []
unknown_ids: list[str] = []
for permission in permissions:
if permission.id not in known_permission_ids:
unknown_ids.append(permission.id)
if strict:
continue
normalized_permissions.append(permission.copy(update={"label": None}))
if unknown_ids and strict:
raise ValueError(
f"Extension '{ext_id}' requests unknown permissions: "
+ ", ".join(sorted(set(unknown_ids)))
)
return normalized_permissions
def _validate_extension_permissions(
ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None,
extension_config: dict[str, Any],
) -> list[ExtensionPermission]:
if extension_config.get("extension_type") != "wasm":
return []
requested_permissions = validate_extension_permissions(
ext_info.id,
[
ExtensionPermission.parse_obj(permission)
for permission in extension_config.get("permissions") or []
if isinstance(permission, dict) and permission.get("id")
],
)
if not requested_permissions:
return []
if granted_permissions is None:
raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
requested_ids = {permission.id for permission in requested_permissions}
granted_ids = {permission.id for permission in granted_permissions}
if requested_ids != granted_ids:
raise ValueError(
f"Extension '{ext_info.id}' was not granted all requested permissions."
)
return requested_permissions
def _load_extension_archive_config(ext_info: InstallableExtension) -> dict[str, Any]:
if not ext_info.zip_path.is_file():
return {}
try:
with zipfile.ZipFile(ext_info.zip_path, "r") as archive:
config_name = _archive_config_name(archive.namelist())
if not config_name:
return {}
with archive.open(config_name) as config_file:
config = json.load(config_file)
except Exception as exc:
raise ValueError(f"Cannot read extension config for '{ext_info.id}'.") from exc
return config if isinstance(config, dict) else {}
def _archive_config_name(names: list[str]) -> str | None:
for name in names:
path = PurePosixPath(name)
if len(path.parts) == 2 and path.name == "config.json":
return name
return None
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
@@ -210,6 +123,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension must expose a `myextension_stop()` function if it is starting tasks.
"""
if is_wasm_extension_id(ext_id):
return True
upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
@@ -242,6 +158,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
Extension CAN expose a `myextension_start()` function if it is starting tasks.
Extension MUST expose a `myextension_stop()` in that case.
"""
if is_wasm_extension_id(ext_id):
return False
upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
+6 -3
View File
@@ -29,6 +29,7 @@ from lnbits.core.models.extensions import (
ReleasePaymentInfo,
UserExtension,
UserExtensionInfo,
wasm_extension_icon_url,
)
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services import check_transaction_status, create_invoice
@@ -39,8 +40,8 @@ from lnbits.core.services.extensions import (
get_valid_extensions,
install_extension,
uninstall_extension,
validate_extension_permissions,
)
from lnbits.core.wasm_ext.api.permissions import validate_extension_permissions
from lnbits.db import Page
from lnbits.decorators import (
check_account_exists,
@@ -573,6 +574,8 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
extension_data = []
for ext in installable_exts:
installed_ext = installed_exts_by_id.get(ext.id)
is_wasm = installed_ext.is_wasm if installed_ext else ext.is_wasm
icon = wasm_extension_icon_url(ext.id) if is_wasm else ext.icon
permissions = (
validate_extension_permissions(
installed_ext.id, installed_ext.permissions, strict=False
@@ -584,7 +587,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"icon": icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
@@ -616,7 +619,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
else {}
),
"isPaymentRequired": ext.requires_payment,
"isWasm": installed_ext.is_wasm if installed_ext else ext.is_wasm,
"isWasm": is_wasm,
"permissions": [dict(permission) for permission in permissions],
"inProgress": False,
"selectedForUpdate": False,
+24
View File
@@ -0,0 +1,24 @@
from .api.host import ExtensionHostAPI
from .api.models import ExtensionAPIMethod, ExtensionAPIMethodExport
from .api.registry import (
extension_api_contract,
extension_api_method,
extension_api_permission_ids,
get_extension_api_method,
list_extension_api_methods,
)
from .api.runtime import ExtensionAPIHost
from .wasm.loader import WasmExtension
__all__ = [
"ExtensionAPIHost",
"ExtensionAPIMethod",
"ExtensionAPIMethodExport",
"ExtensionHostAPI",
"WasmExtension",
"extension_api_contract",
"extension_api_method",
"extension_api_permission_ids",
"get_extension_api_method",
"list_extension_api_methods",
]
+22
View File
@@ -0,0 +1,22 @@
from .host import ExtensionHostAPI
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
from .registry import (
extension_api_contract,
extension_api_method,
extension_api_permission_ids,
get_extension_api_method,
list_extension_api_methods,
)
from .runtime import ExtensionAPIHost
__all__ = [
"ExtensionAPIHost",
"ExtensionAPIMethod",
"ExtensionAPIMethodExport",
"ExtensionHostAPI",
"extension_api_contract",
"extension_api_method",
"extension_api_permission_ids",
"get_extension_api_method",
"list_extension_api_methods",
]
@@ -1,19 +1,21 @@
from __future__ import annotations
import inspect
import json
import logging
import secrets
import time
from collections.abc import Awaitable, Callable, Iterable, Mapping
from dataclasses import dataclass
from functools import wraps
from typing import Any, TypeVar, cast, get_type_hints
from pydantic import BaseModel
from collections.abc import Iterable, Mapping
from typing import Any
from lnbits.helpers import sha256s
from ..storage.crud import (
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_set_row,
)
from .models import (
CreateInvoicePublicRequest,
CreateInvoiceRequest,
@@ -26,6 +28,8 @@ from .models import (
LogRequest,
LogResponse,
NowResponse,
PayInvoiceRequest,
PayInvoiceResponse,
RandomIdRequest,
RandomIdResponse,
StorageDeleteRequest,
@@ -37,135 +41,34 @@ from .models import (
StorageSetRequest,
StorageSetResponse,
UserWalletSummary,
WalletBalanceRequest,
WalletBalanceResponse,
)
from .storage import (
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_set_row,
)
from .registry import extension_api_method
logger = logging.getLogger("lnbits.extensions")
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
@dataclass(frozen=True)
class ExtensionAPIMethodExport:
method_id: str
namespace: str
name: str
host_name: str
sdk_name: str
description: str
required_permission: str | None = None
require_auth: bool = True
@dataclass(frozen=True)
class ExtensionAPIMethod:
method_id: str
namespace: str
name: str
python_name: str
host_name: str
sdk_name: str
description: str
request_model: type[BaseModel]
response_model: type[BaseModel]
required_permission: str | None = None
require_auth: bool = True
@property
def sdk_qualified_name(self) -> str:
return f"{self.namespace}.{self.sdk_name}"
def extension_api_method(
*,
method_id: str,
namespace: str,
name: str,
host_name: str,
sdk_name: str,
description: str,
required_permission: str | None = None,
require_auth: bool = True,
) -> Callable[
[Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]]],
Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]],
]:
export = ExtensionAPIMethodExport(
method_id=method_id,
namespace=namespace,
name=name,
host_name=host_name,
sdk_name=sdk_name,
description=description,
required_permission=required_permission,
require_auth=require_auth,
)
def decorator(
function: Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]],
) -> Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]]:
@wraps(function)
async def wrapper(self: ExtensionAPI, request: _RequestModel) -> _ResponseModel:
if require_auth and not self.has_authenticated_context():
raise PermissionError(
f"Extension API method '{method_id}' requires authentication."
)
self.require_permission(required_permission)
return await function(self, request)
setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export)
return wrapper
return decorator
class ExtensionAPI:
class ExtensionHostAPI:
def __init__(
self,
extension_id: str,
permissions: Iterable[Any],
*,
user_id: str | None = None,
access_token: str | None = None,
context: str = "user",
owner_id: str | None = None,
) -> None:
self.extension_id = extension_id
self.permissions, self.permission_policies = self._permission_data(permissions)
self.user_id = user_id
self.access_token = access_token
self.context = context
self.owner_id = sha256s(user_id) if user_id else owner_id
self._uuid = secrets.token_urlsafe(12).replace("-", "_")
from .utils import ExtensionAPIUtils
def __repr__(self) -> str:
return (
"ExtensionAPI("
f"extension_id={self.extension_id!r}, "
f"context={self.context!r}, "
f"_uuid={self._uuid!r}"
")"
)
def require_permission(self, permission: str | None) -> None:
if permission and permission not in self.permissions:
raise PermissionError(
f"Extension '{self.extension_id}' is missing permission '{permission}'."
)
def has_authenticated_context(self) -> bool:
return bool(self.user_id) or self.context == "event"
def _require_owner_id(self) -> str:
if not self.owner_id:
raise PermissionError("Extension API method requires an owner context.")
return self.owner_id
self.utils = ExtensionAPIUtils(self)
@extension_api_method(
method_id="storage.get",
@@ -208,6 +111,7 @@ class ExtensionAPI:
for field_name, value in row.items()
if field_name in public_fields
}
# todo: check public fields filtering
return StorageGetResponse(data_json=json.dumps(public_row))
@extension_api_method(
@@ -297,22 +201,20 @@ class ExtensionAPI:
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.payments import create_payment_request
if self.user_id:
wallet = await get_wallet(request.wallet_id)
if wallet is None or wallet.user != self.user_id:
raise PermissionError(
"Creating an invoice for this wallet requires an "
"authenticated user context."
)
else:
pass
# todo: security stuff here
if not self.user_id:
raise PermissionError(
"Creating an invoice for this wallet requires an "
"authenticated user context."
)
wallet = await get_wallet(request.wallet_id)
if wallet is None or wallet.user != self.user_id:
raise PermissionError("Not your wallet.")
payment = await create_payment_request(
request.wallet_id,
CreateInvoice(
amount=request.amount_sat,
unit=request.currency or "sat",
amount=request.amount,
unit=request.currency,
memo=request.memo,
extra=request.extra,
extension=self.extension_id,
@@ -340,8 +242,18 @@ class ExtensionAPI:
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.payments import create_payment_request
table, wallet_field = self._public_invoice_wallet_source()
row = await storage_get_public_row(self.extension_id, table, request.source_id)
row: dict[str, Any] | None = None
wallet_field = ""
for policy in self._public_invoice_wallet_sources():
row = await storage_get_public_row(
self.extension_id,
policy["table"],
request.source_id,
)
if row:
wallet_field = policy["wallet_field"]
break
if not row:
raise PermissionError("Public invoice source was not found.")
@@ -400,6 +312,92 @@ class ExtensionAPI:
]
)
@extension_api_method(
method_id="wallet.balance",
namespace="wallet",
name="Read wallet balance",
host_name="wallet_balance",
sdk_name="balance",
description="Read the balance of a wallet available to the user.",
required_permission="wallet.balance.read",
)
async def wallet_balance(
self, request: WalletBalanceRequest
) -> WalletBalanceResponse:
from lnbits.core.crud.wallets import get_wallet
if not self.user_id:
raise PermissionError(
"Reading a wallet balance requires an authenticated user context."
)
wallet = await get_wallet(request.wallet_id)
if wallet is None or wallet.user != self.user_id:
raise PermissionError("Reading this wallet balance is not allowed.")
withdrawable_msat = max(wallet.withdrawable_balance, 0)
fee_reserve_msat = max(wallet.balance_msat - withdrawable_msat, 0)
return WalletBalanceResponse(
wallet_id=wallet.id,
name=wallet.name,
currency=wallet.currency,
balance_msat=wallet.balance_msat,
balance_sat=wallet.balance,
withdrawable_msat=withdrawable_msat,
withdrawable_sat=withdrawable_msat // 1000,
fee_reserve_msat=fee_reserve_msat,
fee_reserve_sat=fee_reserve_msat // 1000,
can_send_payments=wallet.can_send_payments,
)
@extension_api_method(
method_id="wallet.pay_invoice",
namespace="wallet",
name="Pay invoice",
host_name="pay_invoice",
sdk_name="payInvoice",
description="Pay a Lightning invoice from a wallet available to the user.",
required_permission="wallet.pay_invoice",
)
async def wallet_pay_invoice(
self, request: PayInvoiceRequest
) -> PayInvoiceResponse:
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.services.payments import pay_invoice
from lnbits.exceptions import PaymentError
if not self.user_id:
raise PermissionError(
"Paying an invoice requires an authenticated user context."
)
wallet = await get_wallet(request.wallet_id)
if wallet is None or wallet.user != self.user_id:
raise PermissionError("Paying invoices from this wallet is not allowed.")
try:
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=request.payment_request,
max_sat=request.max_sat,
extra={"tag": self.extension_id, **request.extra},
description=request.description,
tag=self.extension_id,
)
except (PaymentError, ValueError) as exc:
return PayInvoiceResponse(ok=False, error=str(exc))
return PayInvoiceResponse(
ok=True,
checking_id=payment.checking_id,
payment_hash=payment.payment_hash,
status=payment.status,
amount_msat=abs(payment.amount),
fee_msat=abs(payment.fee),
pending=payment.pending,
success=payment.success,
)
@extension_api_method(
method_id="http.request",
namespace="http",
@@ -411,10 +409,10 @@ class ExtensionAPI:
require_auth=True,
)
async def http_request(self, request: HttpRequest) -> HttpResponse:
from .http_client import send_extension_http_request
from ..client.http import send_extension_http_request
policy = self.permission_policies.get("http.request") or {}
return await send_extension_http_request(self.extension_id, policy, request)
policies = self.permission_policies.get("http.request") or []
return await send_extension_http_request(self.extension_id, policies, request)
@extension_api_method(
method_id="extension.api.request",
@@ -427,13 +425,14 @@ class ExtensionAPI:
require_auth=True,
)
async def extension_api_request(self, request: ExtensionApiRequest) -> HttpResponse:
from .extension_client import send_extension_api_request
from ..client.extensions import send_extension_api_request
policy = self.permission_policies.get("extension.api.request") or {}
policies = self.permission_policies.get("extension.api.request") or []
return await send_extension_api_request(
self.extension_id,
policy,
policies,
self.user_id,
self.access_token,
request,
)
@@ -480,9 +479,9 @@ class ExtensionAPI:
@staticmethod
def _permission_data(
permissions: Iterable[Any],
) -> tuple[set[str], dict[str, dict[str, Any]]]:
) -> tuple[set[str], dict[str, list[Any]]]:
permission_ids: set[str] = set()
policies: dict[str, dict[str, Any]] = {}
policies: dict[str, list[Any]] = {}
for permission in permissions:
if isinstance(permission, str):
@@ -490,28 +489,27 @@ class ExtensionAPI:
continue
permission_id: str | None = None
policy: Any = None
permission_policies: Any = None
if isinstance(permission, Mapping):
permission_id = permission.get("id") # type: ignore[assignment]
policy = permission.get("policy")
permission_policies = permission.get("policies")
else:
permission_id = getattr(permission, "id", None)
policy = getattr(permission, "policy", None)
permission_policies = getattr(permission, "policies", None)
if not permission_id:
continue
permission_ids.add(permission_id)
if isinstance(policy, dict):
policies[permission_id] = policy
if isinstance(permission_policies, list):
policies[permission_id] = permission_policies
return permission_ids, policies
def _public_storage_fields(self, table: str) -> set[str]:
policy = self.permission_policies.get("ext.storage.read_public") or {}
tables = policy.get("tables")
if not isinstance(tables, list):
tables = self.permission_policies.get("ext.storage.read_public")
if not isinstance(tables, list) or not tables:
raise PermissionError(
"Public storage reads require a tables policy for "
"Public storage reads require policies for "
"'ext.storage.read_public'."
)
@@ -531,129 +529,54 @@ class ExtensionAPI:
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
def _public_invoice_wallet_source(self) -> tuple[str, str]:
policy = self.permission_policies.get("wallet.create_invoice_public") or {}
table = policy.get("table")
wallet_field = policy.get("wallet_field")
if not isinstance(table, str) or not table:
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
policies = self.permission_policies.get("wallet.create_invoice_public")
if not isinstance(policies, list) or not policies:
raise PermissionError("Public invoice creation requires a policies list.")
sources: list[dict[str, str]] = []
for source_policy in policies:
if not isinstance(source_policy, dict):
raise PermissionError(
"Public invoice creation policies must be objects."
)
table = source_policy.get("table")
wallet_field = source_policy.get("wallet_field")
if not isinstance(table, str) or not table:
raise PermissionError(
"Public invoice creation requires a storage table policy."
)
if not isinstance(wallet_field, str) or not wallet_field:
raise PermissionError(
"Public invoice creation requires a wallet field policy."
)
sources.append({"table": table, "wallet_field": wallet_field})
if not sources:
raise PermissionError(
"Public invoice creation requires a storage table policy."
"Public invoice creation requires at least one valid policy."
)
if not isinstance(wallet_field, str) or not wallet_field:
return sources
def require_permission(self, permission: str | None) -> None:
if permission and permission not in self.permissions:
raise PermissionError(
"Public invoice creation requires a wallet field policy."
f"Extension '{self.extension_id}' is missing permission '{permission}'."
)
return table, wallet_field
def has_authenticated_context(self) -> bool:
return bool(self.user_id) or self.context == "event"
def list_extension_api_methods(
api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> list[ExtensionAPIMethod]:
methods: list[ExtensionAPIMethod] = []
def _require_owner_id(self) -> str:
if not self.owner_id:
raise PermissionError("Extension API method requires an owner context.")
return self.owner_id
for python_name, function in inspect.getmembers(api_cls, inspect.isfunction):
export = getattr(function, _EXTENSION_API_METHOD_ATTR, None)
if not export:
continue
request_model, response_model = _get_method_models(function)
methods.append(
ExtensionAPIMethod(
method_id=export.method_id,
namespace=export.namespace,
name=export.name,
python_name=python_name,
host_name=export.host_name,
sdk_name=export.sdk_name,
description=export.description,
request_model=request_model,
response_model=response_model,
required_permission=export.required_permission,
require_auth=export.require_auth,
)
def __repr__(self) -> str:
return (
"ExtensionHostAPI("
f"extension_id={self.extension_id!r}, "
f"context={self.context!r}, "
f"owner_id={self.owner_id!r}"
")"
)
return sorted(methods, key=lambda method: method.method_id)
def extension_api_permission_ids(
api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> set[str]:
return {
method.required_permission
for method in list_extension_api_methods(api_cls)
if method.required_permission
}
def get_extension_api_method(
method_id: str,
api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> ExtensionAPIMethod:
for method in list_extension_api_methods(api_cls):
if method.method_id == method_id:
return method
raise KeyError(f"Unknown extension API method '{method_id}'.")
def extension_api_contract(
api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> dict[str, object]:
return {
"version": 1,
"methods": [
{
"id": method.method_id,
"namespace": method.namespace,
"name": method.name,
"python_name": method.python_name,
"host_name": method.host_name,
"sdk_name": method.sdk_name,
"sdk_qualified_name": method.sdk_qualified_name,
"description": method.description,
"required_permission": method.required_permission,
"require_auth": method.require_auth,
"request_schema": method.request_model.schema(
ref_template="#/definitions/{model}"
),
"response_schema": method.response_model.schema(
ref_template="#/definitions/{model}"
),
}
for method in list_extension_api_methods(api_cls)
],
}
def _get_method_models(
function: Callable[..., object],
) -> tuple[type[BaseModel], type[BaseModel]]:
signature = inspect.signature(function)
request_parameters = [
parameter
for parameter in signature.parameters.values()
if parameter.name != "self"
]
if len(request_parameters) != 1:
raise TypeError(
f"Extension API method '{function.__name__}' must accept one request model."
)
hints = get_type_hints(function)
request_model = hints.get(request_parameters[0].name)
response_model = hints.get("return")
if not _is_pydantic_model(request_model):
raise TypeError(
f"Extension API method '{function.__name__}' request must be a BaseModel."
)
if not _is_pydantic_model(response_model):
raise TypeError(
f"Extension API method '{function.__name__}' response must be a BaseModel."
)
return cast(type[BaseModel], request_model), cast(type[BaseModel], response_model)
def _is_pydantic_model(value: object) -> bool:
return isinstance(value, type) and issubclass(value, BaseModel)
@@ -1,9 +1,43 @@
import json
from dataclasses import dataclass
from typing import Any, Literal
from pydantic import BaseModel, Field, root_validator
@dataclass(frozen=True)
class ExtensionAPIMethodExport:
method_id: str
namespace: str
name: str
host_interface: str
host_name: str
sdk_name: str
description: str
required_permission: str | None = None
require_auth: bool = True
@dataclass(frozen=True)
class ExtensionAPIMethod:
method_id: str
namespace: str
name: str
python_name: str
host_interface: str
host_name: str
sdk_name: str
description: str
request_model: type[BaseModel]
response_model: type[BaseModel]
required_permission: str | None = None
require_auth: bool = True
@property
def sdk_qualified_name(self) -> str:
return f"{self.namespace}.{self.sdk_name}"
class EmptyRequest(BaseModel):
pass
@@ -74,16 +108,20 @@ class StorageDeleteResponse(BaseModel):
class CreateInvoiceRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
amount_sat: int = Field(..., gt=0)
# todo: bridge for extensions to select currencies
currency: str | None = Field(..., min_length=1, max_length=8)
amount: float = Field(..., gt=0)
currency: str = Field("sat", min_length=1, max_length=8)
memo: str = Field(..., max_length=512)
tag: str = Field(..., min_length=1, max_length=64)
extra: dict[str, str] = Field(default_factory=dict)
class CreateInvoicePublicRequest(BaseModel):
source_id: str = Field(..., min_length=1, max_length=512)
source_id: str = Field(
...,
min_length=1,
max_length=512,
description="The source ID (entry id) of the wallet to create the invoice for.",
)
amount: float = Field(..., gt=0)
currency: str = Field(..., min_length=1, max_length=8)
memo: str = Field("", max_length=512)
@@ -118,6 +156,43 @@ class ListUserWalletsResponse(BaseModel):
wallets: list[UserWalletSummary] = Field(default_factory=list)
class WalletBalanceRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
class WalletBalanceResponse(BaseModel):
wallet_id: str
name: str
currency: str | None = None
balance_msat: int
balance_sat: int
withdrawable_msat: int
withdrawable_sat: int
fee_reserve_msat: int
fee_reserve_sat: int
can_send_payments: bool
class PayInvoiceRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
payment_request: str = Field(..., min_length=1, max_length=8192)
max_sat: int | None = Field(None, gt=0)
description: str = Field("", max_length=512)
extra: dict[str, str] = Field(default_factory=dict)
class PayInvoiceResponse(BaseModel):
ok: bool = True
error: str | None = None
checking_id: str | None = None
payment_hash: str | None = None
status: str | None = None
amount_msat: int = 0
fee_msat: int = 0
pending: bool = False
success: bool = False
class HttpRequest(BaseModel):
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
url: str = Field(..., min_length=1, max_length=2048)
@@ -163,6 +238,107 @@ class ExtensionApiRequest(BaseModel):
return values
class CurrencyListResponse(BaseModel):
currencies: list[str] = Field(default_factory=list)
class CurrencyRateRequest(BaseModel):
currency: str = Field(..., min_length=1, max_length=8)
class CurrencyRateResponse(BaseModel):
rate: float
price: float
class CurrencyConvertRequest(BaseModel):
amount: float = Field(..., gt=0)
from_currency: str = Field(..., alias="from", min_length=1, max_length=8)
to: str = Field(..., min_length=1, max_length=256)
class Config:
allow_population_by_field_name = True
class CurrencyConvertResponse(BaseModel):
amounts: list[tuple[str, float]] = Field(default_factory=list)
class FiatToSatsRequest(BaseModel):
amount: float = Field(..., gt=0)
currency: str = Field(..., min_length=1, max_length=8)
class FiatToSatsResponse(BaseModel):
amount_sat: int
class SatsToFiatRequest(BaseModel):
amount: float = Field(..., gt=0)
currency: str = Field(..., min_length=1, max_length=8)
class SatsToFiatResponse(BaseModel):
amount: float
class ServerHealthResponse(BaseModel):
server_time: int
up_time: str
class Bolt11Request(BaseModel):
bolt11: str = Field(..., min_length=1, max_length=8192)
class DecodeInvoiceResponse(BaseModel):
valid: bool = True
payment_hash: str | None = None
amount_msat: int | None = None
expiry: int | None = None
expires_at: int | None = None
memo: str | None = None
class ValidateInvoiceResponse(BaseModel):
valid: bool
error: str | None = None
class InvoicePaymentHashResponse(BaseModel):
payment_hash: str
class InvoiceAmountMsatResponse(BaseModel):
amount_msat: int | None = None
class InvoiceExpiryResponse(BaseModel):
expires_at: int | None = None
class InvoiceMemoResponse(BaseModel):
memo: str | None = None
class VerifyPreimageRequest(BaseModel):
preimage: str = Field(..., min_length=64, max_length=64)
payment_hash: str = Field(..., min_length=64, max_length=64)
class VerifyPreimageResponse(BaseModel):
valid: bool
class RandomSecretAndHashRequest(BaseModel):
length: int = Field(32, ge=16, le=64)
class RandomSecretAndHashResponse(BaseModel):
secret: str
hash: str
class RandomIdRequest(BaseModel):
prefix: str = Field(..., min_length=1, max_length=32)
+59
View File
@@ -0,0 +1,59 @@
from collections.abc import Iterable
from typing import Any
from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension
from lnbits.core.wasm_ext.api.registry import extension_api_permission_ids
def validate_extension_permissions(
ext_id: str,
permissions: Iterable[ExtensionPermission],
*,
strict: bool = True,
) -> list[ExtensionPermission]:
known_permission_ids = extension_api_permission_ids()
normalized_permissions: list[ExtensionPermission] = []
unknown_ids: list[str] = []
for permission in permissions:
if permission.id not in known_permission_ids:
unknown_ids.append(permission.id)
if strict:
continue
normalized_permissions.append(permission.copy(update={"label": None}))
if unknown_ids and strict:
raise ValueError(
f"Extension '{ext_id}' requests unknown permissions: "
+ ", ".join(sorted(set(unknown_ids)))
)
return normalized_permissions
def validate_wasm_extension_permissions(
ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None,
extension_config: dict[str, Any],
) -> list[ExtensionPermission]:
if extension_config.get("extension_type") != "wasm":
return []
requested_permissions = validate_extension_permissions(
ext_info.id,
ExtensionPermission.list_from_config(extension_config),
)
if not requested_permissions:
return []
if granted_permissions is None:
raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
requested_ids = {permission.id for permission in requested_permissions}
granted_ids = {permission.id for permission in granted_permissions}
if requested_ids != granted_ids:
raise ValueError(
f"Extension '{ext_info.id}' was not granted all requested permissions."
)
return requested_permissions
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import inspect
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import Any, TypeVar, cast, get_type_hints
from pydantic import BaseModel
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
_EXTENSION_RUNTIME_PERMISSION_IDS = {"ui.camera.scan_qr"}
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
def extension_api_method(
*,
method_id: str,
namespace: str,
name: str,
host_name: str,
sdk_name: str,
description: str,
host_interface: str = "host",
required_permission: str | None = None,
require_auth: bool = True,
) -> Callable[
[Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]],
Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
]:
export = ExtensionAPIMethodExport(
method_id=method_id,
namespace=namespace,
name=name,
host_interface=host_interface,
host_name=host_name,
sdk_name=sdk_name,
description=description,
required_permission=required_permission,
require_auth=require_auth,
)
def decorator(
function: Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
) -> Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]:
@wraps(function)
async def wrapper(self: Any, request: _RequestModel) -> _ResponseModel:
api = getattr(self, "api", self)
if require_auth and not api.has_authenticated_context():
raise PermissionError(
f"Extension API method '{method_id}' requires authentication."
)
api.require_permission(required_permission)
return await function(self, request)
setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export)
return wrapper
return decorator
def list_extension_api_methods(
api_cls: type[Any] | None = None,
) -> list[ExtensionAPIMethod]:
api_cls = _default_api_cls(api_cls)
methods: list[ExtensionAPIMethod] = []
for prefix, method_cls in _extension_api_method_sources(api_cls):
for python_name, function in inspect.getmembers(method_cls, inspect.isfunction):
export = getattr(function, _EXTENSION_API_METHOD_ATTR, None)
if not export:
continue
request_model, response_model = _get_method_models(function)
methods.append(
ExtensionAPIMethod(
method_id=export.method_id,
namespace=export.namespace,
name=export.name,
python_name=f"{prefix}.{python_name}" if prefix else python_name,
host_interface=export.host_interface,
host_name=export.host_name,
sdk_name=export.sdk_name,
description=export.description,
request_model=request_model,
response_model=response_model,
required_permission=export.required_permission,
require_auth=export.require_auth,
)
)
return sorted(methods, key=lambda method: method.method_id)
def extension_api_permission_ids(api_cls: type[Any] | None = None) -> set[str]:
permissions = {
method.required_permission
for method in list_extension_api_methods(api_cls)
if method.required_permission
}
permissions.update(_EXTENSION_RUNTIME_PERMISSION_IDS)
return permissions
def get_extension_api_method(
method_id: str,
api_cls: type[Any] | None = None,
) -> ExtensionAPIMethod:
for method in list_extension_api_methods(api_cls):
if method.method_id == method_id:
return method
raise KeyError(f"Unknown extension API method '{method_id}'.")
def extension_api_contract(api_cls: type[Any] | None = None) -> dict[str, object]:
return {
"version": 1,
"methods": [
{
"id": method.method_id,
"namespace": method.namespace,
"name": method.name,
"python_name": method.python_name,
"host_interface": method.host_interface,
"host_name": method.host_name,
"sdk_name": method.sdk_name,
"sdk_qualified_name": method.sdk_qualified_name,
"description": method.description,
"required_permission": method.required_permission,
"require_auth": method.require_auth,
"request_schema": method.request_model.schema(
ref_template="#/definitions/{model}"
),
"response_schema": method.response_model.schema(
ref_template="#/definitions/{model}"
),
}
for method in list_extension_api_methods(api_cls)
],
}
def _default_api_cls(api_cls: type[Any] | None) -> type[Any]:
if api_cls is not None:
return api_cls
from .host import ExtensionHostAPI
return ExtensionHostAPI
def _extension_api_method_sources(
api_cls: type[Any],
) -> list[tuple[str, type[Any]]]:
sources: list[tuple[str, type[Any]]] = [("", api_cls)]
from .host import ExtensionHostAPI
if issubclass(api_cls, ExtensionHostAPI):
from .utils import extension_api_utils_method_classes
sources.extend(extension_api_utils_method_classes().items())
return sources
def _get_method_models(
function: Callable[..., object],
) -> tuple[type[BaseModel], type[BaseModel]]:
signature = inspect.signature(function)
request_parameters = [
parameter
for parameter in signature.parameters.values()
if parameter.name != "self"
]
if len(request_parameters) != 1:
raise TypeError(
f"Extension API method '{function.__name__}' must accept one request model."
)
hints = get_type_hints(function)
request_model = hints.get(request_parameters[0].name)
response_model = hints.get("return")
if not _is_pydantic_model(request_model):
raise TypeError(
f"Extension API method '{function.__name__}' request must be a BaseModel."
)
if not _is_pydantic_model(response_model):
raise TypeError(
f"Extension API method '{function.__name__}' response must be a BaseModel."
)
return cast(type[BaseModel], request_model), cast(type[BaseModel], response_model)
def _is_pydantic_model(value: object) -> bool:
return isinstance(value, type) and issubclass(value, BaseModel)
@@ -7,7 +7,9 @@ from typing import Any
from pydantic import BaseModel
from .api import ExtensionAPI, ExtensionAPIMethod, list_extension_api_methods
from .host import ExtensionHostAPI
from .models import ExtensionAPIMethod
from .registry import list_extension_api_methods
HostImport = Callable[..., Awaitable[dict[str, Any]]]
@@ -15,9 +17,9 @@ HostImport = Callable[..., Awaitable[dict[str, Any]]]
class ExtensionAPIHost:
def __init__(
self,
api: ExtensionAPI,
api: ExtensionHostAPI,
*,
api_cls: type[ExtensionAPI] = ExtensionAPI,
api_cls: type[ExtensionHostAPI] = ExtensionHostAPI,
) -> None:
self.api = api
self.methods = list_extension_api_methods(api_cls)
@@ -30,26 +32,34 @@ class ExtensionAPIHost:
) -> dict[str, Any]:
method = self._require_method(host_name)
request = self._request_model(method, payload)
handler = getattr(self.api, method.python_name)
handler = _resolve_attr_path(self.api, method.python_name)
response = handler(request)
if inspect.isawaitable(response):
response = await response
return self._response_payload(method, response)
def imports(self) -> dict[str, HostImport]:
return self.imports_for_interface("host")
def import_object(self) -> dict[str, dict[str, HostImport]]:
interfaces = sorted({method.host_interface for method in self.methods})
return {
f"lnbits:extension/{interface}": self.imports_for_interface(interface)
for interface in interfaces
}
def imports_for_interface(self, host_interface: str) -> dict[str, HostImport]:
return {
_snake_to_camel(method.host_name): self._make_import(method)
for method in self.methods
if method.host_interface == host_interface
}
def import_object(self) -> dict[str, dict[str, HostImport]]:
return {"lnbits:extension/host": self.imports()}
def _make_import(self, method: ExtensionAPIMethod) -> HostImport:
async def host_import(
payload: Mapping[str, Any] | BaseModel | None = None,
) -> dict[str, Any]:
return await self.invoke(method.host_name, payload)
return await self.invoke(method.method_id, payload)
return host_import
@@ -66,6 +76,8 @@ class ExtensionAPIHost:
index: dict[str, ExtensionAPIMethod] = {}
for method in methods:
for host_name in {
method.method_id,
f"{method.host_interface}:{method.host_name}",
method.host_name,
_snake_to_camel(method.host_name),
method.host_name.replace("_", "-"),
@@ -118,3 +130,9 @@ def _snake_to_camel(value: str) -> str:
def _to_snake(value: str) -> str:
value = value.replace("-", "_")
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower()
def _resolve_attr_path(value: Any, path: str) -> Any:
for part in path.split("."):
value = getattr(value, part)
return value
+381
View File
@@ -0,0 +1,381 @@
from __future__ import annotations
import time
from datetime import datetime
from typing import TYPE_CHECKING, Any
from .models import (
Bolt11Request,
CurrencyConvertRequest,
CurrencyConvertResponse,
CurrencyListResponse,
CurrencyRateRequest,
CurrencyRateResponse,
DecodeInvoiceResponse,
EmptyRequest,
FiatToSatsRequest,
FiatToSatsResponse,
InvoiceAmountMsatResponse,
InvoiceExpiryResponse,
InvoiceMemoResponse,
InvoicePaymentHashResponse,
RandomSecretAndHashRequest,
RandomSecretAndHashResponse,
SatsToFiatRequest,
SatsToFiatResponse,
ServerHealthResponse,
ValidateInvoiceResponse,
VerifyPreimageRequest,
VerifyPreimageResponse,
)
from .registry import extension_api_method
if TYPE_CHECKING:
from .host import ExtensionHostAPI
class ExtensionAPIUtils:
def __init__(self, api: ExtensionHostAPI) -> None:
self.api = api
self.currencies = ExtensionCurrencyUtils(api)
self.server = ExtensionServerUtils(api)
self.lightning = ExtensionLightningUtils(api)
class _ExtensionAPIUtilsGroup:
def __init__(self, api: ExtensionHostAPI) -> None:
self.api = api
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.currencies.list",
namespace="utils.currencies",
name="List currencies",
host_interface="utils-currencies",
host_name="list_currencies",
sdk_name="list",
description="List currencies supported by LNbits exchange-rate conversion.",
required_permission="utils.basic",
require_auth=False,
)
async def list(self, request: EmptyRequest) -> CurrencyListResponse:
from lnbits.utils.exchange_rates import allowed_currencies
return CurrencyListResponse(currencies=allowed_currencies())
@extension_api_method(
method_id="utils.currencies.rate",
namespace="utils.currencies",
name="Get currency rate",
host_interface="utils-currencies",
host_name="rate",
sdk_name="rate",
description="Get sats-per-fiat and BTC price for a currency.",
required_permission="utils.basic",
require_auth=False,
)
async def rate(self, request: CurrencyRateRequest) -> CurrencyRateResponse:
from lnbits.utils.exchange_rates import get_fiat_rate_and_price_satoshis
rate, price = await get_fiat_rate_and_price_satoshis(request.currency)
return CurrencyRateResponse(rate=rate, price=price)
@extension_api_method(
method_id="utils.currencies.convert",
namespace="utils.currencies",
name="Convert currency amount",
host_interface="utils-currencies",
host_name="convert",
sdk_name="convert",
description="Convert between sats, BTC, and supported fiat currencies.",
required_permission="utils.basic",
require_auth=False,
)
async def convert(self, request: CurrencyConvertRequest) -> CurrencyConvertResponse:
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
satoshis_amount_as_fiat,
)
from_currency = request.from_currency
if from_currency == "sats":
from_currency = "sat"
amounts: list[tuple[str, float]] = []
if from_currency == "sat":
sats = int(request.amount)
amounts.append(("BTC", sats / 100_000_000))
amounts.append(("sats", sats))
for currency in request.to.split(","):
currency = currency.strip()
if currency:
amounts.append(
(
currency.upper(),
await satoshis_amount_as_fiat(sats, currency),
)
)
else:
sats = await fiat_amount_as_satoshis(request.amount, from_currency)
amounts.append((from_currency.upper(), request.amount))
amounts.append(("sats", sats))
amounts.append(("BTC", sats / 100_000_000))
return CurrencyConvertResponse(amounts=amounts)
@extension_api_method(
method_id="utils.currencies.fiat_to_sats",
namespace="utils.currencies",
name="Convert fiat to sats",
host_interface="utils-currencies",
host_name="fiat_to_sats",
sdk_name="fiatToSats",
description="Convert a fiat amount to sats.",
required_permission="utils.basic",
require_auth=False,
)
async def fiat_to_sats(self, request: FiatToSatsRequest) -> FiatToSatsResponse:
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
return FiatToSatsResponse(
amount_sat=await fiat_amount_as_satoshis(
request.amount,
request.currency,
)
)
@extension_api_method(
method_id="utils.currencies.sats_to_fiat",
namespace="utils.currencies",
name="Convert sats to fiat",
host_interface="utils-currencies",
host_name="sats_to_fiat",
sdk_name="satsToFiat",
description="Convert a sats amount to fiat.",
required_permission="utils.basic",
require_auth=False,
)
async def sats_to_fiat(self, request: SatsToFiatRequest) -> SatsToFiatResponse:
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
return SatsToFiatResponse(
amount=await satoshis_amount_as_fiat(request.amount, request.currency)
)
class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.server.health",
namespace="utils.server",
name="Server health",
host_interface="utils-server",
host_name="health",
sdk_name="health",
description="Return basic public LNbits server health data.",
required_permission="utils.basic",
require_auth=False,
)
async def health(self, request: EmptyRequest) -> ServerHealthResponse:
from lnbits.settings import settings
return ServerHealthResponse(
server_time=int(time.time()),
up_time=settings.lnbits_server_up_time,
)
class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.lightning.decode_invoice",
namespace="utils.lightning",
name="Decode Lightning invoice",
host_interface="utils-lightning",
host_name="decode_invoice",
sdk_name="decodeInvoice",
description="Decode a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def decode_invoice(self, request: Bolt11Request) -> DecodeInvoiceResponse:
invoice = _decode_bolt11(request.bolt11)
return _decoded_invoice_response(invoice)
@extension_api_method(
method_id="utils.lightning.validate_invoice",
namespace="utils.lightning",
name="Validate Lightning invoice",
host_interface="utils-lightning",
host_name="validate_invoice",
sdk_name="validateInvoice",
description="Validate whether a string is a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def validate_invoice(self, request: Bolt11Request) -> ValidateInvoiceResponse:
try:
_decode_bolt11(request.bolt11)
return ValidateInvoiceResponse(valid=True)
except Exception as exc:
return ValidateInvoiceResponse(valid=False, error=str(exc))
@extension_api_method(
method_id="utils.lightning.invoice_payment_hash",
namespace="utils.lightning",
name="Get Lightning invoice payment hash",
host_interface="utils-lightning",
host_name="invoice_payment_hash",
sdk_name="invoicePaymentHash",
description="Get the payment hash from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def invoice_payment_hash(
self, request: Bolt11Request
) -> InvoicePaymentHashResponse:
return InvoicePaymentHashResponse(
payment_hash=str(_decode_bolt11(request.bolt11).payment_hash)
)
@extension_api_method(
method_id="utils.lightning.invoice_amount_msat",
namespace="utils.lightning",
name="Get Lightning invoice amount",
host_interface="utils-lightning",
host_name="invoice_amount_msat",
sdk_name="invoiceAmountMsat",
description="Get the amount in msat from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def invoice_amount_msat(
self, request: Bolt11Request
) -> InvoiceAmountMsatResponse:
return InvoiceAmountMsatResponse(
amount_msat=_invoice_amount_msat(_decode_bolt11(request.bolt11))
)
@extension_api_method(
method_id="utils.lightning.invoice_expiry",
namespace="utils.lightning",
name="Get Lightning invoice expiry",
host_interface="utils-lightning",
host_name="invoice_expiry",
sdk_name="invoiceExpiry",
description="Get the expiry timestamp from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def invoice_expiry(self, request: Bolt11Request) -> InvoiceExpiryResponse:
return InvoiceExpiryResponse(
expires_at=_invoice_expires_at(_decode_bolt11(request.bolt11))
)
@extension_api_method(
method_id="utils.lightning.invoice_memo",
namespace="utils.lightning",
name="Get Lightning invoice memo",
host_interface="utils-lightning",
host_name="invoice_memo",
sdk_name="invoiceMemo",
description="Get the memo from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def invoice_memo(self, request: Bolt11Request) -> InvoiceMemoResponse:
return InvoiceMemoResponse(memo=_invoice_memo(_decode_bolt11(request.bolt11)))
@extension_api_method(
method_id="utils.lightning.verify_preimage",
namespace="utils.lightning",
name="Verify Lightning preimage",
host_interface="utils-lightning",
host_name="verify_preimage",
sdk_name="verifyPreimage",
description="Verify that a preimage matches a payment hash.",
required_permission="utils.basic",
require_auth=False,
)
async def verify_preimage(
self, request: VerifyPreimageRequest
) -> VerifyPreimageResponse:
from lnbits.utils.crypto import verify_preimage
return VerifyPreimageResponse(
valid=verify_preimage(request.preimage, request.payment_hash)
)
@extension_api_method(
method_id="utils.lightning.random_secret_and_hash",
namespace="utils.lightning",
name="Random Lightning secret and hash",
host_interface="utils-lightning",
host_name="random_secret_and_hash",
sdk_name="randomSecretAndHash",
description="Create a random secret and matching SHA256 hash.",
required_permission="utils.basic",
require_auth=False,
)
async def random_secret_and_hash(
self, request: RandomSecretAndHashRequest
) -> RandomSecretAndHashResponse:
from lnbits.utils.crypto import random_secret_and_hash
secret, payment_hash = random_secret_and_hash(request.length)
return RandomSecretAndHashResponse(secret=secret, hash=payment_hash)
def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGroup]]:
return {
"utils.currencies": ExtensionCurrencyUtils,
"utils.server": ExtensionServerUtils,
"utils.lightning": ExtensionLightningUtils,
}
def _decode_bolt11(payment_request: str) -> Any:
from lnbits import bolt11
return bolt11.decode(payment_request)
def _decoded_invoice_response(invoice: Any) -> DecodeInvoiceResponse:
return DecodeInvoiceResponse(
payment_hash=str(getattr(invoice, "payment_hash", "")) or None,
amount_msat=_invoice_amount_msat(invoice),
expiry=_invoice_expiry(invoice),
expires_at=_invoice_expires_at(invoice),
memo=_invoice_memo(invoice),
)
def _invoice_amount_msat(invoice: Any) -> int | None:
amount_msat = getattr(invoice, "amount_msat", None)
if amount_msat is None:
return None
return int(amount_msat)
def _invoice_expiry(invoice: Any) -> int | None:
expiry = getattr(invoice, "expiry", None)
if expiry is None:
return None
return int(expiry)
def _invoice_expires_at(invoice: Any) -> int | None:
expiry_date = getattr(invoice, "expiry_date", None)
if isinstance(expiry_date, datetime):
return int(expiry_date.timestamp())
date = getattr(invoice, "date", None)
expiry = getattr(invoice, "expiry", None)
if isinstance(date, datetime) and expiry is not None:
return int(date.timestamp() + int(expiry))
if isinstance(date, (int, float)) and expiry is not None:
return int(date + int(expiry))
return None
def _invoice_memo(invoice: Any) -> str | None:
memo = getattr(invoice, "description", None)
return str(memo) if memo is not None else None
+4
View File
@@ -0,0 +1,4 @@
from .extensions import send_extension_api_request
from .http import send_extension_http_request
__all__ = ["send_extension_api_request", "send_extension_http_request"]
@@ -11,10 +11,9 @@ from lnbits.core.crud.extensions import (
get_installed_extension,
get_user_active_extensions_ids,
)
from lnbits.core.crud.wallets import get_wallets
from lnbits.settings import settings
from .models import ExtensionApiRequest, HttpResponse
from ..api.models import ExtensionApiRequest, HttpResponse
EXTENSION_API_TIMEOUT_SECONDS = 10.0
EXTENSION_API_MAX_RESPONSE_BYTES = 262_144
@@ -32,19 +31,21 @@ _FORBIDDEN_RESPONSE_HEADERS = {
async def send_extension_api_request(
caller_extension_id: str,
policy: dict[str, Any],
policies: list[Any],
user_id: str | None,
access_token: str | None,
request: ExtensionApiRequest,
) -> HttpResponse:
if not user_id:
raise PermissionError("Extension API requests require authentication.")
if not access_token:
raise PermissionError("Extension API requests require an account access token.")
target_extension_id = _target_extension_id(request.extension_id)
access = _target_extension_access(policy, target_extension_id)
access = _target_extension_access(policies, target_extension_id)
_require_method_access(caller_extension_id, target_extension_id, access, request)
await _require_enabled_extension(target_extension_id, user_id)
api_key = await _user_api_key(user_id, request.method)
path = _extension_api_path(request.path)
body = request.body.encode() if request.body is not None else b""
if len(body) > 65_536:
@@ -60,7 +61,7 @@ async def send_extension_api_request(
async with client.stream(
request.method,
url,
headers={"X-API-KEY": api_key},
headers={"Authorization": f"Bearer {access_token}"},
content=body,
) as response:
response_body = await _read_limited_response(response)
@@ -80,31 +81,33 @@ def _target_extension_id(extension_id: str) -> str:
return target
def _target_extension_access(
policy: dict[str, Any], target_extension_id: str
) -> set[str]:
extensions = policy.get("extensions")
if not isinstance(extensions, list) or not extensions:
def _target_extension_access(policies: list[Any], target_extension_id: str) -> set[str]:
if not isinstance(policies, list) or not policies:
raise PermissionError(
"Extension API requests require a non-empty extensions policy."
)
for extension in extensions:
for extension in policies:
if isinstance(extension, str):
extension_id = extension
access = ["read"]
elif isinstance(extension, dict):
extension_id = extension.get("id")
access = extension.get("access")
raw_extension_id = extension.get("id")
raw_access = extension.get("access")
if not isinstance(raw_extension_id, str):
continue
if not isinstance(raw_access, list):
raise PermissionError(
f"Extension API target '{target_extension_id}' "
"has no access policy."
)
extension_id = raw_extension_id
access = raw_access
else:
continue
if extension_id != target_extension_id:
continue
if not isinstance(access, list):
raise PermissionError(
f"Extension API target '{target_extension_id}' has no access policy."
)
clean_access = {
item
for item in access
@@ -153,14 +156,6 @@ async def _require_enabled_extension(target_extension_id: str, user_id: str) ->
)
async def _user_api_key(user_id: str, method: str) -> str:
wallets = await get_wallets(user_id)
if not wallets:
raise PermissionError("Extension API request requires a user wallet.")
wallet = wallets[0]
return wallet.inkey if method in _READ_METHODS else wallet.adminkey
def _extension_api_path(path: str) -> str:
parts = urlsplit(path)
if parts.scheme or parts.netloc:
@@ -7,7 +7,7 @@ from urllib.parse import urlparse
import httpx
from .models import HttpRequest, HttpResponse
from ..api.models import HttpRequest, HttpResponse
HTTP_REQUEST_TIMEOUT_SECONDS = 10.0
HTTP_MAX_RESPONSE_BYTES = 262_144
@@ -30,10 +30,10 @@ _FORBIDDEN_RESPONSE_HEADERS = {
async def send_extension_http_request(
extension_id: str,
policy: dict[str, Any],
policies: list[Any],
request: HttpRequest,
) -> HttpResponse:
allowed_origins = _allowed_origins(policy)
allowed_origins = _allowed_origins(policies)
origin = _request_origin(request.url)
if origin not in allowed_origins:
raise PermissionError(
@@ -68,13 +68,13 @@ async def send_extension_http_request(
raise ValueError("HTTP request failed.") from exc
def _allowed_origins(policy: dict[str, Any]) -> set[str]:
hosts = policy.get("hosts")
if not isinstance(hosts, list) or not hosts:
def _allowed_origins(policies: list[Any]) -> set[str]:
if not isinstance(policies, list) or not policies:
raise PermissionError("HTTP requests require a non-empty hosts policy.")
origins: set[str] = set()
for host in hosts:
for policy in policies:
host = policy.get("host") if isinstance(policy, dict) else policy
if not isinstance(host, str) or not host:
continue
origins.add(_request_origin(host))
+3
View File
@@ -0,0 +1,3 @@
from .register import register_wasm_extension
__all__ = ["register_wasm_extension"]
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
import json
import re
from typing import Annotated, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from lnbits.core.models import Account
from lnbits.decorators import check_access_token, check_account_exists
from ..wasm.invoke import invoke_wasm_extension_export
from ..wasm.loader import WasmExtension
def register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_config in extension.config.get("api_routes") or []:
_add_wasm_extension_api_route(app, extension, route_config)
def _add_wasm_extension_api_route(
app: FastAPI,
extension: WasmExtension,
route_config: dict[str, Any],
) -> None:
method = _wasm_extension_api_method(extension, route_config.get("method"))
route_path = _wasm_extension_api_path(extension, route_config.get("path"))
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
path_params = route_config.get("path_params") or {}
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
if _has_route(app, route_path, method):
return
async def invoke_wasm_api_request(
request: Request,
account: Account | None = None,
access_token: str | None = None,
) -> dict[str, Any]:
try:
payload = await _read_api_payload(request, path_params)
return await invoke_wasm_extension_export(
extension.id,
export_name,
payload,
user=account,
access_token=access_token,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async def invoke_private_wasm_extension_export(
request: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
account: Account = Depends(check_account_exists),
) -> dict[str, Any]:
return await invoke_wasm_api_request(request, account, access_token)
async def invoke_public_wasm_extension_export(request: Request) -> dict[str, Any]:
return await invoke_wasm_api_request(request)
app.add_api_route(
route_path,
(
invoke_public_wasm_extension_export
if auth == "public"
else invoke_private_wasm_extension_export
),
methods=[method],
name=f"{extension.id}:{method}:{route_path}",
include_in_schema=False,
)
async def _read_api_payload(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
payload = _read_api_path_params(request, path_params)
payload.update(_read_api_query_params(request))
if request.method in {"POST", "PUT", "PATCH"}:
payload.update(await _read_json_object(request))
return payload
async def _read_json_object(request: Request) -> dict[str, Any]:
body = await request.body()
if not body:
return {}
value = json.loads(body)
if not isinstance(value, dict):
raise TypeError("WASM extension API payload must be a JSON object.")
return value
def _read_api_path_params(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
payload: dict[str, Any] = {}
for key, value in request.path_params.items():
target = path_params.get(key) or _snake_to_camel(key)
payload[target] = value
return payload
def _read_api_query_params(request: Request) -> dict[str, Any]:
return {_snake_to_camel(key): value for key, value in request.query_params.items()}
def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str:
if not isinstance(export_name, str) or not export_name:
raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.")
for export in extension.exports:
if export.get("name") != export_name:
continue
if export.get("visibility") in {"public", "authenticated"}:
return export_name
raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.")
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str:
if not isinstance(method, str):
raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.")
method = method.upper()
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.")
return method
def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.")
if path == "/":
return f"/api/v1/ext/{extension.id}"
return f"/api/v1/ext/{extension.id}{path}"
def _wasm_extension_route_auth(extension: WasmExtension, auth: Any) -> str:
if auth in {"public", "user"}:
return auth
raise ValueError(f"Invalid route auth for WASM extension '{extension.id}'.")
def _has_route(app: FastAPI, route_path: str, method: str) -> bool:
for route in app.routes:
if getattr(route, "path", None) != route_path:
continue
methods = getattr(route, "methods", set()) or set()
if method in methods:
return True
return False
def _snake_to_camel(value: str) -> str:
head, *tail = value.split("_")
return head + "".join(part.capitalize() for part in tail)
def _path_template_pattern(path: str) -> str:
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
return f"^{pattern}$"
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.staticfiles import PathLike as StaticFilesPathLike
from starlette.types import Scope
from lnbits.settings import settings
from ..wasm.loader import WasmExtension
WASM_EXTENSION_CORE_ASSET_PREFIX = "_lnbits"
WASM_EXTENSION_CORE_STATIC_ASSETS = {
"bundle.min.css": ("static/bundle.min.css", "text/css; charset=utf-8"),
"material-icons-v50.woff2": (
"static/fonts/material-icons-v50.woff2",
"font/woff2",
),
"quasar.css": ("static/vendor/quasar.css", "text/css; charset=utf-8"),
"quasar.umd.prod.js": (
"static/vendor/quasar.umd.prod.js",
"text/javascript; charset=utf-8",
),
"qrcode.vue.browser.js": (
"static/vendor/qrcode.vue.browser.js",
"text/javascript; charset=utf-8",
),
"vue.global.prod.js": (
"static/vendor/vue.global.prod.js",
"text/javascript; charset=utf-8",
),
}
WASM_EXTENSION_GENERATED_CORE_ASSETS = {
"material-icons.css": (
"""
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url('./material-icons-v50.woff2') format('woff2');
}
""",
"text/css; charset=utf-8",
)
}
WASM_EXTENSION_STATIC_MIME_TYPES = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".png": "image/png",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
}
WASM_EXTENSION_TEXT_STATIC_EXTENSIONS = {".css", ".js"}
WASM_EXTENSION_HTML_PREFIXES = (b"<!doctype", b"<html", b"<script")
class GuardedWasmExtensionStaticFiles(StaticFiles):
async def get_response(self, path: str, scope: Scope) -> Response:
if path.startswith(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/"):
return _wasm_extension_core_asset_response(path)
if Path(path).suffix.lower() not in WASM_EXTENSION_STATIC_MIME_TYPES:
raise HTTPException(status_code=404)
return await super().get_response(path, scope)
def file_response(
self,
full_path: StaticFilesPathLike,
stat_result: os.stat_result,
scope: Scope,
status_code: int = 200,
) -> Response:
suffix = Path(full_path).suffix.lower()
if suffix in WASM_EXTENSION_TEXT_STATIC_EXTENSIONS:
_reject_html_like_wasm_static_asset(Path(full_path))
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["Content-Type"] = WASM_EXTENSION_STATIC_MIME_TYPES[suffix]
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
def mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None:
static_path = extension.root_path / "static"
mount_path = f"/ext-assets/{extension.id}"
if any(getattr(route, "path", None) == mount_path for route in app.routes):
return
app.mount(
mount_path,
GuardedWasmExtensionStaticFiles(directory=static_path, check_dir=False),
name=f"{extension.id}-static",
)
def _reject_html_like_wasm_static_asset(path: Path) -> None:
with path.open("rb") as asset_file:
prefix = asset_file.read(512).lstrip().lower()
if prefix.startswith(WASM_EXTENSION_HTML_PREFIXES):
raise HTTPException(status_code=404)
def _wasm_extension_core_asset_response(path: str) -> Response:
asset_name = path.removeprefix(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/")
if not asset_name or "/" in asset_name or "\\" in asset_name:
raise HTTPException(status_code=404)
generated_asset = WASM_EXTENSION_GENERATED_CORE_ASSETS.get(asset_name)
if generated_asset:
content, content_type = generated_asset
response = Response(content=content, media_type=content_type)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
asset_config = WASM_EXTENSION_CORE_STATIC_ASSETS.get(asset_name)
if not asset_config:
raise HTTPException(status_code=404)
relative_path, content_type = asset_config
asset_path = Path(settings.lnbits_path, relative_path)
if not asset_path.is_file():
raise HTTPException(status_code=404)
response = FileResponse(asset_path)
response.headers["Content-Type"] = content_type
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from fastapi import FastAPI
from loguru import logger
from lnbits.core.db import core_app_extra
from lnbits.settings import settings
from ..wasm.component import warm_wasm_extension
from ..wasm.loader import WasmExtension, load_wasm_extension
from .api import register_wasm_extension_api_routes
from .assets import mount_wasm_extension_static
from .ui import register_wasm_extension_ui_routes
def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
loaded = load_wasm_extension(ext_id)
warm_wasm_extension(loaded)
mount_wasm_extension_static(app, loaded)
register_wasm_extension_ui_routes(app, loaded)
register_wasm_extension_api_routes(app, loaded)
core_app_extra.wasm_extension_registry.register(loaded)
settings.activate_extension_paths(ext_id, "", [])
logger.info(
f"Loaded WASM extension '{loaded.id}' "
f"({loaded.module_path.stat().st_size} bytes)."
)
return loaded
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
from typing import Any, NoReturn
from uuid import uuid4
from fastapi import HTTPException, Request
from loguru import logger
from lnbits.helpers import template_renderer
from lnbits.utils.cache import cache
from ..wasm.loader import WasmExtension
WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60
def wasm_extension_wrapper_response(
request: Request,
extension: WasmExtension,
auth: str,
user_json: str | None,
) -> Any:
public = auth == "public"
response = template_renderer().TemplateResponse(
request,
"wasm_extension.html",
{
"extension": extension,
"public": public,
"user": user_json,
},
)
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
return response
def wasm_extension_frame_csp(request: Request, extension: WasmExtension) -> str:
origin = str(request.base_url).rstrip("/")
extension_assets = f"{origin}/ext-assets/{extension.id}/"
return (
"sandbox allow-scripts; "
"default-src 'none'; "
f"script-src {extension_assets}; "
"script-src-attr 'none'; "
f"style-src {extension_assets}; "
"style-src-attr 'none'; "
f"img-src {extension_assets} data:; "
f"font-src {extension_assets}; "
"connect-src 'none'; "
"form-action 'none'; "
"object-src 'none'; "
"base-uri 'none'; "
"frame-src 'none'; "
"worker-src 'none'; "
"media-src 'none'; "
"manifest-src 'none'; "
"frame-ancestors 'self'"
)
def wasm_extension_frame_url(
extension: WasmExtension, frame_path: str, user_id: str | None
) -> str:
token = _create_wasm_extension_frame_token(extension, frame_path, user_id)
return f"{frame_path}?frame_token={token}"
def consume_wasm_extension_frame_token(
request: Request,
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> None:
token = request.query_params.get("frame_token")
if not token:
_raise_wasm_extension_frame_not_found(extension, frame_path, "missing")
cache_key = _wasm_extension_frame_token_cache_key(token)
token_data = cache.get(cache_key)
if (
not isinstance(token_data, dict)
or token_data.get("extension_id") != extension.id
or token_data.get("frame_path") != frame_path
):
_raise_wasm_extension_frame_not_found(
extension, frame_path, "unknown or expired"
)
token_user_id = token_data.get("user_id")
if token_user_id and token_user_id != user_id:
_raise_wasm_extension_frame_not_found(extension, frame_path, "wrong user")
cache.pop(cache_key)
def _create_wasm_extension_frame_token(
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> str:
token = uuid4().hex
cache.set(
_wasm_extension_frame_token_cache_key(token),
{
"extension_id": extension.id,
"frame_path": frame_path,
"user_id": user_id,
},
expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS,
)
return token
def _wasm_extension_frame_token_cache_key(token: str) -> str:
return f"wasm-frame-token:{token}"
def _raise_wasm_extension_frame_not_found(
extension: WasmExtension,
frame_path: str,
reason: str,
) -> NoReturn:
logger.warning(
f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'."
)
raise HTTPException(status_code=404, detail="Not found")
+368
View File
@@ -0,0 +1,368 @@
from __future__ import annotations
from pathlib import Path
from typing import Annotated, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import UUID4
from lnbits.core.crud import get_installed_extension, get_user_from_account
from lnbits.core.models import Account
from lnbits.decorators import (
check_access_token,
check_account_exists,
optional_user_id,
)
from ..wasm.loader import WasmExtension
from .api import (
_has_route,
_path_template_pattern,
_read_json_object,
_snake_to_camel,
_wasm_extension_api_export,
_wasm_extension_api_method,
_wasm_extension_api_path,
_wasm_extension_route_auth,
)
from .security import (
consume_wasm_extension_frame_token,
wasm_extension_frame_csp,
wasm_extension_frame_url,
wasm_extension_wrapper_response,
)
def register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
_add_wasm_extension_frame_config_route(app, extension)
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
entrypoint = _wasm_extension_entrypoint(
extension, route_config.get("entrypoint")
)
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
_add_wasm_extension_wrapper_route(
app,
extension,
route_path,
auth,
)
def _add_wasm_extension_frame_config_route(
app: FastAPI,
extension: WasmExtension,
) -> None:
route_path = _wasm_extension_frame_config_path(extension)
if _has_route(app, route_path, "POST"):
return
async def create_wasm_extension_frame_config(
request: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> dict[str, Any]:
try:
body = await _read_json_object(request)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
ui_route = _match_wasm_extension_ui_route(extension, body.get("path"))
auth = ui_route["auth"]
if auth == "user":
account = await check_account_exists(request, access_token, usr)
user_id: str | None = account.id
else:
user_id = await _optional_wasm_user_id(request, access_token, usr)
granted_permission_ids = await _wasm_extension_granted_permission_ids(extension)
return _wasm_extension_frame_config(
extension,
ui_route["frame_path"],
auth,
ui_route["path_params"],
ui_route["route_params"],
_read_wasm_extension_route_query(body.get("query")),
user_id,
granted_permission_ids,
)
app.add_api_route(
route_path,
create_wasm_extension_frame_config,
methods=["POST"],
name=f"{extension.id}:frame-config",
include_in_schema=False,
)
def _add_wasm_extension_wrapper_route(
app: FastAPI,
extension: WasmExtension,
route_path: str,
auth: str,
) -> None:
if _has_route(app, route_path, "GET"):
return
async def serve_private_wasm_extension_page(
request: Request,
account: Account = Depends(check_account_exists),
) -> Any:
user = await get_user_from_account(account)
return wasm_extension_wrapper_response(
request,
extension,
auth,
user.json() if user else None,
)
async def serve_public_wasm_extension_page(request: Request) -> Any:
return wasm_extension_wrapper_response(
request,
extension,
auth,
None,
)
app.add_api_route(
route_path,
(
serve_public_wasm_extension_page
if auth == "public"
else serve_private_wasm_extension_page
),
methods=["GET"],
name=f"{extension.id}:{route_path}",
include_in_schema=False,
)
def _add_wasm_extension_frame_route(
app: FastAPI,
extension: WasmExtension,
frame_path: str,
entrypoint: Path,
) -> None:
if _has_route(app, frame_path, "GET"):
return
async def serve_wasm_extension_frame(
request: Request,
user_id: str | None = Depends(_optional_wasm_user_id),
) -> FileResponse:
consume_wasm_extension_frame_token(request, extension, frame_path, user_id)
response = FileResponse(entrypoint)
response.headers["Content-Security-Policy"] = wasm_extension_frame_csp(
request, extension
)
response.headers["Cache-Control"] = "no-store"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
# Extension access goes through the parent bridge.
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=(), "
"clipboard-read=(), usb=()"
)
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
app.add_api_route(
frame_path,
serve_wasm_extension_frame,
methods=["GET"],
name=f"{extension.id}:frame:{frame_path}",
include_in_schema=False,
)
def _wasm_extension_bridge_api_routes(
extension: WasmExtension,
public: bool,
) -> list[dict[str, str]]:
routes: list[dict[str, str]] = []
for route_config in extension.config.get("api_routes") or []:
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
if public and auth != "public":
continue
method = _wasm_extension_api_method(extension, route_config.get("method"))
path = _wasm_extension_api_path(extension, route_config.get("path"))
_wasm_extension_api_export(extension, route_config.get("export"))
routes.append(
{
"method": method,
"path": path,
"pattern": _path_template_pattern(path),
}
)
return routes
def _wasm_extension_frame_config_path(extension: WasmExtension) -> str:
return f"/api/v1/ext/{extension.id}/_ui/frame"
def _match_wasm_extension_ui_route(
extension: WasmExtension,
path: Any,
) -> dict[str, Any]:
if not isinstance(path, str) or not path.startswith("/"):
raise HTTPException(status_code=404, detail="Not found")
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
route_params = _path_template_params(route_path, path)
if route_params is None:
continue
return {
"frame_path": f"/ext-frame/{extension.id}/{route_index}",
"auth": _wasm_extension_route_auth(extension, route_config.get("auth")),
"path_params": route_config.get("path_params") or {},
"route_params": route_params,
}
raise HTTPException(status_code=404, detail="Not found")
def _path_template_params(template: str, path: str) -> dict[str, str] | None:
template_parts = _path_parts(template)
path_parts = _path_parts(path)
if len(template_parts) != len(path_parts):
return None
params: dict[str, str] = {}
for template_part, path_part in zip(template_parts, path_parts, strict=False):
if template_part.startswith("{") and template_part.endswith("}"):
param_name = template_part[1:-1]
if not param_name:
return None
params[param_name] = path_part
continue
if template_part != path_part:
return None
return params
def _path_parts(path: str) -> list[str]:
return [part for part in path.strip("/").split("/") if part]
def _wasm_extension_frame_config(
extension: WasmExtension,
frame_path: str,
auth: str,
path_params: dict[str, str],
route_params: dict[str, str],
query: dict[str, Any],
user_id: str | None,
permissions: set[str],
) -> dict[str, Any]:
public = auth == "public"
return {
"extension": {
"id": extension.id,
"name": extension.name,
},
"frameUrl": wasm_extension_frame_url(extension, frame_path, user_id),
"bridge": {
"extensionId": extension.id,
"public": public,
"routeParams": _map_wasm_extension_route_params(route_params, path_params),
"query": query,
"permissions": sorted(permissions),
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
},
}
async def _wasm_extension_granted_permission_ids(
extension: WasmExtension,
) -> set[str]:
installed_extension = await get_installed_extension(extension.id)
if not installed_extension:
return set()
return {permission.id for permission in installed_extension.permissions}
def _map_wasm_extension_route_params(
route_params: dict[str, str],
path_params: dict[str, str],
) -> dict[str, str]:
payload: dict[str, str] = {}
for key, value in route_params.items():
target = path_params.get(key) or _snake_to_camel(key)
payload[target] = value
return payload
def _read_wasm_extension_route_query(query: Any) -> dict[str, Any]:
if not isinstance(query, dict):
return {}
payload: dict[str, Any] = {}
for key, value in query.items():
if value is None:
continue
payload[_snake_to_camel(str(key))] = value
return payload
async def _optional_wasm_user_id(
request: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> str | None:
try:
return await optional_user_id(request, access_token, usr)
except HTTPException:
return None
def _wasm_extension_ui_route_path(extension: WasmExtension, path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.")
if path == "/":
return "/ext"
return f"/ext{path}"
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path:
if not isinstance(entrypoint, str) or not entrypoint:
raise ValueError(
f"Invalid route entrypoint for WASM extension '{extension.id}'."
)
if entrypoint.startswith("/"):
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be a "
"relative extension path."
)
path = (extension.root_path / entrypoint).resolve()
root_path = extension.root_path.resolve()
if path != root_path and root_path not in path.parents:
raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}")
static_path = (extension.root_path / "static").resolve()
if path == static_path or static_path in path.parents:
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must not be "
"inside the static asset directory."
)
if path.suffix.lower() != ".html":
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be "
"an HTML file."
)
if not path.is_file():
raise FileNotFoundError(f"Route entrypoint not found: {path}")
return path
+19
View File
@@ -0,0 +1,19 @@
from .crud import (
migrate_wasm_extension_database,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
storage_set_row,
)
__all__ = [
"migrate_wasm_extension_database",
"storage_delete_row",
"storage_get_paginated_rows",
"storage_get_public_row",
"storage_get_row",
"storage_get_row_owner_id",
"storage_set_row",
]
+13
View File
@@ -0,0 +1,13 @@
from .component import warm_wasm_extension
from .events import dispatch_wasm_invoice_paid
from .invoke import invoke_wasm_extension_export
from .loader import WasmExtension, is_wasm_extension_dir, is_wasm_extension_id
__all__ = [
"WasmExtension",
"dispatch_wasm_invoice_paid",
"invoke_wasm_extension_export",
"is_wasm_extension_dir",
"is_wasm_extension_id",
"warm_wasm_extension",
]
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from functools import lru_cache
from typing import Any
from .loader import WasmExtension
def warm_wasm_extension(extension: WasmExtension) -> None:
_wasm_component(extension)
@lru_cache(maxsize=1)
def _wasm_engine() -> Any:
try:
from wasmtime import Config, Engine
except ImportError as exc:
raise RuntimeError(
"WASM extension runtime is not installed. Install the 'wasmtime' "
"Python package to run WASM extensions."
) from exc
config = Config()
config.wasm_component_model = True
return Engine(config)
def _wasm_component(extension: WasmExtension) -> Any:
stat = extension.module_path.stat()
return _cached_wasm_component(
str(extension.module_path),
stat.st_mtime_ns,
stat.st_size,
)
@lru_cache(maxsize=32)
def _cached_wasm_component(
module_path: str,
mtime_ns: int,
size: int,
) -> Any:
from wasmtime import component
return component.Component.from_file(_wasm_engine(), module_path)
@@ -27,7 +27,7 @@ async def dispatch_wasm_invoice_paid(payment: Any) -> None:
return
try:
from lnbits.core.extensions.wasm import invoke_wasm_extension_export
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
await invoke_wasm_extension_export(
extension.id,
@@ -54,13 +54,17 @@ def _payment_extension_id(payment: Any) -> str | None:
async def _wasm_invoice_paid_owner_id(extension: Any, payment: Any) -> str | None:
source_id = _payment_source_id(payment)
source_table = _wasm_public_invoice_source_table(extension.config)
if not source_id or not source_table:
source_tables = _wasm_public_invoice_source_tables(extension.config)
if not source_id or not source_tables:
return None
from lnbits.core.extensions.storage import storage_get_row_owner_id
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
return await storage_get_row_owner_id(extension.id, source_table, source_id)
for source_table in source_tables:
owner_id = await storage_get_row_owner_id(extension.id, source_table, source_id)
if owner_id:
return owner_id
return None
def _payment_source_id(payment: Any) -> str | None:
@@ -69,17 +73,24 @@ def _payment_source_id(payment: Any) -> str | None:
return source_id if isinstance(source_id, str) and source_id else None
def _wasm_public_invoice_source_table(config: dict[str, Any]) -> str | None:
def _wasm_public_invoice_source_tables(config: dict[str, Any]) -> list[str]:
permissions = config.get("permissions") or []
for permission in permissions:
if not isinstance(permission, dict):
continue
if permission.get("id") != "wallet.create_invoice_public":
continue
policy = permission.get("policy") or {}
table = policy.get("table")
return table if isinstance(table, str) and table else None
return None
policies = permission.get("policies")
if not isinstance(policies, list):
return []
return [
source_policy["table"]
for source_policy in policies
if isinstance(source_policy, dict)
and isinstance(source_policy.get("table"), str)
and source_policy["table"]
]
return []
def _wasm_invoice_paid_export(config: dict[str, Any]) -> str | None:
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
import asyncio
import re
from collections.abc import Mapping
from typing import Any
from ..api.models import EmptyRequest
from ..api.registry import list_extension_api_methods
from ..api.runtime import ExtensionAPIHost
def add_extension_host_imports(
linker: Any,
api_host: ExtensionAPIHost,
event_loop: asyncio.AbstractEventLoop,
) -> None:
with linker.root() as root:
methods_by_interface: dict[str, list[Any]] = {}
for method in list_extension_api_methods():
methods_by_interface.setdefault(method.host_interface, []).append(method)
for host_interface, methods in methods_by_interface.items():
with root.add_instance(f"lnbits:extension/{host_interface}") as host:
for method in methods:
host.add_func(
method.host_name.replace("_", "-"),
_make_host_import(
api_host,
method.method_id,
method.request_model is EmptyRequest,
event_loop,
),
)
def _make_host_import(
api_host: ExtensionAPIHost,
host_name: str,
empty_request: bool,
event_loop: asyncio.AbstractEventLoop,
) -> Any:
if empty_request:
def empty_host_import(_store: Any) -> Any:
future = asyncio.run_coroutine_threadsafe(
api_host.invoke(host_name), event_loop
)
response = future.result()
return _dict_to_component_record(response)
return empty_host_import
def host_import(_store: Any, request: Any = None) -> Any:
payload = _component_payload_to_dict(request)
future = asyncio.run_coroutine_threadsafe(
api_host.invoke(host_name, payload), event_loop
)
response = future.result()
return _dict_to_component_record(response)
return host_import
def _component_payload_to_dict(value: Any) -> dict[str, Any]:
if value is None:
return {}
if hasattr(value, "__dict__"):
return dict(value.__dict__)
if isinstance(value, Mapping):
return dict(value)
raise TypeError("WASM host function payload must be a record.")
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
from wasmtime import component
record = component.Record()
for key, item in value.items():
setattr(record, _camel_to_kebab(key), _to_component_value(item))
return record
def _to_component_value(value: Any) -> Any:
if isinstance(value, Mapping):
return _dict_to_component_record(value)
if isinstance(value, list):
return [_to_component_value(item) for item in value]
return value
def _camel_to_kebab(value: str) -> str:
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
@@ -2,17 +2,17 @@ from __future__ import annotations
import asyncio
import json
import re
from collections.abc import Mapping
from functools import lru_cache
from typing import Any
from lnbits.core.crud.extensions import get_installed_extension
from lnbits.core.db import core_app_extra
from .api import ExtensionAPI, list_extension_api_methods
from ..api.host import ExtensionHostAPI
from ..api.runtime import ExtensionAPIHost
from .component import _wasm_component, _wasm_engine
from .host import add_extension_host_imports
from .loader import WasmExtension
from .runtime import ExtensionAPIHost
async def invoke_wasm_extension_export(
@@ -21,15 +21,17 @@ async def invoke_wasm_extension_export(
payload: Mapping[str, Any] | None = None,
*,
user: Any | None = None,
access_token: str | None = None,
context: str = "user",
owner_id: str | None = None,
) -> dict[str, Any]:
extension = _get_registered_extension(ext_id)
permissions = await _extension_permissions(extension)
api = ExtensionAPI(
api = ExtensionHostAPI(
extension.id,
permissions,
user_id=_user_id(user),
access_token=access_token,
context=context,
owner_id=owner_id,
)
@@ -45,15 +47,11 @@ async def invoke_wasm_extension_export(
)
def warm_wasm_extension(extension: WasmExtension) -> None:
_wasm_component(extension)
def _invoke_wasm_extension_export_sync(
extension: WasmExtension,
export_name: str,
payload: Mapping[str, Any],
api: ExtensionAPI,
api: ExtensionHostAPI,
event_loop: asyncio.AbstractEventLoop,
) -> dict[str, Any]:
try:
@@ -70,7 +68,7 @@ def _invoke_wasm_extension_export_sync(
linker = component.Linker(engine)
linker.add_wasip2()
_add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop)
add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop)
wasm_component = _wasm_component(extension)
instance = linker.instantiate(store, wasm_component)
@@ -85,98 +83,6 @@ def _invoke_wasm_extension_export_sync(
return _parse_wasm_export_result(extension, result)
@lru_cache(maxsize=1)
def _wasm_engine() -> Any:
try:
from wasmtime import Config, Engine
except ImportError as exc:
raise RuntimeError(
"WASM extension runtime is not installed. Install the 'wasmtime' "
"Python package to run WASM extensions."
) from exc
config = Config()
config.wasm_component_model = True
return Engine(config)
def _wasm_component(extension: WasmExtension) -> Any:
stat = extension.module_path.stat()
return _cached_wasm_component(
str(extension.module_path),
stat.st_mtime_ns,
stat.st_size,
)
@lru_cache(maxsize=32)
def _cached_wasm_component(
module_path: str,
mtime_ns: int,
size: int,
) -> Any:
from wasmtime import component
return component.Component.from_file(_wasm_engine(), module_path)
def _add_extension_host_imports(
linker: Any,
api_host: ExtensionAPIHost,
event_loop: asyncio.AbstractEventLoop,
) -> None:
with linker.root() as root:
with root.add_instance("lnbits:extension/host") as host:
for method in list_extension_api_methods():
host.add_func(
method.host_name.replace("_", "-"),
_make_host_import(api_host, method.host_name, event_loop),
)
def _make_host_import(
api_host: ExtensionAPIHost,
host_name: str,
event_loop: asyncio.AbstractEventLoop,
) -> Any:
def host_import(_store: Any, request: Any = None) -> Any:
payload = _component_payload_to_dict(request)
future = asyncio.run_coroutine_threadsafe(
api_host.invoke(host_name, payload), event_loop
)
response = future.result()
return _dict_to_component_record(response)
return host_import
def _component_payload_to_dict(value: Any) -> dict[str, Any]:
if value is None:
return {}
if hasattr(value, "__dict__"):
return dict(value.__dict__)
if isinstance(value, Mapping):
return dict(value)
raise TypeError("WASM host function payload must be a record.")
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
from wasmtime import component
record = component.Record()
for key, item in value.items():
setattr(record, _camel_to_kebab(key), _to_component_value(item))
return record
def _to_component_value(value: Any) -> Any:
if isinstance(value, Mapping):
return _dict_to_component_record(value)
if isinstance(value, list):
return [_to_component_value(item) for item in value]
return value
def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]:
if isinstance(value, bytes):
value = value.decode()
@@ -217,7 +123,3 @@ async def _extension_permissions(extension: WasmExtension) -> list[Any]:
def _user_id(user: Any | None) -> str | None:
return getattr(user, "id", None) if user else None
def _camel_to_kebab(value: str) -> str:
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
@@ -60,7 +60,7 @@ def load_wasm_extension(ext_id: str) -> WasmExtension:
module_path=module_path,
wit_path=wit_path,
world=wasm_config.get("world") or "",
host_api=wasm_config.get("host_api") or "lnbits.core.extensions.ExtensionAPI",
host_api=wasm_config.get("host_api") or "lnbits.core.wasm_ext.ExtensionHostAPI",
exports=wasm_config.get("exports") or [],
config=config,
)
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+26
View File
@@ -522,24 +522,50 @@ window.localisation.en = {
extension_permissions_title: 'Grant extension permissions',
extension_permissions_request: 'This extension requests these permissions:',
extension_permissions_grant_install: 'Grant and install',
extension_permissions_high_risk_warning:
'This extension requests permissions that can move funds.',
extension_permission_risk_low: 'Low risk',
extension_permission_risk_medium: 'Medium risk',
extension_permission_risk_high: 'High risk',
extension_permission_warning_wallet_pay_invoice:
'Can spend funds from wallets available to your account.',
extension_permission_warning_extension_api_request_write:
'Can write data or trigger actions in approved extensions.',
extension_permission_warning_http_request:
'Can send data to external services.',
extension_permission_ext_storage_read: 'Read extension storage',
extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
extension_permission_extension_api_request: 'Use other extensions',
extension_permission_extension_api_request_desc:
'Call approved installed extensions using your account permissions.',
extension_permission_extension_api_request_extensions: 'Allowed extensions',
extension_permission_access_read: 'Read',
extension_permission_access_write: 'Write',
extension_permission_http_request: 'Connect to external websites',
extension_permission_http_request_desc:
'Make HTTP requests to approved external hosts.',
extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_utils_basic: 'Use basic LNbits utilities',
extension_permission_utils_basic_desc:
'Use public currency conversion, server health, and Lightning invoice helper functions.',
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
extension_permission_ui_camera_scan_qr_desc:
'Use the LNbits scanner to read QR codes when you choose to scan.',
extension_permission_payments_watch: 'Watch payments',
extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public:
'Create Lightning invoices from public pages',
extension_permission_wallet_create_invoice_public_desc:
'Create incoming Lightning invoices from public pages.',
extension_permission_wallet_balance_read: 'View wallet balances',
extension_permission_wallet_balance_read_desc:
'Read balances of wallets available to your account.',
extension_permission_wallet_list: 'List wallets',
extension_permission_wallet_pay_invoice: 'Pay invoices',
extension_permission_wallet_pay_invoice_desc:
'Send Lightning payments from wallets available to your account.',
create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
+10
View File
@@ -139,6 +139,16 @@ const routes = [
name: 'PageError',
component: PageError
},
{
path: '/ext/:extId',
name: 'WasmExtensionRoot',
component: window.WasmExtensionComponent
},
{
path: '/ext/:extId/:pathMatch(.*)*',
name: 'WasmExtension',
component: window.WasmExtensionComponent
},
{
path: '/:pathMatch(.*)*',
name: 'DynamicComponent',
+257 -29
View File
@@ -164,6 +164,7 @@ window.PageExtensions = {
)
extension.isAvailable = true
extension.isInstalled = true
extension.icon = response.data.icon || extension.icon
extension.installedRelease = release
this.toggleExtension(extension)
extension.inProgress = false
@@ -723,6 +724,262 @@ window.PageExtensions = {
resolve(grantedPermissions)
}
},
permissionGrantHasHighRisk() {
return this.permissionGrantDisplayItems().some(
permission => permission.risk.level === 'high'
)
},
permissionGrantDisplayItems() {
const permissions = this.permissionGrant.permissions || []
const permissionsById = new Map(
permissions.map(permission => [permission.id, permission])
)
const hasReadWriteStorage =
permissionsById.has('ext.storage.read') &&
permissionsById.has('ext.storage.write')
let addedReadWriteStorage = false
return permissions
.map((permission, index) => {
if (
hasReadWriteStorage &&
['ext.storage.read', 'ext.storage.write'].includes(permission.id)
) {
if (addedReadWriteStorage) return null
addedReadWriteStorage = true
return {
index,
orderId: 'ext.storage.read',
permissions: [
permissionsById.get('ext.storage.read'),
permissionsById.get('ext.storage.write')
]
}
}
return {
index,
orderId: permission.id,
permissions: [permission]
}
})
.filter(Boolean)
.sort((left, right) => {
const leftOrder = this.permissionOrderIndex(left.orderId)
const rightOrder = this.permissionOrderIndex(right.orderId)
return leftOrder === rightOrder
? left.index - right.index
: leftOrder - rightOrder
})
.map(group => this.permissionDisplayItem(group.permissions))
},
permissionDisplayItem(permissions) {
const permission = permissions[0]
const isReadWriteStorage =
permissions.length === 2 &&
permissions.some(permission => permission.id === 'ext.storage.read') &&
permissions.some(permission => permission.id === 'ext.storage.write')
const descriptions = permissions
.map(permission => this.permissionManifestDescription(permission))
.filter(Boolean)
const item = {
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
label: isReadWriteStorage
? this.$t('extension_permission_ext_storage_read_write')
: this.permissionLabel(permission),
risk: this.permissionRisk(permissions),
badges: [],
descriptions,
fieldGroups: [],
invoicePolicies: [],
extensionAccess: [],
httpHosts: []
}
if (permission.id === 'ext.storage.read_public') {
item.fieldGroups = this.publicStorageFieldGroups(permission)
item.badges = item.fieldGroups.map(group => ({
key: group.table,
label: group.table
}))
}
if (permission.id === 'extension.api.request') {
item.extensionAccess = this.extensionApiPermissionTargets(permission)
item.badges = item.extensionAccess.map(target => ({
key: target.id,
label: target.name
}))
}
if (permission.id === 'http.request') {
item.httpHosts = this.httpRequestPermissionHosts(permission)
}
if (permission.id === 'wallet.create_invoice_public') {
item.invoicePolicies = this.publicInvoicePolicies(permission)
}
return item
},
permissionRisk(permissions) {
const risks = permissions.map(permission =>
this.permissionRiskForPermission(permission)
)
const highestRisk = risks.find(risk => risk.level === 'high')
if (highestRisk) return highestRisk
return risks.find(risk => risk.level === 'medium') || this.lowRisk()
},
permissionRiskForPermission(permission) {
if (permission.id === 'wallet.pay_invoice') {
return this.highRisk('extension_permission_warning_wallet_pay_invoice')
}
if (permission.id === 'extension.api.request') {
const hasWriteAccess = this.extensionApiPermissionTargets(
permission
).some(target => target.access.includes('write'))
return hasWriteAccess
? this.highRisk(
'extension_permission_warning_extension_api_request_write'
)
: this.mediumRisk()
}
if (permission.id === 'http.request') {
return this.mediumRisk()
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.read_public',
'payments.watch'
].includes(permission.id)
) {
return this.mediumRisk()
}
return this.lowRisk()
},
lowRisk() {
return {
level: 'low',
color: 'grey-6',
label: this.$t('extension_permission_risk_low'),
warning: ''
}
},
mediumRisk() {
return {
level: 'medium',
color: 'warning',
label: this.$t('extension_permission_risk_medium'),
warning: ''
}
},
highRisk(warningKey) {
return {
level: 'high',
color: 'negative',
label: this.$t('extension_permission_risk_high'),
warning: this.$t(warningKey)
}
},
permissionOrderIndex(permissionId) {
const order = [
'wallet.pay_invoice',
'wallet.list',
'wallet.balance.read',
'extension.api.request',
'http.request',
'ui.camera.scan_qr',
'ext.storage.read',
'ext.storage.write',
'ext.storage.read_public',
'wallet.create_invoice_public',
'wallet.create_invoice',
'utils.basic'
]
const index = order.indexOf(permissionId)
return index === -1 ? order.length : index
},
publicStorageFieldGroups(permission) {
const tables = permission.policies
if (!Array.isArray(tables)) return []
return tables
.map(table => {
const tableName =
typeof table === 'string' ? table : table?.table_name || ''
const fields =
typeof table === 'string' || !Array.isArray(table?.public_fields)
? []
: table.public_fields.filter(
field => typeof field === 'string' && field
)
return tableName ? {table: tableName, fields} : null
})
.filter(Boolean)
},
httpRequestPermissionHosts(permission) {
const hosts = permission.policies
if (!Array.isArray(hosts)) return []
return hosts
.map(host => (typeof host === 'string' ? host : host?.host || ''))
.filter(host => typeof host === 'string' && host)
},
publicInvoicePolicies(permission) {
const policies = permission.policies
if (!Array.isArray(policies)) return []
return policies
.map(policy => {
if (!policy || typeof policy !== 'object') return null
const table = policy.table
const walletField = policy.wallet_field
if (typeof table !== 'string' || !table) return null
if (typeof walletField !== 'string' || !walletField) return null
return {table, walletField}
})
.filter(Boolean)
},
publicInvoicePolicySentence(policy) {
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
},
extensionApiPermissionTargets(permission) {
const extensions = permission.policies
if (!Array.isArray(extensions)) return []
return extensions
.map(extension => {
const extensionId =
typeof extension === 'string' ? extension : extension?.id
if (!extensionId) return null
const access =
typeof extension === 'string'
? ['read']
: Array.isArray(extension.access) && extension.access.length
? extension.access
: ['read']
return {
id: extensionId,
name: this.extensionDisplayName(extensionId),
access
}
})
.filter(Boolean)
},
extensionDisplayName(extensionId) {
const extension = (this.extensions || []).find(
extension => extension.id === extensionId
)
return extension?.name || extensionId
},
permissionAccessLabel(access) {
const key = `extension_permission_access_${access}`
const label = this.$t(key)
return label === key ? access : label
},
permissionManifestDescription(permission) {
return typeof permission.description === 'string'
? permission.description
: ''
},
permissionI18nKey(permission) {
return `extension_permission_${permission.id.replace(/[^A-Za-z0-9]/g, '_')}`
},
@@ -731,35 +988,6 @@ window.PageExtensions = {
const label = this.$t(key)
return label === key ? permission.id : label
},
permissionDescription(permission) {
const key = `${this.permissionI18nKey(permission)}_desc`
const description = this.$t(key)
return description === key ? permission.description : description
},
permissionPolicyDetails(permission) {
if (permission.id === 'http.request') {
const hosts = permission.policy?.hosts
if (!Array.isArray(hosts) || !hosts.length) return ''
return `${this.$t('extension_permission_http_request_hosts')}: ${hosts.join(', ')}`
}
if (permission.id === 'extension.api.request') {
const extensions = permission.policy?.extensions
if (!Array.isArray(extensions) || !extensions.length) return ''
const targets = extensions
.map(extension => {
if (typeof extension === 'string') return `${extension} (read)`
if (!extension?.id) return null
const access = Array.isArray(extension.access)
? extension.access.join(', ')
: 'read'
return `${extension.id} (${access})`
})
.filter(Boolean)
if (!targets.length) return ''
return `${this.$t('extension_permission_extension_api_request_extensions')}: ${targets.join(', ')}`
}
return ''
},
async selectAllUpdatableExtensionss() {
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
},
@@ -0,0 +1,566 @@
window.WasmExtensionComponent = {
template: `
<div class="wasm-extension-page relative-position">
<q-inner-loading :showing="loading && !frameUrl">
<q-spinner-dots size="40px"></q-spinner-dots>
</q-inner-loading>
<q-banner v-if="error" class="q-ma-md bg-negative text-white">
{{ error }}
</q-banner>
<iframe
v-else-if="frameUrl"
ref="frame"
:key="frameUrl"
class="wasm-extension-frame"
:src="frameUrl"
:title="extensionName || 'Extension'"
sandbox="allow-scripts"
allow="clipboard-write"
referrerpolicy="no-referrer"
></iframe>
<q-dialog v-model="cameraPrompt.show" persistent>
<q-card style="width: min(520px, calc(100vw - 32px)); max-width: 520px">
<q-card-section>
<div class="text-h6">Camera access</div>
</q-card-section>
<q-card-section class="q-pt-none">
{{ cameraPrompt.extensionName }} wants to access the camera to scan a QR code.
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
color="negative"
label="Deny"
@click="resolveCameraPrompt('deny')"
></q-btn>
<q-btn
flat
color="primary"
label="Allow"
@click="resolveCameraPrompt('allow')"
></q-btn>
<q-btn
unelevated
color="primary"
label="Allow and Remember"
@click="resolveCameraPrompt('allow_remember')"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</div>
`,
data() {
return {
allowedPaymentHashes: new Set(),
bridge: {
apiRoutes: [],
extensionId: '',
permissions: [],
public: false,
query: {},
routeParams: {}
},
bridgePort: null,
cameraPrompt: {
extensionName: '',
reject: null,
resolve: null,
show: false
},
error: '',
extensionName: '',
frameUrl: '',
handleWindowMessage: null,
loading: false,
loadId: 0,
paymentSubscriptions: new Map()
}
},
created() {
this.handleWindowMessage = event => this.onWindowMessage(event)
window.addEventListener('message', this.handleWindowMessage)
},
unmounted() {
window.removeEventListener('message', this.handleWindowMessage)
this.rejectCameraPrompt('Camera scan cancelled.')
this.closeBridgePort()
},
watch: {
'$route.fullPath': {
immediate: true,
handler() {
this.loadFrameConfig()
}
}
},
methods: {
emptyBridge() {
return {
apiRoutes: [],
extensionId: '',
permissions: [],
public: false,
query: {},
routeParams: {}
}
},
plainBridgeContext() {
return {
extensionId: String(this.bridge.extensionId || ''),
public: Boolean(this.bridge.public),
routeParams: this.plainValue(this.bridge.routeParams || {}),
query: this.plainValue(this.bridge.query || {})
}
},
hasBridgePermission(permission) {
return (this.bridge.permissions || []).includes(permission)
},
cameraPromptStorageKey() {
return `lnbits.ext.permissions.${this.bridge.extensionId}.ui.camera.scan_qr`
},
emptyCameraPrompt() {
return {
extensionName: '',
reject: null,
resolve: null,
show: false
}
},
plainValue(value) {
try {
return JSON.parse(JSON.stringify(value))
} catch (_error) {
return {}
}
},
async loadFrameConfig() {
const extId = String(this.$route.params.extId || '')
const loadId = ++this.loadId
this.loading = true
this.error = ''
this.frameUrl = ''
this.bridge = this.emptyBridge()
this.allowedPaymentHashes.clear()
this.rejectCameraPrompt('Camera scan cancelled.')
this.closeBridgePort()
try {
const response = await fetch(
`/api/v1/ext/${encodeURIComponent(extId)}/_ui/frame`,
{
method: 'POST',
headers: {'content-type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify({
path: this.$route.path,
query: this.$route.query || {}
})
}
)
const text = await response.text()
let data = {}
if (text) {
try {
data = JSON.parse(text)
} catch (_error) {
data = {detail: text}
}
}
if (!response.ok) {
throw new Error(data?.detail || 'Failed to load extension page.')
}
if (loadId !== this.loadId) return
this.bridge = data.bridge || this.emptyBridge()
this.extensionName = data.extension?.name || extId
this.frameUrl = data.frameUrl
} catch (error) {
if (loadId !== this.loadId) return
console.error('[lnbits wasm extension] Failed to load frame.', error)
this.error = error instanceof Error ? error.message : String(error)
} finally {
if (loadId === this.loadId) {
this.loading = false
}
}
},
extensionFrameWindow() {
return this.$refs.frame?.contentWindow
},
sendResponse(reply, id, payload) {
reply({
type: 'lnbits-extension:response',
id,
...payload
})
},
allowedApiRoute(method, path) {
let url
try {
url = new URL(path, window.location.origin)
} catch (_error) {
return false
}
if (url.origin !== window.location.origin) return false
method = String(method || 'GET').toUpperCase()
return (this.bridge.apiRoutes || []).some(route => {
return (
route.method === method &&
new RegExp(route.pattern).test(url.pathname)
)
})
},
async callApi(message) {
const method = String(message.method || 'GET').toUpperCase()
const path = String(message.path || '')
if (!this.allowedApiRoute(method, path)) {
throw new Error('Extension API route is not allowed.')
}
const options = {
method,
headers: {},
credentials: 'same-origin'
}
if (message.body !== undefined && message.body !== null) {
options.headers['content-type'] = 'application/json'
options.body = JSON.stringify(message.body)
}
const response = await fetch(path, options)
const text = await response.text()
let data = text
if (text) {
try {
data = JSON.parse(text)
} catch (_error) {
data = text
}
}
if (!response.ok) {
throw new Error(
typeof data === 'object' && data.detail ? data.detail : text
)
}
this.rememberPaymentHashes(data)
return data
},
notify(message) {
const level = ['positive', 'negative', 'warning', 'info'].includes(
message.level
)
? message.level
: 'info'
if (window.Quasar?.Notify) {
window.Quasar.Notify.create({
color: level,
message: String(message.message || '')
})
}
},
async scanQrCode() {
if (!this.hasBridgePermission('ui.camera.scan_qr')) {
throw new Error('Extension is missing scanner permission.')
}
if (!this.g) {
throw new Error('LNbits scanner is not available.')
}
if (this.g.scanner) {
throw new Error('A scanner is already active.')
}
await this.requireCameraScanApproval()
if (this.g.scanner) {
throw new Error('A scanner is already active.')
}
return new Promise((resolve, reject) => {
let completed = false
const cleanup = () => {
window.clearTimeout(timeout)
window.clearInterval(cancelPoll)
if (this.g.scanner === onScan) {
this.g.scanner = null
}
}
const complete = callback => value => {
if (completed) return
completed = true
cleanup()
callback(value)
}
const onScan = value => {
complete(resolve)({value: String(value || '')})
}
const timeout = window.setTimeout(() => {
complete(reject)(new Error('QR scan timed out.'))
}, 120000)
const cancelPoll = window.setInterval(() => {
if (!completed && this.g.scanner !== onScan) {
complete(reject)(new Error('QR scan cancelled.'))
}
}, 250)
this.g.scanner = onScan
})
},
requireCameraScanApproval() {
if (this.isCameraScanRemembered()) return Promise.resolve()
if (this.cameraPrompt.show) {
return Promise.reject(
new Error('Camera access prompt is already open.')
)
}
return new Promise((resolve, reject) => {
this.cameraPrompt = {
extensionName:
this.extensionName || this.bridge.extensionId || 'This extension',
reject,
resolve,
show: true
}
})
},
isCameraScanRemembered() {
try {
return (
this.$q.localStorage.getItem(this.cameraPromptStorageKey()) ===
'allow'
)
} catch (_error) {
return false
}
},
rememberCameraScanApproval() {
try {
this.$q.localStorage.set(this.cameraPromptStorageKey(), 'allow')
} catch (_error) {}
},
resolveCameraPrompt(decision) {
const resolve = this.cameraPrompt.resolve
const reject = this.cameraPrompt.reject
this.cameraPrompt = this.emptyCameraPrompt()
if (decision === 'allow_remember') {
this.rememberCameraScanApproval()
resolve?.()
return
}
if (decision === 'allow') {
resolve?.()
return
}
reject?.(new Error('Camera scan denied by user.'))
},
rejectCameraPrompt(message) {
const reject = this.cameraPrompt.reject
this.cameraPrompt = this.emptyCameraPrompt()
reject?.(new Error(message))
},
rememberPaymentHashes(value) {
if (!value || typeof value !== 'object') return
if (Array.isArray(value)) {
value.forEach(item => this.rememberPaymentHashes(item))
return
}
for (const [key, item] of Object.entries(value)) {
if (
['paymentHash', 'payment_hash'].includes(key) &&
this.isPaymentHash(item)
) {
this.allowedPaymentHashes.add(item)
}
this.rememberPaymentHashes(item)
}
},
isPaymentHash(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
},
websocketUrl(path) {
const url = new URL(window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = path
url.search = ''
url.hash = ''
return url.toString()
},
sendBridgeEvent(message) {
if (!this.bridgePort) return
this.bridgePort.postMessage({
type: 'lnbits-extension:event',
...message
})
},
closePaymentSubscription(subscriptionId) {
const subscription = this.paymentSubscriptions.get(subscriptionId)
if (!subscription) return
this.paymentSubscriptions.delete(subscriptionId)
try {
subscription.socket.close()
} catch (_error) {}
},
closePaymentSubscriptions() {
for (const subscriptionId of Array.from(
this.paymentSubscriptions.keys()
)) {
this.closePaymentSubscription(subscriptionId)
}
},
closeBridgePort() {
this.closePaymentSubscriptions()
this.bridgePort?.close()
this.bridgePort = null
},
subscribePayment(message) {
const subscriptionId = String(message.subscriptionId || '')
const paymentHash = String(message.paymentHash || '')
if (!subscriptionId || !this.isPaymentHash(paymentHash)) {
throw new Error('Invalid payment subscription.')
}
if (!this.allowedPaymentHashes.has(paymentHash)) {
throw new Error('Payment subscription is not allowed.')
}
this.closePaymentSubscription(subscriptionId)
const socket = new WebSocket(
this.websocketUrl(`/api/v1/ws/${encodeURIComponent(paymentHash)}`)
)
this.paymentSubscriptions.set(subscriptionId, {paymentHash, socket})
socket.addEventListener('message', event => {
let data = event.data
try {
data = JSON.parse(event.data)
} catch (_error) {}
this.sendBridgeEvent({
event: 'payment.update',
subscriptionId,
paymentHash,
data
})
if (
data &&
typeof data === 'object' &&
(data.pending === false ||
['success', 'settled', 'paid'].includes(String(data.status || '')))
) {
this.sendBridgeEvent({
event: 'payment.settled',
subscriptionId,
paymentHash,
data
})
this.closePaymentSubscription(subscriptionId)
}
})
socket.addEventListener('error', () => {
this.sendBridgeEvent({
event: 'payment.error',
subscriptionId,
paymentHash
})
this.closePaymentSubscription(subscriptionId)
})
socket.addEventListener('close', () => {
this.paymentSubscriptions.delete(subscriptionId)
})
},
async handleBridgeRequest(message, reply) {
if (!message || message.type !== 'lnbits-extension:request') return
try {
if (message.action === 'context') {
this.sendResponse(reply, message.id, {
ok: true,
data: this.plainBridgeContext()
})
return
}
if (message.action === 'api') {
this.sendResponse(reply, message.id, {
ok: true,
data: await this.callApi(message)
})
return
}
if (message.action === 'ui.notify') {
this.notify(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'ui.scan_qr') {
this.sendResponse(reply, message.id, {
ok: true,
data: await this.scanQrCode()
})
return
}
if (message.action === 'payment.subscribe') {
this.subscribePayment(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'payment.unsubscribe') {
this.closePaymentSubscription(String(message.subscriptionId || ''))
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
throw new Error('Unknown extension bridge action.')
} catch (error) {
this.sendResponse(reply, message.id, {
ok: false,
error: error instanceof Error ? error.message : String(error)
})
}
},
onWindowMessage(event) {
if (event.source !== this.extensionFrameWindow()) return
const message = event.data
if (!message || message.type !== 'lnbits-extension:connect') return
const port = event.ports?.[0]
if (!port) return
this.closeBridgePort()
this.bridgePort = port
this.bridgePort.addEventListener('message', portEvent => {
this.handleBridgeRequest(portEvent.data, response => {
port.postMessage(response)
})
})
this.bridgePort.start()
this.bridgePort.postMessage({
type: 'lnbits-extension:connected',
id: message.id
})
}
}
}
+1
View File
@@ -96,6 +96,7 @@
"js/components/extension-settings.js",
"js/components/data-fields.js",
"js/components.js",
"js/wasm-extension-component.js",
"js/init-app.js"
],
"css": ["vendor/quasar.css", "css/base.css"]
+17 -3
View File
@@ -16,6 +16,18 @@
src: url("{{ static_url_for('static', 'fonts/material-icons-v50.woff2') }}")
format('woff2');
}
.wasm-extension-page,
.wasm-extension-frame {
height: calc(100vh - 56px);
min-height: calc(100vh - 56px);
width: 100%;
}
.wasm-extension-frame {
border: 0;
display: block;
}
</style>
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
<meta charset="utf-8" />
@@ -44,12 +56,14 @@
<lnbits-drawer v-if="g.user && !g.isPublicPage"></lnbits-drawer>
{% block page_container %}
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
<q-page
:class="$route.path.startsWith('/ext/') ? 'q-pa-none' : ['q-px-md', 'q-py-lg', {'q-px-lg': $q.screen.gt.xs}]"
>
<lnbits-wallet-new
v-if="g.user && !g.isPublicPage"
v-if="g.user && !g.isPublicPage && !$route.path.startsWith('/ext/')"
></lnbits-wallet-new>
<lnbits-header-wallets
v-if="g.user && !g.isPublicPage"
v-if="g.user && !g.isPublicPage && !$route.path.startsWith('/ext/')"
></lnbits-header-wallets>
<!-- block page content from static extensions -->
<div
+112 -19
View File
@@ -460,36 +460,129 @@
position="top"
@hide="onManageExtensionDialogHide"
>
<q-card v-if="permissionGrant.show" class="q-pa-lg lnbits__dialog-card">
<q-card v-if="permissionGrant.show" class="q-pa-md lnbits__dialog-card">
<q-card-section>
<div class="text-h6" v-text="$t('extension_permissions_title')"></div>
<div
class="text-body2 q-mt-sm"
v-text="$t('extension_permissions_request')"
></div>
<q-banner
v-if="permissionGrantHasHighRisk()"
dense
class="bg-red-1 text-red-10 q-mt-md"
>
<template v-slot:avatar>
<q-icon name="warning" color="negative"></q-icon>
</template>
<span v-text="$t('extension_permissions_high_risk_warning')"></span>
</q-banner>
</q-card-section>
<q-list bordered separator class="q-mt-md">
<q-item
v-for="permission of permissionGrant.permissions"
<q-expansion-item
v-for="permission of permissionGrantDisplayItems()"
:key="permission.id"
dense
expand-separator
class="q-pt-xs"
>
<q-item-section>
<q-item-label>
<li><strong v-text="permissionLabel(permission)"></strong></li>
</q-item-label>
<q-item-label
v-if="permissionDescription(permission)"
caption
v-text="permissionDescription(permission)"
></q-item-label>
<q-item-label
v-if="permissionPolicyDetails(permission)"
caption
v-text="permissionPolicyDetails(permission)"
></q-item-label>
</q-item-section>
</q-item>
<template v-slot:header>
<q-item-section>
<q-item-label class="text-weight-medium">
<span v-text="permission.label"></span>
</q-item-label>
</q-item-section>
<q-item-section
v-if="permission.risk.level !== 'low' || permission.badges.length"
side
top
>
<div class="row items-center justify-end q-gutter-xs">
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
</div>
</q-item-section>
</template>
<div class="q-px-md q-pb-sm">
<div
v-if="permission.risk.warning"
class="row items-center text-negative text-caption q-mb-xs"
>
<q-icon name="warning" size="16px" class="q-mr-xs"></q-icon>
<span v-text="permission.risk.warning"></span>
</div>
<p
v-for="description of permission.descriptions"
:key="description"
class="text-caption q-mb-xs"
v-text="description"
></p>
<p
v-for="policy of permission.invoicePolicies"
:key="policy.table + ':' + policy.walletField"
class="text-caption q-mb-xs"
v-text="publicInvoicePolicySentence(policy)"
></p>
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
<li v-for="group of permission.fieldGroups" :key="group.table">
<span v-text="group.table"></span>
<ul v-if="group.fields.length" class="q-pl-md">
<li
v-for="field of group.fields"
:key="group.table + ':' + field"
v-text="field"
></li>
</ul>
</li>
</ul>
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="
$t('extension_permission_extension_api_request_extensions')
"
></div>
<div
v-for="target of permission.extensionAccess"
:key="target.id"
class="row items-center q-gutter-xs q-mt-xs"
>
<span class="text-caption" v-text="target.name"></span>
<q-badge
v-for="access of target.access"
:key="target.id + access"
color="grey-7"
v-text="permissionAccessLabel(access)"
></q-badge>
</div>
</div>
<div v-if="permission.httpHosts.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="$t('extension_permission_http_request_hosts')"
></div>
<ul class="q-my-sm q-pl-md">
<li
v-for="host of permission.httpHosts"
:key="host"
v-text="host"
></li>
</ul>
</div>
</div>
</q-expansion-item>
</q-list>
<div class="row q-mt-lg">
+1 -351
View File
@@ -1,351 +1 @@
{% extends "base.html" %} {% block styles %}
<style>
.wasm-extension-frame {
border: 0;
display: block;
height: calc(100vh - 56px);
min-height: calc(100vh - 56px);
width: 100%;
}
</style>
{% endblock %} {% block page_container %}
<q-page-container>
<!-- # todo:revisit this -->
<q-page
v-if="$route.path.startsWith('{{ normalize_path(request.path) }}')"
class="q-pa-none"
>
<iframe
id="lnbits-wasm-extension-frame"
class="wasm-extension-frame"
data-frame-url="{{ frame_url }}"
title="{{ extension.name }}"
sandbox="allow-scripts"
allow="clipboard-write"
referrerpolicy="no-referrer"
></iframe>
</q-page>
<q-page v-else class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
<lnbits-wallet-new v-if="g.user && !g.isPublicPage"></lnbits-wallet-new>
<lnbits-header-wallets
v-if="g.user && !g.isPublicPage"
></lnbits-header-wallets>
<router-view :key="$route.path"></router-view>
</q-page>
</q-page-container>
{% endblock %} {% block scripts %}
<script>
;(() => {
const bridge = {{ bridge | tojson | safe }}
let bridgePort = null
const allowedPaymentHashes = new Set()
const paymentSubscriptions = new Map()
function extensionFrameWindow() {
return extensionFrame()?.contentWindow
}
function extensionFrame() {
return document.getElementById('lnbits-wasm-extension-frame')
}
function loadExtensionFrame() {
const frame = extensionFrame()
if (!frame) {
closeBridgePort()
return
}
if (frame.dataset.loaded === 'true') return
frame.dataset.loaded = 'true'
frame.src = frame.dataset.frameUrl
}
function startExtensionFrameLoader() {
loadExtensionFrame()
window.router?.afterEach(() => {
window.setTimeout(loadExtensionFrame)
})
}
function sendResponse(reply, id, payload) {
reply({
type: 'lnbits-extension:response',
id,
...payload
})
}
function allowedApiRoute(method, path) {
let url
try {
url = new URL(path, window.location.origin)
} catch (_error) {
return false
}
if (url.origin !== window.location.origin) return false
method = String(method || 'GET').toUpperCase()
return bridge.apiRoutes.some(route => {
return (
route.method === method &&
new RegExp(route.pattern).test(url.pathname)
)
})
}
async function callApi(message) {
const method = String(message.method || 'GET').toUpperCase()
const path = String(message.path || '')
if (!allowedApiRoute(method, path)) {
throw new Error('Extension API route is not allowed.')
}
const options = {
method,
headers: {},
credentials: 'same-origin'
}
if (message.body !== undefined && message.body !== null) {
options.headers['content-type'] = 'application/json'
options.body = JSON.stringify(message.body)
}
const response = await fetch(path, options)
const text = await response.text()
let data = text
if (text) {
try {
data = JSON.parse(text)
} catch (_error) {
data = text
}
}
if (!response.ok) {
throw new Error(
typeof data === 'object' && data.detail ? data.detail : text
)
}
rememberPaymentHashes(data)
return data
}
function notify(message) {
const level = ['positive', 'negative', 'warning', 'info'].includes(
message.level
)
? message.level
: 'info'
if (window.Quasar?.Notify) {
window.Quasar.Notify.create({
color: level,
message: String(message.message || '')
})
}
}
function rememberPaymentHashes(value) {
if (!value || typeof value !== 'object') return
if (Array.isArray(value)) {
value.forEach(rememberPaymentHashes)
return
}
for (const [key, item] of Object.entries(value)) {
if (
['paymentHash', 'payment_hash'].includes(key) &&
isPaymentHash(item)
) {
allowedPaymentHashes.add(item)
}
rememberPaymentHashes(item)
}
}
function isPaymentHash(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
}
function websocketUrl(path) {
const url = new URL(window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = path
url.search = ''
url.hash = ''
return url.toString()
}
function sendBridgeEvent(message) {
if (!bridgePort) return
bridgePort.postMessage({
type: 'lnbits-extension:event',
...message
})
}
function closePaymentSubscription(subscriptionId) {
const subscription = paymentSubscriptions.get(subscriptionId)
if (!subscription) return
paymentSubscriptions.delete(subscriptionId)
try {
subscription.socket.close()
} catch (_error) {}
}
function closePaymentSubscriptions() {
for (const subscriptionId of Array.from(paymentSubscriptions.keys())) {
closePaymentSubscription(subscriptionId)
}
}
function closeBridgePort() {
closePaymentSubscriptions()
bridgePort?.close()
bridgePort = null
}
function subscribePayment(message) {
const subscriptionId = String(message.subscriptionId || '')
const paymentHash = String(message.paymentHash || '')
if (!subscriptionId || !isPaymentHash(paymentHash)) {
throw new Error('Invalid payment subscription.')
}
if (!allowedPaymentHashes.has(paymentHash)) {
throw new Error('Payment subscription is not allowed.')
}
closePaymentSubscription(subscriptionId)
const socket = new WebSocket(
websocketUrl(`/api/v1/ws/${encodeURIComponent(paymentHash)}`)
)
paymentSubscriptions.set(subscriptionId, {paymentHash, socket})
socket.addEventListener('message', event => {
let data = event.data
try {
data = JSON.parse(event.data)
} catch (_error) {}
sendBridgeEvent({
event: 'payment.update',
subscriptionId,
paymentHash,
data
})
if (
data &&
typeof data === 'object' &&
(data.pending === false ||
['success', 'settled', 'paid'].includes(String(data.status || '')))
) {
sendBridgeEvent({
event: 'payment.settled',
subscriptionId,
paymentHash,
data
})
closePaymentSubscription(subscriptionId)
}
})
socket.addEventListener('error', () => {
sendBridgeEvent({
event: 'payment.error',
subscriptionId,
paymentHash
})
closePaymentSubscription(subscriptionId)
})
socket.addEventListener('close', () => {
paymentSubscriptions.delete(subscriptionId)
})
}
async function handleBridgeRequest(message, reply) {
if (!message || message.type !== 'lnbits-extension:request') return
try {
if (message.action === 'context') {
sendResponse(reply, message.id, {
ok: true,
data: {
extensionId: bridge.extensionId,
public: bridge.public,
routeParams: bridge.routeParams,
query: bridge.query
}
})
return
}
if (message.action === 'api') {
sendResponse(reply, message.id, {
ok: true,
data: await callApi(message)
})
return
}
if (message.action === 'ui.notify') {
notify(message)
sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'payment.subscribe') {
subscribePayment(message)
sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'payment.unsubscribe') {
closePaymentSubscription(String(message.subscriptionId || ''))
sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
throw new Error('Unknown extension bridge action.')
} catch (error) {
sendResponse(reply, message.id, {
ok: false,
error: error instanceof Error ? error.message : String(error)
})
}
}
window.addEventListener('message', event => {
if (event.source !== extensionFrameWindow()) return
const message = event.data
if (!message || message.type !== 'lnbits-extension:connect') return
const port = event.ports?.[0]
if (!port) return
closeBridgePort()
bridgePort = port
bridgePort.addEventListener('message', portEvent => {
handleBridgeRequest(portEvent.data, response => {
port.postMessage(response)
})
})
bridgePort.start()
bridgePort.postMessage({
type: 'lnbits-extension:connected',
id: message.id
})
})
window.addEventListener('load', startExtensionFrameLoader)
})()
</script>
{% endblock %}
{% extends "base.html" %}
+1
View File
@@ -149,6 +149,7 @@
"js/components/extension-settings.js",
"js/components/data-fields.js",
"js/components.js",
"js/wasm-extension-component.js",
"js/init-app.js"
],
"css": [
Generated
+109 -2
View File
@@ -1633,7 +1633,7 @@ version = "3.3.2"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.10"
groups = ["main"]
groups = ["main", "dev"]
files = [
{file = "greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d"},
{file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13"},
@@ -2972,6 +2972,28 @@ files = [
{file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"},
]
[[package]]
name = "playwright"
version = "1.61.0"
description = "A high-level API to automate web browsers"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0"},
{file = "playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a"},
{file = "playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af"},
{file = "playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e"},
{file = "playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c"},
{file = "playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b"},
{file = "playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597"},
{file = "playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51"},
]
[package.dependencies]
greenlet = ">=3.1.1,<4.0.0"
pyee = ">=13,<14"
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -3386,6 +3408,24 @@ typing-extensions = ">=4.2.0"
dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
[[package]]
name = "pyee"
version = "13.0.1"
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
{file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
]
[package.dependencies]
typing-extensions = "*"
[package.extras]
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -3627,6 +3667,25 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-base-url"
version = "2.1.0"
description = "pytest plugin for URL based testing"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6"},
{file = "pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45"},
]
[package.dependencies]
pytest = ">=7.0.0"
requests = ">=2.9"
[package.extras]
test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-localserver (>=0.7.1)", "tox (>=3.24.5)"]
[[package]]
name = "pytest-cov"
version = "7.0.0"
@@ -3695,6 +3754,24 @@ pytest = ">=6.2.5"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
[[package]]
name = "pytest-playwright"
version = "0.8.0"
description = "A pytest wrapper with fixtures for Playwright to automate web browsers"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933"},
{file = "pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981"},
]
[package.dependencies]
playwright = ">=1.18"
pytest = ">=6.2.4,<10.0.0"
pytest-base-url = ">=1.0.0,<3.0.0"
python-slugify = ">=6.0.0,<9.0.0"
[[package]]
name = "python-crontab"
version = "3.3.0"
@@ -3758,6 +3835,24 @@ files = [
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
]
[[package]]
name = "python-slugify"
version = "8.0.4"
description = "A Python slugify application that also handles Unicode"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"},
{file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"},
]
[package.dependencies]
text-unidecode = ">=1.3"
[package.extras]
unidecode = ["Unidecode (>=1.1.1)"]
[[package]]
name = "pytokens"
version = "0.4.1"
@@ -4350,6 +4445,18 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
[[package]]
name = "text-unidecode"
version = "1.3"
description = "The most basic Text::Unidecode port"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
]
[[package]]
name = "tibs"
version = "0.5.7"
@@ -5148,4 +5255,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "3097673d0cd279b0bf2b8fa59c1e523273f63d430f2c68ccc268a3cf232068af"
content-hash = "09f0ddc9546d2ea7e6fb686200e3e15ab7e8db0f1ac33d49c94afe1cbd701fe8"
+1
View File
@@ -86,6 +86,7 @@ dev = [
"types-mock~=5.2.0.20250924",
"mock~=5.2.0",
"grpcio-tools~=1.76.0",
"pytest-playwright>=0.8.0",
]
[tool.uv]
+170
View File
@@ -0,0 +1,170 @@
{
"id": "lnbits-wasm-test-extension",
"name": "WASM Test Extension",
"short_description": "Minimal WASM extension used by LNbits e2e tests.",
"version": "0.0.1",
"min_lnbits_version": "1.5.5",
"extension_type": "wasm",
"wasm": {
"module": "wasm/module.wasm",
"exports": [
{
"name": "list-wallets",
"visibility": "authenticated"
},
{
"name": "invoice-details",
"visibility": "authenticated"
},
{
"name": "get-selection",
"visibility": "authenticated"
},
{
"name": "save-selection",
"visibility": "authenticated"
},
{
"name": "pay-large-invoice",
"visibility": "authenticated"
},
{
"name": "create-tip-jar",
"visibility": "authenticated"
},
{
"name": "list-tip-jars",
"visibility": "authenticated"
},
{
"name": "get-public-tip-jar",
"visibility": "public"
},
{
"name": "create-tip-invoice",
"visibility": "public"
},
{
"name": "record-payment",
"visibility": "event"
}
]
},
"events": {
"onInvoicePaid": "record-payment"
},
"ui_routes": [
{
"path": "/lnbits-wasm-test-extension",
"entrypoint": "ui/admin.html",
"auth": "user"
},
{
"path": "/lnbits-wasm-test-extension/public/{item_id}",
"entrypoint": "ui/public.html",
"auth": "public",
"path_params": {
"item_id": "itemId"
}
}
],
"api_routes": [
{
"method": "GET",
"path": "/wallets",
"export": "list-wallets",
"auth": "user"
},
{
"method": "POST",
"path": "/payments",
"export": "pay-large-invoice",
"auth": "user"
},
{
"method": "GET",
"path": "/jars/{jar_id}",
"export": "get-public-tip-jar",
"auth": "public",
"path_params": {
"jar_id": "jarId"
}
},
{
"method": "POST",
"path": "/invoice",
"export": "create-tip-invoice",
"auth": "public"
}
],
"permissions": [
{
"id": "wallet.pay_invoice",
"description": "Pay Lightning invoices from selected wallets."
},
{
"id": "wallet.list",
"description": "List wallets available to the installing user."
},
{
"id": "wallet.balance.read",
"description": "Read wallet balances for payment selection."
},
{
"id": "extension.api.request",
"description": "Call APIs exposed by another installed extension.",
"policies": [
{
"id": "watchonly",
"access": ["read", "write"]
}
]
},
{
"id": "ui.camera.scan_qr",
"description": "Use the LNbits scanner to read QR codes when requested."
},
{
"id": "ext.storage.read",
"description": "Read extension storage rows."
},
{
"id": "ext.storage.write",
"description": "Write extension storage rows."
},
{
"id": "ext.storage.read_public",
"description": "Read public extension storage fields.",
"policies": [
{
"table_name": "tip_jars",
"public_fields": [
"id",
"title",
"description",
"currency",
"suggested_amounts"
]
}
]
},
{
"id": "wallet.create_invoice",
"description": "Create incoming Lightning invoices from authenticated pages."
},
{
"id": "wallet.create_invoice_public",
"description": "Create incoming Lightning invoices from public pages.",
"policies": [
{
"table": "tip_jars",
"wallet_field": "wallet_id"
}
]
},
{
"id": "utils.basic",
"description": "Use LNbits utility functions."
}
]
}
+49
View File
@@ -0,0 +1,49 @@
window.__lnbitsWasmTestExtensionLoaded = true
;(function () {
const channel = new MessageChannel()
const pending = new Map()
let counter = 0
const ready = new Promise(resolve => {
channel.port1.addEventListener('message', event => {
const message = event.data || {}
if (message.type === 'lnbits-extension:connected') {
resolve(true)
return
}
if (message.type !== 'lnbits-extension:response') return
const callback = pending.get(message.id)
if (!callback) return
pending.delete(message.id)
callback(message)
})
channel.port1.start()
window.parent.postMessage(
{type: 'lnbits-extension:connect', id: 'wasm-test-extension'},
'*',
[channel.port2]
)
})
window.lnbitsWasmTestBridge = {
ready() {
return ready
},
request(message) {
return ready.then(() => {
return new Promise(resolve => {
const id = `wasm-test-${++counter}`
pending.set(id, resolve)
channel.port1.postMessage({
type: 'lnbits-extension:request',
id,
...message
})
})
})
}
}
})()
@@ -0,0 +1 @@
{"unsafe": true}
@@ -0,0 +1,4 @@
<!doctype html>
<html>
<body>This must not be served as JavaScript.</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WASM Test Extension Admin</title>
<script src="/ext-assets/lnbits-wasm-test-extension/app.js"></script>
</head>
<body>
<main id="wasm-test-admin">WASM Test Admin</main>
</body>
</html>
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WASM Test Extension Public</title>
<script src="/ext-assets/lnbits-wasm-test-extension/app.js"></script>
</head>
<body>
<main id="wasm-test-public">WASM Test Public</main>
</body>
</html>
Binary file not shown.
@@ -0,0 +1,70 @@
from __future__ import annotations
import pytest
from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension
from lnbits.core.wasm_ext.api.permissions import (
validate_extension_permissions,
validate_wasm_extension_permissions,
)
def test_validate_extension_permissions_rejects_unknown_permission() -> None:
permissions = [
ExtensionPermission(id="wallet.list"),
ExtensionPermission(id="unknown.permission"),
]
with pytest.raises(ValueError, match="unknown.permission"):
validate_extension_permissions("demo", permissions)
def test_validate_wasm_extension_permissions_requires_grant() -> None:
extension = InstallableExtension(id="demo", name="Demo", version="0.0.1")
config = {
"extension_type": "wasm",
"permissions": [{"id": "wallet.list"}],
}
with pytest.raises(ValueError, match="requires permission approval"):
validate_wasm_extension_permissions(extension, None, config)
def test_validate_wasm_extension_permissions_requires_exact_grants() -> None:
extension = InstallableExtension(id="demo", name="Demo", version="0.0.1")
config = {
"extension_type": "wasm",
"permissions": [{"id": "wallet.list"}, {"id": "utils.basic"}],
}
with pytest.raises(ValueError, match="was not granted all requested permissions"):
validate_wasm_extension_permissions(
extension,
[ExtensionPermission(id="wallet.list")],
config,
)
def test_validate_wasm_extension_permissions_returns_core_normalized_grants() -> None:
extension = InstallableExtension(id="demo", name="Demo", version="0.0.1")
config = {
"extension_type": "wasm",
"permissions": [
{
"id": "wallet.list",
"label": "Extension supplied label",
"description": "Show wallets.",
}
],
}
permissions = validate_wasm_extension_permissions(
extension,
[ExtensionPermission(id="wallet.list", label="Extension supplied label")],
config,
)
assert len(permissions) == 1
assert permissions[0].id == "wallet.list"
assert permissions[0].label is None
assert permissions[0].description == "Show wallets."
+1
View File
@@ -0,0 +1 @@
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import time
from collections.abc import Iterator
from typing import Any
import httpx
import pytest
from tests.wasm_ext.helpers import (
EXTENSION_ID,
REPO_ROOT,
SERVER_HOST,
LiveLNbitsServer,
)
@pytest.fixture(scope="session")
def browser_name() -> str:
return "chromium"
@pytest.fixture(scope="session")
def lnbits_server(
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[LiveLNbitsServer]:
run_root = tmp_path_factory.mktemp("wasm_ext_e2e")
data_dir = run_root / "data"
extensions_root = run_root / "extensions-root"
extension_target = extensions_root / "extensions" / EXTENSION_ID
fixture_extension = REPO_ROOT / "tests" / "fixtures" / EXTENSION_ID
shutil.copytree(fixture_extension, extension_target)
port = _free_tcp_port()
base_url = f"http://{SERVER_HOST}:{port}"
env = {
**os.environ,
"AUTH_HTTPS_ONLY": "false",
"DEBUG": "true",
"HOST": SERVER_HOST,
"LNBITS_ADMIN_UI": "true",
"LNBITS_BACKEND_WALLET_CLASS": "FakeWallet",
"LNBITS_DATA_FOLDER": str(data_dir),
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": "false",
"LNBITS_EXTENSIONS_PATH": str(extensions_root),
"LNBITS_PATH": str(REPO_ROOT),
"PORT": str(port),
"PYTHONUNBUFFERED": "1",
}
process = subprocess.Popen( # noqa: S603
[
sys.executable,
"-m",
"uvicorn",
"lnbits.__main__:app",
"--host",
SERVER_HOST,
"--port",
str(port),
"--loop",
"asyncio",
"--log-level",
"warning",
],
cwd=REPO_ROOT,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
_wait_for_first_install_page(process, base_url)
auth_cookies = _complete_first_install(base_url)
yield LiveLNbitsServer(base_url=base_url, auth_cookies=auth_cookies)
finally:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
@pytest.fixture
def authenticated_page(page: Any, lnbits_server: LiveLNbitsServer) -> Any:
page.context.add_cookies(lnbits_server.auth_cookies)
return page
def _free_tcp_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((SERVER_HOST, 0))
return int(sock.getsockname()[1])
def _wait_for_first_install_page(
process: subprocess.Popen[str],
base_url: str,
) -> None:
deadline = time.monotonic() + 60
last_error: Exception | None = None
with httpx.Client(follow_redirects=False, timeout=2) as client:
while time.monotonic() < deadline:
if process.poll() is not None:
output = process.stdout.read() if process.stdout else ""
raise RuntimeError(f"LNbits exited before startup:\n{output}")
try:
response = client.get(f"{base_url}/first_install")
if response.status_code == 200:
return
except httpx.HTTPError as exc:
last_error = exc
time.sleep(0.25)
output = process.stdout.read() if process.stdout else ""
raise TimeoutError(f"LNbits did not start: {last_error}\n{output}")
def _complete_first_install(base_url: str) -> list[dict[str, Any]]:
with httpx.Client(base_url=base_url, follow_redirects=False, timeout=10) as client:
response = client.put(
"/api/v1/auth/first_install",
json={
"username": "wasmtest-admin",
"password": "secret1234",
"password_repeat": "secret1234",
},
)
response.raise_for_status()
enable_response = client.put(f"/api/v1/extension/{EXTENSION_ID}/enable")
enable_response.raise_for_status()
return [
{
"name": cookie.name,
"value": cookie.value,
"url": base_url,
"secure": cookie.secure,
"sameSite": "Lax",
}
for cookie in client.cookies.jar
]
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
EXTENSION_ID = "lnbits-wasm-test-extension"
REPO_ROOT = Path(__file__).resolve().parents[2]
SERVER_HOST = "127.0.0.1"
@dataclass(frozen=True)
class LiveLNbitsServer:
base_url: str
auth_cookies: list[dict[str, Any]]
extension_id: str = EXTENSION_ID
+385
View File
@@ -0,0 +1,385 @@
from __future__ import annotations
import json
import re
from re import Pattern
from typing import Any
from playwright.sync_api import Frame, Page, expect
from tests.wasm_ext.helpers import EXTENSION_ID, REPO_ROOT, LiveLNbitsServer
def test_permission_grant_dialog_logic_is_compact_and_explicit(page: Page) -> None:
permissions = _fixture_permissions()
page.goto("about:blank")
page.add_script_tag(path=str(REPO_ROOT / "lnbits/static/js/pages/extensions.js"))
result = page.evaluate(
"""
({permissions, translations}) => {
const methods = window.PageExtensions.methods
const context = {
extensions: [{id: 'watchonly', name: 'Watchonly'}],
permissionGrant: {show: false, permissions: [], resolve: null},
selectedExtension: {isWasm: true},
selectedRelease: null,
showManageExtensionDialog: false,
$t: key => translations[key] || key
}
Object.assign(context, methods)
const release = {extension_type: 'wasm', permissions}
const pendingGrant = context.resolveExtensionPermissionGrant(release)
const opened =
context.permissionGrant.show === true &&
context.showManageExtensionDialog === true
const items = context.permissionGrantDisplayItems()
context.grantExtensionPermissions()
return pendingGrant.then(grantedPermissions => ({
opened,
closed:
context.permissionGrant.show === false &&
context.showManageExtensionDialog === false,
grantedPermissionIds: grantedPermissions.map(permission => permission.id),
items
}))
}
""",
{"permissions": permissions, "translations": _permission_translations()},
)
assert result["opened"] is True
assert result["closed"] is True
assert result["grantedPermissionIds"] == [
permission["id"] for permission in permissions
]
items = result["items"]
assert [item["id"] for item in items] == [
"wallet.pay_invoice",
"wallet.list",
"wallet.balance.read",
"extension.api.request",
"ui.camera.scan_qr",
"ext.storage.read_write",
"ext.storage.read_public",
"wallet.create_invoice_public",
"wallet.create_invoice",
"utils.basic",
]
by_id = {item["id"]: item for item in items}
assert by_id["wallet.pay_invoice"]["risk"]["level"] == "high"
assert by_id["extension.api.request"]["risk"]["level"] == "high"
assert by_id["ext.storage.read_write"]["risk"]["level"] == "low"
assert by_id["wallet.create_invoice"]["risk"]["level"] == "low"
assert by_id["ui.camera.scan_qr"]["risk"]["level"] == "low"
assert by_id["ext.storage.read_write"]["label"] == (
"Read & Write extension storage"
)
assert by_id["ext.storage.read_public"]["badges"] == [
{"key": "tip_jars", "label": "tip_jars"}
]
assert by_id["ext.storage.read_public"]["fieldGroups"] == [
{
"table": "tip_jars",
"fields": [
"id",
"title",
"description",
"currency",
"suggested_amounts",
],
}
]
assert by_id["extension.api.request"]["badges"] == [
{"key": "watchonly", "label": "Watchonly"}
]
assert by_id["extension.api.request"]["extensionAccess"] == [
{"id": "watchonly", "name": "Watchonly", "access": ["read", "write"]}
]
assert by_id["wallet.create_invoice_public"]["invoicePolicies"] == [
{"table": "tip_jars", "walletField": "wallet_id"}
]
def test_public_wasm_page_loads_sandboxed_frame(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
public_url = (
f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}/public/item-123"
"?source=test"
)
frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/1"
with page.expect_response(lambda response: frame_url_part in response.url) as info:
page.goto(public_url)
frame_response = info.value
assert frame_response.status == 200
assert "sandbox allow-scripts" in frame_response.headers["content-security-policy"]
assert frame_response.headers["cache-control"] == "no-store"
assert frame_response.headers["x-content-type-options"] == "nosniff"
frame = page.locator("iframe.wasm-extension-frame")
expect(frame).to_have_attribute("sandbox", "allow-scripts")
expect(frame).to_have_attribute("allow", "clipboard-write")
expect(frame).to_have_attribute("referrerpolicy", "no-referrer")
expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part))
expect(
page.frame_locator("iframe.wasm-extension-frame").locator("body")
).to_contain_text("WASM Test Public")
def test_static_assets_are_strictly_whitelisted(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
assets_base = f"{lnbits_server.base_url}/ext-assets/{lnbits_server.extension_id}"
script = page.request.get(f"{assets_base}/app.js")
assert script.status == 200
assert script.headers["content-type"].startswith("text/javascript")
assert script.headers["x-content-type-options"] == "nosniff"
assert script.headers["cache-control"] == "no-store"
core_script = page.request.get(f"{assets_base}/_lnbits/vue.global.prod.js")
assert core_script.status == 200
assert core_script.headers["content-type"].startswith("text/javascript")
assert core_script.headers["x-content-type-options"] == "nosniff"
unsupported_extension = page.request.get(f"{assets_base}/data.json")
assert unsupported_extension.status == 404
html_like_javascript = page.request.get(f"{assets_base}/html-like.js")
assert html_like_javascript.status == 404
def test_frame_config_exposes_permissions_and_filters_public_routes(
authenticated_page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
public_config = authenticated_page.request.post(
_frame_config_url(lnbits_server),
data={
"path": f"/ext/{lnbits_server.extension_id}/public/item-123",
"query": {"source_id": "abc", "empty": None},
},
)
assert public_config.status == 200
public_bridge = public_config.json()["bridge"]
assert public_bridge["public"] is True
assert public_bridge["routeParams"] == {"itemId": "item-123"}
assert public_bridge["query"] == {"sourceId": "abc"}
assert {route["path"] for route in public_bridge["apiRoutes"]} == {
f"/api/v1/ext/{lnbits_server.extension_id}/jars/{{jar_id}}",
f"/api/v1/ext/{lnbits_server.extension_id}/invoice",
}
private_config = authenticated_page.request.post(
_frame_config_url(lnbits_server),
data={"path": f"/ext/{lnbits_server.extension_id}", "query": {}},
)
assert private_config.status == 200
private_bridge = private_config.json()["bridge"]
assert private_bridge["public"] is False
assert set(private_bridge["permissions"]) == {
permission["id"] for permission in _fixture_permissions()
}
assert {route["path"] for route in private_bridge["apiRoutes"]} == {
f"/api/v1/ext/{lnbits_server.extension_id}/wallets",
f"/api/v1/ext/{lnbits_server.extension_id}/payments",
f"/api/v1/ext/{lnbits_server.extension_id}/jars/{{jar_id}}",
f"/api/v1/ext/{lnbits_server.extension_id}/invoice",
}
def test_private_frame_config_and_api_routes_require_auth(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
private_config = page.request.post(
_frame_config_url(lnbits_server),
data={"path": f"/ext/{lnbits_server.extension_id}", "query": {}},
)
assert private_config.status == 401
private_api = page.request.get(
f"{lnbits_server.base_url}/api/v1/ext/{lnbits_server.extension_id}/wallets"
)
assert private_api.status == 401
public_config = page.request.post(
_frame_config_url(lnbits_server),
data={
"path": f"/ext/{lnbits_server.extension_id}/public/item-123",
"query": {},
},
)
assert public_config.status == 200
def test_bridge_context_and_denied_api_request(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/1"
with page.expect_response(lambda response: frame_url_part in response.url):
page.goto(
f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}"
"/public/item-123?source=test"
)
frame = _wasm_frame(page, frame_url_part)
assert frame.evaluate("() => window.lnbitsWasmTestBridge.ready()") is True
context_response = frame.evaluate("""
() => window.lnbitsWasmTestBridge.request({
action: 'context'
})
""")
assert context_response == {
"type": "lnbits-extension:response",
"id": "wasm-test-1",
"ok": True,
"data": {
"extensionId": EXTENSION_ID,
"public": True,
"routeParams": {"itemId": "item-123"},
"query": {"source": "test"},
},
}
denied_response = frame.evaluate("""
() => window.lnbitsWasmTestBridge.request({
action: 'api',
method: 'GET',
path: '/api/v1/wallets'
})
""")
assert denied_response["ok"] is False
assert denied_response["error"] == "Extension API route is not allowed."
unknown_response = frame.evaluate("""
() => window.lnbitsWasmTestBridge.request({
action: 'unknown'
})
""")
assert unknown_response["ok"] is False
assert unknown_response["error"] == "Unknown extension bridge action."
def test_frame_token_is_route_bound_required_and_single_use(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
frame_config = page.request.post(
f"{lnbits_server.base_url}/api/v1/ext/"
f"{lnbits_server.extension_id}/_ui/frame",
data={
"path": f"/ext/{lnbits_server.extension_id}/public/item-123",
"query": {"source": "test"},
},
)
assert frame_config.status == 200
frame_url = frame_config.json()["frameUrl"]
missing_token = page.request.get(
f"{lnbits_server.base_url}/ext-frame/{lnbits_server.extension_id}/1"
)
assert missing_token.status == 404
wrong_route = page.request.get(
f"{lnbits_server.base_url}{frame_url.replace('/1?', '/0?')}"
)
assert wrong_route.status == 404
first_use = page.request.get(f"{lnbits_server.base_url}{frame_url}")
assert first_use.status == 200
assert "form-action 'none'" in first_use.headers["content-security-policy"]
second_use = page.request.get(f"{lnbits_server.base_url}{frame_url}")
assert second_use.status == 404
def test_private_wasm_page_uses_lnbits_shell_and_private_frame(
authenticated_page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
page = authenticated_page
frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/0"
with page.expect_response(lambda response: frame_url_part in response.url) as info:
page.goto(f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}")
assert info.value.status == 200
expect(page.locator("body")).to_contain_text("LNbits")
frame = page.locator("iframe.wasm-extension-frame")
expect(frame).to_have_attribute("sandbox", "allow-scripts")
expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part))
expect(
page.frame_locator("iframe.wasm-extension-frame").locator("body")
).to_contain_text("WASM Test Admin")
def _frame_src_pattern(frame_url_part: str) -> Pattern[str]:
return re.compile(f"^{re.escape(frame_url_part)}\\?frame_token=[a-f0-9]{{32}}$")
def _frame_config_url(lnbits_server: LiveLNbitsServer) -> str:
return (
f"{lnbits_server.base_url}/api/v1/ext/"
f"{lnbits_server.extension_id}/_ui/frame"
)
def _fixture_permissions() -> list[dict[str, Any]]:
config = json.loads(
(REPO_ROOT / "tests/fixtures" / EXTENSION_ID / "config.json").read_text()
)
permissions = config["permissions"]
assert isinstance(permissions, list)
return permissions
def _permission_translations() -> dict[str, str]:
return {
"extension_permission_access_read": "Read",
"extension_permission_access_write": "Write",
"extension_permission_ext_storage_read_public": (
"Read public extension storage"
),
"extension_permission_ext_storage_read_write": (
"Read & Write extension storage"
),
"extension_permission_extension_api_request": "Use other extensions",
"extension_permission_risk_high": "High risk",
"extension_permission_risk_low": "Low risk",
"extension_permission_risk_medium": "Medium risk",
"extension_permission_ui_camera_scan_qr": "Scan QR codes",
"extension_permission_utils_basic": "Use basic LNbits utilities",
"extension_permission_wallet_balance_read": "View wallet balances",
"extension_permission_wallet_create_invoice": "Create invoices",
"extension_permission_wallet_create_invoice_public": (
"Create Lightning invoices from public pages"
),
"extension_permission_wallet_list": "List wallets",
"extension_permission_wallet_pay_invoice": "Pay invoices",
"extension_permission_warning_extension_api_request_write": (
"This extension can write to another extension."
),
"extension_permission_warning_wallet_pay_invoice": (
"This extension can spend from selected wallets."
),
}
def _wasm_frame(page: Page, frame_url_part: str) -> Frame:
frame = page.frame(url=lambda url: frame_url_part in url)
assert frame is not None
return frame
+112 -54
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import re
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
@@ -9,24 +10,24 @@ from typing import Any, Literal, Union, get_args, get_origin
from pydantic import BaseModel
from lnbits.core.extensions import (
ExtensionAPI,
from lnbits.core.wasm_ext import (
ExtensionAPIMethod,
ExtensionHostAPI,
get_extension_api_method,
list_extension_api_methods,
)
def generate_typescript_sdk(
api_cls: type[ExtensionAPI] | None = None,
api_cls: type[ExtensionHostAPI] | None = None,
method_ids: Sequence[str] | None = None,
) -> str:
api_cls = api_cls or ExtensionAPI
api_cls = api_cls or ExtensionHostAPI
methods = _select_methods(api_cls, method_ids)
models = _collect_models(methods)
lines = [
"/* Generated by LNbits ExtensionAPI codegen. */",
"/* Generated by LNbits ExtensionHostAPI codegen. */",
"/* Do not edit by hand. */",
"",
"export type MaybePromise<T> = T | Promise<T>",
@@ -51,7 +52,7 @@ def generate_typescript_sdk(
def write_typescript_sdk(
path: str | Path,
api_cls: type[ExtensionAPI] | None = None,
api_cls: type[ExtensionHostAPI] | None = None,
method_ids: Sequence[str] | None = None,
) -> None:
Path(path).write_text(
@@ -60,7 +61,7 @@ def write_typescript_sdk(
def _select_methods(
api_cls: type[ExtensionAPI], method_ids: Sequence[str] | None
api_cls: type[ExtensionHostAPI], method_ids: Sequence[str] | None
) -> list[ExtensionAPIMethod]:
if not method_ids:
return list_extension_api_methods(api_cls)
@@ -185,6 +186,7 @@ def _render_method_metadata(
f' namespace: "{method.namespace}",',
f' sdkName: "{method.sdk_name}",',
f' pythonName: "{method.python_name}",',
f' hostInterface: "{method.host_interface}",',
f' hostName: "{method.host_name}",',
f' hostJsName: "{_camel(method.host_name)}",',
f" requiredPermission: {permission},",
@@ -197,76 +199,120 @@ def _render_method_metadata(
def _render_host_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
lines = ["export type ExtensionHost = {"]
for method in sorted(methods, key=lambda item: item.host_name):
request = _model_name(method.request_model)
response = _model_name(method.response_model)
if _is_empty_model(method.request_model):
lines.append(f" {_camel(method.host_name)}(): MaybePromise<{response}>")
else:
lines.append(
f" {_camel(method.host_name)}"
f"(input: {request}): MaybePromise<{response}>"
)
lines.append("}")
return lines
def _render_sdk_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
namespaces = _methods_by_namespace(methods)
lines = ["export type ExtensionSdk = {"]
for namespace, namespace_methods in namespaces.items():
lines.append(f" {namespace}: {{")
for method in namespace_methods:
for host_interface, interface_methods in _methods_by_host_interface(
methods
).items():
lines.append(f" {_ts_property(host_interface)}: {{")
for method in sorted(interface_methods, key=lambda item: item.host_name):
request = _model_name(method.request_model)
response = _model_name(method.response_model)
if _is_empty_model(method.request_model):
lines.append(f" {method.sdk_name}(): Promise<{response}>")
lines.append(
f" {_camel(method.host_name)}(): MaybePromise<{response}>"
)
else:
lines.append(
f" {method.sdk_name}(input: {request}): Promise<{response}>"
f" {_camel(method.host_name)}"
f"(input: {request}): MaybePromise<{response}>"
)
lines.append(" }")
lines.append("}")
return lines
def _render_sdk_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
lines = ["export type ExtensionSdk = {"]
_render_sdk_type_node(lines, _namespace_tree(methods), 1)
lines.append("}")
return lines
def _render_create_sdk(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
namespaces = _methods_by_namespace(methods)
lines = [
"export function createExtensionSdk(",
" host: ExtensionHost",
"): ExtensionSdk {",
" return {",
]
for namespace, namespace_methods in namespaces.items():
lines.append(f" {namespace}: {{")
for method in namespace_methods:
host_name = _camel(method.host_name)
if _is_empty_model(method.request_model):
signature = f"{method.sdk_name}()"
host_call = f"host.{host_name}()"
else:
signature = f"{method.sdk_name}(input)"
host_call = f"host.{host_name}(input)"
lines.extend(
[
f" async {signature} {{",
f" return {host_call}",
" },",
]
)
lines.append(" },")
_render_create_sdk_node(lines, _namespace_tree(methods), 2)
lines.extend([" }", "}"])
return lines
def _methods_by_namespace(
def _render_sdk_type_node(lines: list[str], node: dict[str, Any], level: int) -> None:
indent = " " * level
for namespace, child in _iter_child_namespaces(node):
lines.append(f"{indent}{namespace}: {{")
_render_sdk_type_node(lines, child, level + 1)
lines.append(f"{indent}}}")
for method in node.get("__methods__", []):
request = _model_name(method.request_model)
response = _model_name(method.response_model)
if _is_empty_model(method.request_model):
lines.append(f"{indent}{method.sdk_name}(): Promise<{response}>")
else:
lines.append(
f"{indent}{method.sdk_name}(input: {request}): Promise<{response}>"
)
def _render_create_sdk_node(lines: list[str], node: dict[str, Any], level: int) -> None:
indent = " " * level
for namespace, child in _iter_child_namespaces(node):
lines.append(f"{indent}{namespace}: {{")
_render_create_sdk_node(lines, child, level + 1)
lines.append(f"{indent}}},")
for method in node.get("__methods__", []):
host_call_target = (
f"host{_ts_access(method.host_interface)}"
f"{_ts_access(_camel(method.host_name))}"
)
if _is_empty_model(method.request_model):
signature = f"{method.sdk_name}()"
host_call = f"{host_call_target}()"
else:
signature = f"{method.sdk_name}(input)"
host_call = f"{host_call_target}(input)"
lines.extend(
[
f"{indent}async {signature} {{",
f"{indent} return {host_call}",
f"{indent}}},",
]
)
def _methods_by_host_interface(
methods: Sequence[ExtensionAPIMethod],
) -> dict[str, list[ExtensionAPIMethod]]:
namespaces: dict[str, list[ExtensionAPIMethod]] = defaultdict(list)
interfaces: dict[str, list[ExtensionAPIMethod]] = defaultdict(list)
for method in sorted(
methods, key=lambda item: (item.host_interface, item.host_name)
):
interfaces[method.host_interface].append(method)
return dict(sorted(interfaces.items()))
def _namespace_tree(methods: Sequence[ExtensionAPIMethod]) -> dict[str, Any]:
tree: dict[str, Any] = {}
for method in sorted(methods, key=lambda item: (item.namespace, item.sdk_name)):
namespaces[method.namespace].append(method)
return dict(sorted(namespaces.items()))
node = tree
for part in method.namespace.split("."):
node = node.setdefault(part, {})
node.setdefault("__methods__", []).append(method)
return tree
def _iter_child_namespaces(
node: dict[str, Any],
) -> list[tuple[str, dict[str, Any]]]:
return [
(key, value)
for key, value in sorted(node.items())
if key != "__methods__" and isinstance(value, dict)
]
def _model_name(model: type[BaseModel]) -> str:
@@ -278,19 +324,31 @@ def _camel(value: str) -> str:
return head + "".join(part.capitalize() for part in tail)
def _ts_property(value: str) -> str:
if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", value):
return value
return f'"{value}"'
def _ts_access(value: str) -> str:
if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", value):
return f".{value}"
return f'["{value}"]'
def _is_empty_model(model: type[BaseModel]) -> bool:
return not model.__fields__
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Generate a TypeScript SDK from the LNbits ExtensionAPI."
description="Generate a TypeScript SDK from the LNbits ExtensionHostAPI."
)
parser.add_argument(
"--method",
action="append",
dest="method_ids",
help="ExtensionAPI method id to include. Can be passed multiple times.",
help="ExtensionHostAPI method id to include. Can be passed multiple times.",
)
parser.add_argument(
"--out",
Generated
+82
View File
@@ -1368,6 +1368,7 @@ dev = [
{ name = "pytest-httpserver" },
{ name = "pytest-md" },
{ name = "pytest-mock" },
{ name = "pytest-playwright" },
{ name = "ruff" },
{ name = "types-mock" },
{ name = "types-passlib" },
@@ -1446,6 +1447,7 @@ dev = [
{ name = "pytest-httpserver", specifier = "~=1.1.3" },
{ name = "pytest-md", specifier = "~=0.2.0" },
{ name = "pytest-mock", specifier = "~=3.15.1" },
{ name = "pytest-playwright", specifier = ">=0.8.0" },
{ name = "ruff", specifier = "~=0.14.10" },
{ name = "types-mock", specifier = "~=5.2.0.20250924" },
{ name = "types-passlib", specifier = "~=1.7.7.20250602" },
@@ -1832,6 +1834,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
name = "playwright"
version = "1.61.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877, upload-time = "2026-06-29T10:32:48.428Z" },
{ url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016, upload-time = "2026-06-29T10:32:52.104Z" },
{ url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884, upload-time = "2026-06-29T10:32:55.773Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381, upload-time = "2026-06-29T10:32:59.903Z" },
{ url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545, upload-time = "2026-06-29T10:33:03.574Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841, upload-time = "2026-06-29T10:33:07.361Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846, upload-time = "2026-06-29T10:33:10.637Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127, upload-time = "2026-06-29T10:33:14.008Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -2059,6 +2080,18 @@ email = [
{ name = "email-validator" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -2201,6 +2234,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-base-url"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
@@ -2251,6 +2297,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "pytest-playwright"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-base-url" },
{ name = "python-slugify" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/ef/172eb8e23c80491fc72f1401c72f9305663873649351306a38b18406b0c9/pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981", size = 17132, upload-time = "2026-05-18T10:16:15.919Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/71/1c545fac6a9054b52b3771238fb2dc6e8f1d0ccec116e1c7786ec191887c/pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933", size = 17143, upload-time = "2026-05-18T10:16:18.226Z" },
]
[[package]]
name = "python-crontab"
version = "3.3.0"
@@ -2291,6 +2352,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" },
]
[[package]]
name = "python-slugify"
version = "8.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "text-unidecode" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"
@@ -2630,6 +2703,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
]
[[package]]
name = "text-unidecode"
version = "1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
]
[[package]]
name = "tibs"
version = "0.5.7"