Compare commits

...
Author SHA1 Message Date
Vlad Stan 54e8dbcd8d chore: make bundle 2026-07-16 15:31:55 +03:00
Vlad Stan b678773728 fix: stricter public reads 2026-07-16 15:29:56 +03:00
Vlad Stan 58ccdb7212 chore: make bundle 2026-07-16 11:36:29 +03:00
Vlad Stan 44f627ffda test: fix 2026-07-16 11:35:04 +03:00
Vlad Stan a25cadb66d feat: partial implementation 2026-07-16 11:35:04 +03:00
Vlad Stan 9f317c5edf feat: append public row 2026-07-16 11:35:02 +03:00
17 changed files with 1393 additions and 43 deletions
+260 -3
View File
@@ -11,10 +11,15 @@ from lnbits.helpers import sha256s
from ..client.extensions import send_extension_api_request
from ..storage.crud import (
OWNER_ID_FIELD,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
storage_set_row,
)
from .background_payments import (
@@ -39,21 +44,28 @@ from .models import (
PayLnurlRequest,
RandomIdRequest,
RandomIdResponse,
StorageAppendPublicRequest,
StorageAppendPublicResponse,
StorageDeleteRequest,
StorageDeleteResponse,
StorageGetRequest,
StorageGetResponse,
StoragePaginatedRequest,
StoragePaginatedResponse,
StoragePublicPaginatedRequest,
StorageSetRequest,
StorageSetResponse,
UserWalletSummary,
WalletBalanceRequest,
WalletBalanceResponse,
WebsocketPublishRequest,
WebsocketPublishResponse,
)
from .registry import extension_api_method
from .websockets import scoped_websocket_item_id
logger = logging.getLogger("lnbits.extensions")
PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
class ExtensionHostAPI:
@@ -117,7 +129,7 @@ class ExtensionHostAPI:
async def storage_get_public(
self, request: StorageGetRequest
) -> StorageGetResponse:
public_fields = self._public_storage_fields(request.table)
public_fields = self._public_storage_policy(request.table)["public_fields"]
row = await storage_get_public_row(self.extension_id, request.table, request.id)
if not row:
return StorageGetResponse()
@@ -129,6 +141,45 @@ class ExtensionHostAPI:
# todo: check public fields filtering
return StorageGetResponse(data_json=json.dumps(public_row))
@extension_api_method(
method_id="storage.append_public",
namespace="storage",
name="Append public storage row",
host_name="storage_append_public",
sdk_name="appendPublic",
description="Append one public row to an extension storage table.",
required_permission="ext.storage.append_public",
require_auth=False,
)
async def storage_append_public(
self, request: StorageAppendPublicRequest
) -> StorageAppendPublicResponse:
policy, owner_id = await self._public_storage_append_policy(
request.table, request.source_id
)
data = dict(request.data)
self._validate_public_append_data(policy, data)
source_id_field = policy["source_id_field"]
current_rows = await storage_count_rows(
self.extension_id,
request.table,
{source_id_field: request.source_id},
owner_id=owner_id,
)
if current_rows >= policy["max_rows_per_source"]:
raise PermissionError(
f"Public storage append limit reached for '{request.table}'."
)
row_id = await storage_append_public_row(
self.extension_id,
request.table,
{**data, source_id_field: request.source_id},
owner_id,
)
return StorageAppendPublicResponse(id=row_id)
@extension_api_method(
method_id="storage.set",
namespace="storage",
@@ -178,6 +229,55 @@ class ExtensionHostAPI:
total=page["total"],
)
@extension_api_method(
method_id="storage.get_public_paginated",
namespace="storage",
name="Get paginated public storage rows",
host_name="storage_get_public_paginated",
sdk_name="getPublicPaginated",
description="Get filtered, searched, sorted, paginated public storage rows.",
required_permission="ext.storage.read_public",
require_auth=False,
)
async def storage_get_public_paginated(
self, request: StoragePublicPaginatedRequest
) -> StoragePaginatedResponse:
policy = self._public_storage_policy(request.table)
public_fields = policy["public_fields"]
source_id_field = policy["source_id_field"]
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public paginated storage reads require a source ID field policy."
)
filters = self._public_storage_paginated_filters(
request, public_fields, source_id_field
)
page = await storage_get_public_paginated_rows(
self.extension_id,
request.table,
filters,
search=request.search,
search_fields=request.search_fields,
sort_by=request.sort_by,
descending=request.descending,
limit=request.limit,
offset=request.offset,
)
return StoragePaginatedResponse(
rows_json=json.dumps(
[
{
field_name: value
for field_name, value in row.items()
if field_name in public_fields
}
for row in page["data"]
]
),
total=page["total"],
)
@extension_api_method(
method_id="storage.delete",
namespace="storage",
@@ -199,6 +299,25 @@ class ExtensionHostAPI:
)
return StorageDeleteResponse()
@extension_api_method(
method_id="websocket.publish",
namespace="websocket",
name="Publish websocket message",
host_name="websocket_publish",
sdk_name="publish",
description="Publish a JSON message on an extension-local websocket channel.",
required_permission="websocket.publish",
require_auth=False,
)
async def websocket_publish(
self, request: WebsocketPublishRequest
) -> WebsocketPublishResponse:
from lnbits.core.services import websocket_manager
item_id = scoped_websocket_item_id(self.extension_id, request.item_id)
await websocket_manager.send(item_id, request.data_json)
return WebsocketPublishResponse()
@extension_api_method(
method_id="wallet.create_invoice",
namespace="wallet",
@@ -628,7 +747,7 @@ class ExtensionHostAPI:
return permission_ids, policies
def _public_storage_fields(self, table: str) -> set[str]:
def _public_storage_policy(self, table: str) -> dict[str, Any]:
tables = self.permission_policies.get("ext.storage.read_public")
if not isinstance(tables, list) or not tables:
raise PermissionError(
@@ -648,10 +767,148 @@ class ExtensionHostAPI:
raise PermissionError(
f"Public storage table '{table}' has no valid public fields."
)
return set(public_fields)
source_id_field = table_policy.get("source_id_field")
if source_id_field is not None and (
not isinstance(source_id_field, str) or not source_id_field
):
raise PermissionError(
f"Public storage table '{table}' has no valid source ID field."
)
return {
"public_fields": set(public_fields),
"source_id_field": source_id_field,
}
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
def _validate_public_storage_query_fields(
self,
request: StoragePaginatedRequest,
public_fields: set[str],
allowed_private_fields: set[str] | None = None,
) -> None:
allowed_private_fields = allowed_private_fields or set()
query_fields = set(request.filters)
query_fields.update(request.search_fields)
if request.sort_by:
query_fields.add(request.sort_by)
private_fields = sorted(query_fields - public_fields - allowed_private_fields)
if private_fields:
raise PermissionError(
"Public storage query uses non-public fields: "
+ ", ".join(private_fields)
)
def _public_storage_paginated_filters(
self,
request: StoragePublicPaginatedRequest,
public_fields: set[str],
source_id_field: str,
) -> dict[str, Any]:
self._validate_public_storage_query_fields(
request, public_fields, {source_id_field}
)
filters = dict(request.filters)
requested_source_id = filters.get(source_id_field)
if requested_source_id is not None and requested_source_id != request.source_id:
raise PermissionError(
"Public storage source filter does not match source_id."
)
filters[source_id_field] = request.source_id
return filters
async def _public_storage_append_policy(
self, table: str, source_id: str
) -> tuple[dict[str, Any], str]:
policies = self.permission_policies.get("ext.storage.append_public")
if not isinstance(policies, list) or not policies:
raise PermissionError(
"Public storage appends require policies for "
"'ext.storage.append_public'."
)
source_not_found = False
for raw_policy in policies:
policy = self._normalize_public_storage_append_policy(raw_policy)
if policy["table"] != table:
continue
owner_id = await storage_get_row_owner_id(
self.extension_id,
policy["source_table"],
source_id,
)
if not owner_id:
source_not_found = True
continue
return policy, owner_id
if source_not_found:
raise PermissionError("Public storage append source was not found.")
raise PermissionError(f"Storage table '{table}' is not publicly appendable.")
def _normalize_public_storage_append_policy(self, policy: Any) -> dict[str, Any]:
if not isinstance(policy, dict):
raise PermissionError("Public storage append policies must be objects.")
table = policy.get("table")
source_table = policy.get("source_table")
source_id_field = policy.get("source_id_field")
allowed_fields = policy.get("allowed_fields")
max_rows_per_source = policy.get(
"max_rows_per_source", PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
)
if not isinstance(table, str) or not table:
raise PermissionError("Public storage append requires a table policy.")
if not isinstance(source_table, str) or not source_table:
raise PermissionError(
"Public storage append requires a source table policy."
)
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public storage append requires a source ID field policy."
)
if source_id_field == "id":
raise PermissionError("Public storage append source field cannot be 'id'.")
if (
not isinstance(allowed_fields, list)
or not all(isinstance(field, str) and field for field in allowed_fields)
or "id" in allowed_fields
or OWNER_ID_FIELD in allowed_fields
or source_id_field in allowed_fields
):
raise PermissionError(
"Public storage append requires valid allowed fields."
)
if (
isinstance(max_rows_per_source, bool)
or not isinstance(max_rows_per_source, int)
or max_rows_per_source <= 0
):
raise PermissionError(
"Public storage append requires a positive row limit."
)
return {
"table": table,
"source_table": source_table,
"source_id_field": source_id_field,
"allowed_fields": set(allowed_fields),
"max_rows_per_source": max_rows_per_source,
}
def _validate_public_append_data(
self, policy: dict[str, Any], data: dict[str, Any]
) -> None:
if not isinstance(data, dict):
raise PermissionError("Public storage append data must be an object.")
unknown_fields = sorted(set(data) - policy["allowed_fields"])
if unknown_fields:
raise PermissionError(
"Public storage append contains disallowed fields: "
+ ", ".join(unknown_fields)
)
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
policies = self.permission_policies.get("wallet.create_invoice_public")
if not isinstance(policies, list) or not policies:
+53
View File
@@ -67,6 +67,23 @@ class StorageSetResponse(BaseModel):
ok: bool = True
class StorageAppendPublicRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
source_id: str = Field(..., min_length=1, max_length=512)
data: dict[str, Any] = Field(default_factory=dict)
@root_validator(pre=True)
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
data_json = values.get("data_json")
if data_json is not None and "data" not in values:
values["data"] = json.loads(data_json)
return values
class StorageAppendPublicResponse(BaseModel):
id: str
class StoragePaginatedRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
filters: dict[str, Any] = Field(default_factory=dict)
@@ -92,11 +109,47 @@ class StoragePaginatedRequest(BaseModel):
return values
class StoragePublicPaginatedRequest(StoragePaginatedRequest):
source_id: str = Field(..., min_length=1, max_length=512)
class StoragePaginatedResponse(BaseModel):
rows_json: str = "[]"
total: int = 0
class WebsocketPublishRequest(BaseModel):
item_id: str = Field(..., min_length=1, max_length=128)
data: Any = Field(default_factory=dict)
@root_validator(pre=True)
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
data_json = values.get("data_json")
if data_json is not None and "data" not in values:
values["data"] = json.loads(data_json)
return values
@root_validator
def validate_data_size(cls, values: dict[str, Any]) -> dict[str, Any]:
data = values.get("data")
try:
encoded = json.dumps(data, separators=(",", ":"))
except TypeError as exc:
raise ValueError("websocket data must be JSON serializable.") from exc
if len(encoded.encode()) > 65536:
raise ValueError("websocket data must not exceed 65536 bytes.")
values["data"] = data
return values
@property
def data_json(self) -> str:
return json.dumps(self.data, separators=(",", ":"))
class WebsocketPublishResponse(BaseModel):
sent: bool = True
class StorageDeleteRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
id: str = Field(..., min_length=1, max_length=512)
+89 -6
View File
@@ -10,11 +10,14 @@ from lnbits.core.wasm_ext.wasm.config import (
)
_POLICY_AWARE_PERMISSION_IDS = {
"ext.storage.append_public",
"ext.storage.read_public",
"extension.api.request",
"http.request",
"wallet.create_invoice_public",
}
_OWNER_ID_FIELD = "__lnbits_owner_id__"
_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
def validate_extension_permissions(
@@ -127,6 +130,10 @@ def _permission_grant_is_subset(
return _http_request_grant_is_subset(requested.policies, granted.policies)
if requested.id == "extension.api.request":
return _extension_api_grant_is_subset(requested.policies, granted.policies)
if requested.id == "ext.storage.append_public":
return _public_storage_append_grant_is_subset(
requested.policies, granted.policies
)
if requested.id == "ext.storage.read_public":
return _public_storage_grant_is_subset(requested.policies, granted.policies)
if requested.id == "wallet.create_invoice_public":
@@ -203,32 +210,108 @@ def _public_storage_grant_is_subset(
) -> bool:
requested_tables = _public_storage_tables(requested_policies)
granted_tables = _public_storage_tables(granted_policies)
for table_name, granted_fields in granted_tables.items():
requested_fields = requested_tables.get(table_name)
if requested_fields is None or not granted_fields.issubset(requested_fields):
for table_name, granted_policy in granted_tables.items():
requested_policy = requested_tables.get(table_name)
if requested_policy is None:
return False
if not granted_policy["public_fields"].issubset(
requested_policy["public_fields"]
):
return False
requested_source_id_field = requested_policy["source_id_field"]
if (
requested_source_id_field
and granted_policy["source_id_field"] != requested_source_id_field
):
return False
return True
def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]:
tables: dict[str, set[str]] = {}
def _public_storage_tables(policies: list[Any] | None) -> dict[str, dict[str, Any]]:
tables: dict[str, dict[str, Any]] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table_name = policy.get("table_name")
public_fields = policy.get("public_fields")
source_id_field = policy.get("source_id_field")
if (
not isinstance(table_name, str)
or table_name in tables
or not isinstance(public_fields, list)
or (
source_id_field is not None
and (not isinstance(source_id_field, str) or not source_id_field)
)
):
continue
fields = {field for field in public_fields if isinstance(field, str) and field}
if fields:
tables[table_name] = fields
tables[table_name] = {
"public_fields": fields,
"source_id_field": source_id_field,
}
return tables
def _public_storage_append_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
requested_targets = _public_storage_append_targets(requested_policies)
granted_targets = _public_storage_append_targets(granted_policies)
for target, granted_policy in granted_targets.items():
requested_policy = requested_targets.get(target)
if requested_policy is None:
return False
if not granted_policy["allowed_fields"].issubset(
requested_policy["allowed_fields"]
):
return False
if (
granted_policy["max_rows_per_source"]
> requested_policy["max_rows_per_source"]
):
return False
return True
def _public_storage_append_targets(policies: list[Any] | None) -> dict[Any, dict]:
targets: dict[Any, dict] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table = policy.get("table")
source_table = policy.get("source_table")
source_id_field = policy.get("source_id_field")
allowed_fields = policy.get("allowed_fields")
max_rows_per_source = policy.get(
"max_rows_per_source", _PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
)
if (
not isinstance(table, str)
or not table
or not isinstance(source_table, str)
or not source_table
or not isinstance(source_id_field, str)
or not source_id_field
or source_id_field == "id"
or not isinstance(allowed_fields, list)
or isinstance(max_rows_per_source, bool)
or not isinstance(max_rows_per_source, int)
or max_rows_per_source <= 0
):
continue
fields = {field for field in allowed_fields if isinstance(field, str) and field}
if "id" in fields or _OWNER_ID_FIELD in fields or source_id_field in fields:
continue
targets[(table, source_table, source_id_field)] = {
"allowed_fields": fields,
"max_rows_per_source": max_rows_per_source,
}
return targets
def _public_invoice_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
+1
View File
@@ -15,6 +15,7 @@ _EXTENSION_RUNTIME_PERMISSION_IDS = {
"wallet.pay_invoice",
"wallet.pay_invoice_background",
"wallet.payments.watch",
"websocket.subscribe",
}
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import re
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
_LOCAL_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$")
def scoped_websocket_item_id(extension_id: str, item_id: str) -> str:
if not _EXTENSION_ID_RE.fullmatch(extension_id):
raise ValueError("Extension websocket namespace is invalid.")
if not _LOCAL_ITEM_ID_RE.fullmatch(item_id):
raise ValueError(
"Extension websocket item ID must be 1-128 characters and contain "
"only letters, numbers, colon, underscore, or dash."
)
return f"ext:{extension_id}:{item_id}"
+6
View File
@@ -1,7 +1,10 @@
from .crud import (
migrate_wasm_extension_database,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
@@ -10,8 +13,11 @@ from .crud import (
__all__ = [
"migrate_wasm_extension_database",
"storage_append_public_row",
"storage_count_rows",
"storage_delete_row",
"storage_get_paginated_rows",
"storage_get_public_paginated_rows",
"storage_get_public_row",
"storage_get_row",
"storage_get_row_owner_id",
+78
View File
@@ -5,6 +5,7 @@ import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
from loguru import logger
@@ -110,6 +111,39 @@ async def storage_set_row(
await conn.execute(query, clean_data)
async def storage_append_public_row(
ext_id: str,
table: str,
data: dict[str, Any],
owner_id: str,
) -> str:
row_id = uuid4().hex
await storage_set_row(ext_id, table, {**data, "id": row_id}, owner_id)
return row_id
async def storage_count_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
owner_id: str,
) -> int:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(database, table_schema, filters, None, [])
where_sql = _append_owner_where_sql(where_sql)
values[OWNER_ID_FIELD] = owner_id
query = f"""
SELECT COUNT(*) AS count FROM {_table_ref_for_schema(ext_id, table)}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
row = await conn.fetchone(query, values)
return int(row["count"]) if row else 0
async def storage_get_paginated_rows(
ext_id: str,
table: str,
@@ -157,6 +191,50 @@ async def storage_get_paginated_rows(
}
async def storage_get_public_paginated_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
search: str | None,
search_fields: list[str],
sort_by: str | None,
descending: bool,
limit: int,
offset: int,
) -> dict[str, Any]:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, table_schema, filters, search, search_fields
)
order_sql = _order_sql(table_schema, sort_by, descending)
count_values = dict(values)
values.update({"limit": min(limit, 1000), "offset": offset})
table_ref = _table_ref_for_schema(ext_id, table)
rows_query = f"""
SELECT * FROM {table_ref}
{where_sql}
{order_sql}
LIMIT :limit
OFFSET :offset
""" # noqa: S608
count_query = f"""
SELECT COUNT(*) AS count FROM {table_ref}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
return {
"data": [_row_from_db(table_schema, row) for row in rows],
"total": int(count_row["count"]) if count_row else 0,
}
async def storage_delete_row(
ext_id: str,
table: str,
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
+9
View File
@@ -544,7 +544,13 @@ window.localisation.en = {
extension_permission_warning_extension_api_request_write:
'Can write data or trigger actions in approved extensions.',
extension_permission_ext_storage_read: 'Read extension storage',
extension_permission_ext_storage_append_public:
'Append public extension storage',
extension_permission_ext_storage_append_public_sources:
'Allowed append targets',
extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_read_public_source_required:
'required to read',
extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
extension_permission_extension_api_request: 'Use other extensions',
@@ -555,6 +561,9 @@ window.localisation.en = {
extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_utils_basic: 'Use basic LNbits utilities',
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
extension_permission_websocket: 'Use extension websockets',
extension_permission_websocket_publish: 'Publish websocket messages',
extension_permission_websocket_subscribe: 'Subscribe to websocket messages',
extension_permission_wallet_payments_watch: 'Watch wallet payments',
extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public:
@@ -110,11 +110,15 @@
'extension_permission_warning_wallet_payments_watch'
)
}
if (['websocket.publish', 'websocket.subscribe'].includes(permission.id)) {
return mediumRisk(translateFn)
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.append_public',
'ext.storage.read_public'
].includes(permission.id)
) {
@@ -142,9 +146,13 @@
'extension.api.request',
'http.request',
'ui.camera.scan_qr',
'websocket',
'websocket.publish',
'websocket.subscribe',
'ext.storage.read',
'ext.storage.write',
'ext.storage.read_public',
'ext.storage.append_public',
'wallet.create_invoice_public',
'wallet.create_invoice',
'utils.basic'
@@ -166,7 +174,12 @@
: table.public_fields.filter(
field => typeof field === 'string' && field
)
return tableName ? {table: tableName, fields} : null
const sourceIdField =
typeof table === 'string' ||
typeof table?.source_id_field !== 'string'
? ''
: table.source_id_field
return tableName ? {table: tableName, fields, sourceIdField} : null
})
.filter(Boolean)
}
@@ -194,24 +207,68 @@
.filter(Boolean)
}
function publicAppendPolicies(permission) {
const policies = permission.policies
if (!Array.isArray(policies)) return []
return policies
.map(policy => {
if (!policy || typeof policy !== 'object') return null
const table = policy.table
const sourceTable = policy.source_table
const sourceIdField = policy.source_id_field
const allowedFields = Array.isArray(policy.allowed_fields)
? policy.allowed_fields.filter(
field => typeof field === 'string' && field
)
: []
const maxRowsPerSource = Number.isInteger(policy.max_rows_per_source)
? policy.max_rows_per_source
: 10000
if (typeof table !== 'string' || !table) return null
if (typeof sourceTable !== 'string' || !sourceTable) return null
if (typeof sourceIdField !== 'string' || !sourceIdField) return null
return {
table,
sourceTable,
sourceIdField,
allowedFields,
maxRowsPerSource
}
})
.filter(Boolean)
}
function permissionDisplayItem(permissions, extensions, translateFn) {
const permission = permissions[0]
const isReadWriteStorage =
permissions.length === 2 &&
permissions.some(permission => permission.id === 'ext.storage.read') &&
permissions.some(permission => permission.id === 'ext.storage.write')
const isWebsocket =
permissions.every(permission =>
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) &&
permissions.some(permission => permission.id === 'websocket.publish') &&
permissions.some(permission => permission.id === 'websocket.subscribe')
const descriptions = permissions
.map(permission => permissionManifestDescription(permission))
.filter(Boolean)
const item = {
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
id: isReadWriteStorage
? 'ext.storage.read_write'
: isWebsocket
? 'websocket'
: permission.id,
label: isReadWriteStorage
? translate(translateFn, 'extension_permission_ext_storage_read_write')
: permissionLabel(permission, translateFn),
: isWebsocket
? translate(translateFn, 'extension_permission_websocket')
: permissionLabel(permission, translateFn),
risk: permissionRisk(permissions, extensions, translateFn),
badges: [],
descriptions,
fieldGroups: [],
appendPolicies: [],
invoicePolicies: [],
extensionAccess: [],
httpHosts: []
@@ -244,6 +301,15 @@
item.invoicePolicies = publicInvoicePolicies(permission)
}
if (permission.id === 'ext.storage.append_public') {
item.appendPolicies = publicAppendPolicies(permission)
item.badges = item.appendPolicies.map(policy => ({
key:
policy.table + ':' + policy.sourceTable + ':' + policy.sourceIdField,
label: policy.table
}))
}
return item
}
@@ -255,7 +321,11 @@
const hasReadWriteStorage =
permissionsById.has('ext.storage.read') &&
permissionsById.has('ext.storage.write')
const hasWebsocket =
permissionsById.has('websocket.publish') &&
permissionsById.has('websocket.subscribe')
let addedReadWriteStorage = false
let addedWebsocket = false
return permissionList
.map((permission, index) => {
@@ -274,6 +344,21 @@
]
}
}
if (
hasWebsocket &&
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) {
if (addedWebsocket) return null
addedWebsocket = true
return {
index,
orderId: 'websocket',
permissions: [
permissionsById.get('websocket.publish'),
permissionsById.get('websocket.subscribe')
]
}
}
return {
index,
orderId: permission.id,
@@ -327,6 +412,9 @@
publicInvoicePolicySentence(policy) {
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
},
publicAppendPolicySentence(policy) {
return `${policy.table} rows can be appended for ${policy.sourceTable} using ${policy.sourceIdField}. Limit: ${policy.maxRowsPerSource} rows per source.`
},
permissionAccessLabel(access) {
const key = `extension_permission_access_${access}`
const label = this.$t(key)
+129 -1
View File
@@ -183,7 +183,8 @@ window.WasmExtensionComponent = {
handleWindowMessage: null,
loading: false,
loadId: 0,
paymentSubscriptions: new Map()
paymentSubscriptions: new Map(),
websocketSubscriptions: new Map()
}
},
created() {
@@ -357,6 +358,29 @@ window.WasmExtensionComponent = {
)
})
},
extensionRoute(path) {
let url
try {
url = new URL(String(path || ''), window.location.origin)
} catch (_error) {
throw new Error('Invalid extension route.')
}
if (url.origin !== window.location.origin) {
throw new Error('Extension route must stay on this server.')
}
const basePath = `/ext/${encodeURIComponent(this.bridge.extensionId)}`
if (
url.pathname !== basePath &&
!url.pathname.startsWith(`${basePath}/`)
) {
throw new Error('Extension route must stay inside this extension.')
}
return `${url.pathname}${url.search}${url.hash}`
},
replaceExtensionRoute(message) {
return this.$router.replace(this.extensionRoute(message.path))
},
async callApi(message) {
const method = String(message.method || 'GET').toUpperCase()
const path = String(message.path || '')
@@ -844,6 +868,18 @@ window.WasmExtensionComponent = {
isPaymentHash(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
},
isWebsocketItemId(value) {
return (
typeof value === 'string' &&
/^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$/.test(value)
)
},
scopedWebsocketItemId(itemId) {
if (!this.isWebsocketItemId(itemId)) {
throw new Error('Invalid websocket item ID.')
}
return `ext:${this.bridge.extensionId}:${itemId}`
},
websocketUrl(path) {
const url = new URL(window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -874,8 +910,24 @@ window.WasmExtensionComponent = {
this.closePaymentSubscription(subscriptionId)
}
},
closeWebsocketSubscription(subscriptionId) {
const subscription = this.websocketSubscriptions.get(subscriptionId)
if (!subscription) return
this.websocketSubscriptions.delete(subscriptionId)
try {
subscription.socket.close()
} catch (_error) {}
},
closeWebsocketSubscriptions() {
for (const subscriptionId of Array.from(
this.websocketSubscriptions.keys()
)) {
this.closeWebsocketSubscription(subscriptionId)
}
},
closeBridgePort() {
this.closePaymentSubscriptions()
this.closeWebsocketSubscriptions()
this.bridgePort?.close()
this.bridgePort = null
},
@@ -937,6 +989,55 @@ window.WasmExtensionComponent = {
this.paymentSubscriptions.delete(subscriptionId)
})
},
subscribeWebsocket(message) {
if (!this.hasBridgePermission('websocket.subscribe')) {
throw new Error('Extension is missing websocket subscribe permission.')
}
const subscriptionId = String(message.subscriptionId || '')
const itemId = String(message.itemId || '')
if (
!subscriptionId ||
subscriptionId.length > 128 ||
!this.isWebsocketItemId(itemId)
) {
throw new Error('Invalid websocket subscription.')
}
this.closeWebsocketSubscription(subscriptionId)
const scopedItemId = this.scopedWebsocketItemId(itemId)
const socket = new WebSocket(
this.websocketUrl(`/api/v1/ws/${encodeURIComponent(scopedItemId)}`)
)
this.websocketSubscriptions.set(subscriptionId, {itemId, socket})
socket.addEventListener('message', event => {
let data = event.data
try {
data = JSON.parse(event.data)
} catch (_error) {}
this.sendBridgeEvent({
event: 'websocket.message',
subscriptionId,
itemId,
data
})
})
socket.addEventListener('error', () => {
this.sendBridgeEvent({
event: 'websocket.error',
subscriptionId,
itemId
})
this.closeWebsocketSubscription(subscriptionId)
})
socket.addEventListener('close', () => {
this.websocketSubscriptions.delete(subscriptionId)
})
},
async handleBridgeRequest(message, reply) {
if (!message || message.type !== 'lnbits-extension:request') return
@@ -966,6 +1067,15 @@ window.WasmExtensionComponent = {
return
}
if (message.action === 'navigation.replace') {
await this.replaceExtensionRoute(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'ui.scan_qr') {
this.sendResponse(reply, message.id, {
ok: true,
@@ -1016,6 +1126,24 @@ window.WasmExtensionComponent = {
return
}
if (message.action === 'websocket.subscribe') {
this.subscribeWebsocket(message)
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'websocket.unsubscribe') {
this.closeWebsocketSubscription(String(message.subscriptionId || ''))
this.sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
throw new Error('Unknown extension bridge action.')
} catch (error) {
this.sendResponse(reply, message.id, {
@@ -9,28 +9,31 @@
>
<template v-slot:header>
<q-item-section>
<q-item-label class="text-weight-medium">
<span v-text="permission.label"></span>
</q-item-label>
</q-item-section>
<q-item-section
v-if="permission.risk.level !== 'low' || permission.badges.length"
side
top
>
<div class="row items-center justify-end q-gutter-xs">
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
<div class="row items-center q-col-gutter-x-md q-row-gutter-sm">
<q-item-label
class="text-weight-medium col-auto"
style="max-width: 100%"
>
<span v-text="permission.label"></span>
</q-item-label>
<div
v-if="permission.risk.level !== 'low' || permission.badges.length"
class="row items-center q-gutter-xs col-auto q-mb-xs"
style="max-width: 100%"
>
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
</div>
</div>
</q-item-section>
</template>
@@ -57,7 +60,24 @@
></p>
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
<li v-for="group of permission.fieldGroups" :key="group.table">
<span v-text="group.table"></span>
<div class="row items-center q-gutter-xs">
<span v-text="group.table"></span>
<template v-if="group.sourceIdField">
<q-badge
color="warning"
text-color="dark"
v-text="group.sourceIdField"
></q-badge>
<span
class="text-caption text-grey"
v-text="
$t(
'extension_permission_ext_storage_read_public_source_required'
)
"
></span>
</template>
</div>
<ul v-if="group.fields.length" class="q-pl-md">
<li
v-for="field of group.fields"
@@ -67,6 +87,35 @@
</ul>
</li>
</ul>
<div v-if="permission.appendPolicies.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="
$t('extension_permission_ext_storage_append_public_sources')
"
></div>
<ul class="q-my-sm q-pl-md">
<li
v-for="policy of permission.appendPolicies"
:key="
policy.table +
':' +
policy.sourceTable +
':' +
policy.sourceIdField
"
>
<span v-text="publicAppendPolicySentence(policy)"></span>
<ul v-if="policy.allowedFields.length" class="q-pl-md">
<li
v-for="field of policy.allowedFields"
:key="policy.table + ':' + field"
v-text="field"
></li>
</ul>
</li>
</ul>
</div>
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
<div
class="text-caption text-grey"
+8 -1
View File
@@ -14,7 +14,7 @@ def test_wasm_frontend_assets_are_registered_in_component_bundle():
assert "js/components/admin/lnbits-admin-wasm-limit-config.js" in components
def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
def test_wasm_frontend_bridge_restricts_api_routes_and_realtime_actions():
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
encoding="utf-8"
)
@@ -22,8 +22,15 @@ def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
assert "allowedApiRoute(method, path)" in bridge
assert "url.origin !== window.location.origin" in bridge
assert "Extension API route is not allowed." in bridge
assert "extensionRoute(path)" in bridge
assert "Extension route must stay inside this extension." in bridge
assert "message.action === 'payment.subscribe'" in bridge
assert "message.action === 'payment.unsubscribe'" in bridge
assert "message.action === 'websocket.subscribe'" in bridge
assert "message.action === 'websocket.unsubscribe'" in bridge
assert "message.action === 'navigation.replace'" in bridge
assert "hasBridgePermission('websocket.subscribe')" in bridge
assert "ext:${this.bridge.extensionId}:${itemId}" in bridge
assert "message.action === 'ui.scan_qr'" in bridge
+336
View File
@@ -10,8 +10,11 @@ from lnbits.core.wasm_ext.api.models import (
CreateInvoicePublicRequest,
EmptyRequest,
PayInvoiceRequest,
StorageAppendPublicRequest,
StorageGetRequest,
StoragePublicPaginatedRequest,
WalletBalanceRequest,
WebsocketPublishRequest,
)
from lnbits.exceptions import PaymentError
from lnbits.helpers import sha256s
@@ -48,6 +51,209 @@ async def test_host_api_filters_public_storage_fields(mocker: MockerFixture):
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
@pytest.mark.anyio
async def test_host_api_filters_public_paginated_storage_rows(
mocker: MockerFixture,
):
storage_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_public_paginated_rows",
mocker.AsyncMock(
return_value={
"data": [
{
"id": "message-1",
"thread_id": "thread-1",
"message": "Hello",
"admin_note": "secret",
}
],
"total": 1,
}
),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "thread_id", "message"],
}
],
)
],
)
response = await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
assert json.loads(response.rows_json) == [
{"id": "message-1", "thread_id": "thread-1", "message": "Hello"}
]
assert response.total == 1
storage_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
search="hello",
search_fields=["message"],
sort_by="id",
descending=False,
limit=25,
offset=0,
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_private_query_fields():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="non-public fields"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"admin_note": "secret"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_requires_source_policy():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="source ID field policy"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_public_paginated_storage_rejects_conflicting_source_filter():
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"source_id_field": "thread_id",
"public_fields": ["id", "message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="does not match source_id"):
await api.storage_get_public_paginated(
StoragePublicPaginatedRequest(
table="messages",
filters={"thread_id": "thread-2"},
search=None,
search_fields=[],
sort_by=None,
descending=False,
limit=25,
offset=0,
source_id="thread-1",
)
)
@pytest.mark.anyio
async def test_host_api_websocket_publish_scopes_item_id(mocker: MockerFixture):
send_mock = mocker.patch(
"lnbits.core.services.websocket_manager.send",
mocker.AsyncMock(),
)
api = ExtensionHostAPI("demoext", ["websocket.publish"])
response = await api.websocket_publish(
WebsocketPublishRequest(
item_id="conversation:abc_123",
data={"message": "Hello"},
)
)
assert response.sent is True
send_mock.assert_awaited_once_with(
"ext:demoext:conversation:abc_123",
'{"message":"Hello"}',
)
@pytest.mark.anyio
async def test_host_api_websocket_publish_rejects_invalid_item_id():
api = ExtensionHostAPI("demoext", ["websocket.publish"])
with pytest.raises(ValueError, match="item ID"):
await api.websocket_publish(
WebsocketPublishRequest(
item_id="../other",
data={"message": "Hello"},
)
)
@pytest.mark.anyio
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
mocker: MockerFixture,
@@ -77,6 +283,136 @@ async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
)
@pytest.mark.anyio
async def test_host_api_public_append_uses_source_owner_and_allowed_fields(
mocker: MockerFixture,
):
owner_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
count_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_count_rows",
mocker.AsyncMock(return_value=0),
)
append_mock = mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_append_public_row",
mocker.AsyncMock(return_value="message-1"),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["name", "message"],
}
],
)
],
)
response = await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"name": "Alice", "message": "Hello"},
)
)
assert response.id == "message-1"
owner_mock.assert_awaited_once_with("demoext", "threads", "thread-1")
count_mock.assert_awaited_once_with(
"demoext",
"messages",
{"thread_id": "thread-1"},
owner_id="owner-1",
)
append_mock.assert_awaited_once_with(
"demoext",
"messages",
{"name": "Alice", "message": "Hello", "thread_id": "thread-1"},
"owner-1",
)
@pytest.mark.anyio
async def test_host_api_public_append_rejects_disallowed_fields(
mocker: MockerFixture,
):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
}
],
)
],
)
with pytest.raises(PermissionError, match="disallowed fields"):
await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"message": "Hello", "admin": True},
)
)
@pytest.mark.anyio
async def test_host_api_public_append_enforces_row_limit(mocker: MockerFixture):
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
mocker.AsyncMock(return_value="owner-1"),
)
mocker.patch(
"lnbits.core.wasm_ext.api.host.storage_count_rows",
mocker.AsyncMock(return_value=1),
)
api = ExtensionHostAPI(
"demoext",
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 1,
}
],
)
],
)
with pytest.raises(PermissionError, match="limit reached"):
await api.storage_append_public(
StorageAppendPublicRequest(
table="messages",
source_id="thread-1",
data={"message": "Hello"},
)
)
@pytest.mark.anyio
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
mocker: MockerFixture,
@@ -65,6 +65,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
"policies": [
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title", "description"],
}
],
@@ -80,6 +81,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
policies=[
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"],
}
],
@@ -95,6 +97,7 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
policies=[
{
"table_name": "tip_jars",
"source_id_field": "wallet_id",
"public_fields": ["id", "title"],
}
],
@@ -102,6 +105,140 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
]
def test_validate_wasm_permissions_rejects_public_read_source_field_omission():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.read_public",
"policies": [
{
"table_name": "messages",
"source_id_field": "conversation_id",
"public_fields": ["id", "body"],
}
],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.read_public",
policies=[
{
"table_name": "messages",
"public_fields": ["id", "body"],
}
],
)
],
extension_config,
)
def test_validate_wasm_permissions_allows_narrower_public_append_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.append_public",
"description": "Append public messages.",
"policies": [
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["name", "message"],
"max_rows_per_source": 100,
}
],
}
],
)
permissions = validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 50,
}
],
)
],
extension_config,
)
assert permissions == [
ExtensionPermission(
id="ext.storage.append_public",
description="Append public messages.",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 50,
}
],
)
]
def test_validate_wasm_permissions_rejects_broader_public_append_grant():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{
"id": "ext.storage.append_public",
"policies": [
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message"],
"max_rows_per_source": 100,
}
],
}
],
)
with pytest.raises(ValueError, match="broader policies"):
validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(
id="ext.storage.append_public",
policies=[
{
"table": "messages",
"source_table": "threads",
"source_id_field": "thread_id",
"allowed_fields": ["message", "admin"],
"max_rows_per_source": 101,
}
],
)
],
extension_config,
)
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
@@ -171,6 +308,29 @@ def test_validate_wasm_permissions_allows_wallet_payments_watch_permission():
) == [ExtensionPermission(id="wallet.payments.watch")]
def test_validate_wasm_permissions_allows_websocket_permissions():
ext_info = make_installable_extension("demoext")
extension_config = _wasm_config(
"demoext",
[
{"id": "websocket.publish"},
{"id": "websocket.subscribe"},
],
)
assert validate_wasm_extension_permissions(
ext_info,
[
ExtensionPermission(id="websocket.publish"),
ExtensionPermission(id="websocket.subscribe"),
],
extension_config,
) == [
ExtensionPermission(id="websocket.publish"),
ExtensionPermission(id="websocket.subscribe"),
]
def test_background_payment_grant_lookup_and_policy_coverage():
permissions = {
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
+82 -4
View File
@@ -2,7 +2,7 @@ import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from typing import Any, cast
from uuid import uuid4
import pytest
@@ -22,6 +22,8 @@ from lnbits.core.wasm_ext.storage import crud as storage_crud
from lnbits.core.wasm_ext.storage.crud import (
OWNER_ID_FIELD,
migrate_wasm_extension_database,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
@@ -295,6 +297,59 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
assert deleted is None
@pytest.mark.anyio
async def test_wasm_storage_public_append_generates_id_and_counts_by_owner(
tmp_path: Path,
settings: Settings,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
_write_storage_extension(settings, ext_id)
await migrate_wasm_extension_database(make_installable_extension(ext_id))
await storage_set_row(
ext_id,
"threads",
{"id": "thread-1", "title": "Support"},
"owner-1",
)
message_id = await storage_append_public_row(
ext_id,
"messages",
{"thread_id": "thread-1", "message": "Hello"},
"owner-1",
)
owner_count = await storage_count_rows(
ext_id,
"messages",
{"thread_id": "thread-1"},
owner_id="owner-1",
)
other_owner_count = await storage_count_rows(
ext_id,
"messages",
{"thread_id": "thread-1"},
owner_id="owner-2",
)
message = await storage_get_row(ext_id, "messages", message_id, "owner-1")
finally:
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_data_folder = original_data_folder
assert message_id
assert owner_count == 1
assert other_owner_count == 0
assert message is not None
assert message["id"] == message_id
assert message["thread_id"] == "thread-1"
assert message["message"] == "Hello"
@pytest.mark.anyio
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
tmp_path: Path,
@@ -378,7 +433,7 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
storage_dir = ext_dir / "storage"
migrations_dir = storage_dir / "migrations"
migrations_dir.mkdir(parents=True)
schema = {
schema: dict[str, Any] = {
"tables": {
"notes": {
"fields": [
@@ -389,7 +444,20 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
{"name": "tags", "type": "string", "list": True},
{"name": "created_at", "type": "datetime"},
]
}
},
"threads": {
"fields": [
{"name": "id", "type": "string"},
{"name": "title", "type": "string"},
]
},
"messages": {
"fields": [
{"name": "id", "type": "string"},
{"name": "thread_id", "type": "string"},
{"name": "message", "type": "string"},
]
},
}
}
migration = {
@@ -398,7 +466,17 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
"op": "create_table",
"table": "notes",
"fields": schema["tables"]["notes"]["fields"],
}
},
{
"op": "create_table",
"table": "threads",
"fields": schema["tables"]["threads"]["fields"],
},
{
"op": "create_table",
"table": "messages",
"fields": schema["tables"]["messages"]["fields"],
},
]
}
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")