feat: append public row

This commit is contained in:
Vlad Stan
2026-07-16 11:35:02 +03:00
parent 09c6e7239d
commit 9f317c5edf
11 changed files with 646 additions and 2 deletions
+138
View File
@@ -11,10 +11,14 @@ from lnbits.helpers import sha256s
from ..client.extensions import send_extension_api_request from ..client.extensions import send_extension_api_request
from ..storage.crud import ( from ..storage.crud import (
OWNER_ID_FIELD,
storage_append_public_row,
storage_count_rows,
storage_delete_row, storage_delete_row,
storage_get_paginated_rows, storage_get_paginated_rows,
storage_get_public_row, storage_get_public_row,
storage_get_row, storage_get_row,
storage_get_row_owner_id,
storage_set_row, storage_set_row,
) )
from .background_payments import ( from .background_payments import (
@@ -39,6 +43,8 @@ from .models import (
PayLnurlRequest, PayLnurlRequest,
RandomIdRequest, RandomIdRequest,
RandomIdResponse, RandomIdResponse,
StorageAppendPublicRequest,
StorageAppendPublicResponse,
StorageDeleteRequest, StorageDeleteRequest,
StorageDeleteResponse, StorageDeleteResponse,
StorageGetRequest, StorageGetRequest,
@@ -54,6 +60,7 @@ from .models import (
from .registry import extension_api_method from .registry import extension_api_method
logger = logging.getLogger("lnbits.extensions") logger = logging.getLogger("lnbits.extensions")
PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
class ExtensionHostAPI: class ExtensionHostAPI:
@@ -129,6 +136,45 @@ class ExtensionHostAPI:
# todo: check public fields filtering # todo: check public fields filtering
return StorageGetResponse(data_json=json.dumps(public_row)) 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( @extension_api_method(
method_id="storage.set", method_id="storage.set",
namespace="storage", namespace="storage",
@@ -652,6 +698,98 @@ class ExtensionHostAPI:
raise PermissionError(f"Storage table '{table}' is not publicly readable.") raise PermissionError(f"Storage table '{table}' is not publicly readable.")
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]]: def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
policies = self.permission_policies.get("wallet.create_invoice_public") policies = self.permission_policies.get("wallet.create_invoice_public")
if not isinstance(policies, list) or not policies: if not isinstance(policies, list) or not policies:
+17
View File
@@ -67,6 +67,23 @@ class StorageSetResponse(BaseModel):
ok: bool = True 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): class StoragePaginatedRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128) table: str = Field(..., min_length=1, max_length=128)
filters: dict[str, Any] = Field(default_factory=dict) filters: dict[str, Any] = Field(default_factory=dict)
+65
View File
@@ -10,11 +10,14 @@ from lnbits.core.wasm_ext.wasm.config import (
) )
_POLICY_AWARE_PERMISSION_IDS = { _POLICY_AWARE_PERMISSION_IDS = {
"ext.storage.append_public",
"ext.storage.read_public", "ext.storage.read_public",
"extension.api.request", "extension.api.request",
"http.request", "http.request",
"wallet.create_invoice_public", "wallet.create_invoice_public",
} }
_OWNER_ID_FIELD = "__lnbits_owner_id__"
_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
def validate_extension_permissions( def validate_extension_permissions(
@@ -127,6 +130,10 @@ def _permission_grant_is_subset(
return _http_request_grant_is_subset(requested.policies, granted.policies) return _http_request_grant_is_subset(requested.policies, granted.policies)
if requested.id == "extension.api.request": if requested.id == "extension.api.request":
return _extension_api_grant_is_subset(requested.policies, granted.policies) 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": if requested.id == "ext.storage.read_public":
return _public_storage_grant_is_subset(requested.policies, granted.policies) return _public_storage_grant_is_subset(requested.policies, granted.policies)
if requested.id == "wallet.create_invoice_public": if requested.id == "wallet.create_invoice_public":
@@ -229,6 +236,64 @@ def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]:
return tables 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( def _public_invoice_grant_is_subset(
requested_policies: list[Any] | None, requested_policies: list[Any] | None,
granted_policies: list[Any] | None, granted_policies: list[Any] | None,
+4
View File
@@ -1,5 +1,7 @@
from .crud import ( from .crud import (
migrate_wasm_extension_database, migrate_wasm_extension_database,
storage_append_public_row,
storage_count_rows,
storage_delete_row, storage_delete_row,
storage_get_paginated_rows, storage_get_paginated_rows,
storage_get_public_row, storage_get_public_row,
@@ -10,6 +12,8 @@ from .crud import (
__all__ = [ __all__ = [
"migrate_wasm_extension_database", "migrate_wasm_extension_database",
"storage_append_public_row",
"storage_count_rows",
"storage_delete_row", "storage_delete_row",
"storage_get_paginated_rows", "storage_get_paginated_rows",
"storage_get_public_row", "storage_get_public_row",
+34
View File
@@ -5,6 +5,7 @@ import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4
from loguru import logger from loguru import logger
@@ -110,6 +111,39 @@ async def storage_set_row(
await conn.execute(query, clean_data) 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( async def storage_get_paginated_rows(
ext_id: str, ext_id: str,
table: str, table: str,
+4
View File
@@ -544,6 +544,10 @@ window.localisation.en = {
extension_permission_warning_extension_api_request_write: extension_permission_warning_extension_api_request_write:
'Can write data or trigger actions in approved extensions.', 'Can write data or trigger actions in approved extensions.',
extension_permission_ext_storage_read: 'Read extension storage', 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: 'Read public extension storage',
extension_permission_ext_storage_write: 'Write extension storage', extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_ext_storage_read_write: 'Read & Write extension storage', extension_permission_ext_storage_read_write: 'Read & Write extension storage',
@@ -115,6 +115,7 @@
'wallet.list', 'wallet.list',
'wallet.balance.read', 'wallet.balance.read',
'wallet.create_invoice_public', 'wallet.create_invoice_public',
'ext.storage.append_public',
'ext.storage.read_public' 'ext.storage.read_public'
].includes(permission.id) ].includes(permission.id)
) { ) {
@@ -145,6 +146,7 @@
'ext.storage.read', 'ext.storage.read',
'ext.storage.write', 'ext.storage.write',
'ext.storage.read_public', 'ext.storage.read_public',
'ext.storage.append_public',
'wallet.create_invoice_public', 'wallet.create_invoice_public',
'wallet.create_invoice', 'wallet.create_invoice',
'utils.basic' 'utils.basic'
@@ -194,6 +196,37 @@
.filter(Boolean) .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) { function permissionDisplayItem(permissions, extensions, translateFn) {
const permission = permissions[0] const permission = permissions[0]
const isReadWriteStorage = const isReadWriteStorage =
@@ -212,6 +245,7 @@
badges: [], badges: [],
descriptions, descriptions,
fieldGroups: [], fieldGroups: [],
appendPolicies: [],
invoicePolicies: [], invoicePolicies: [],
extensionAccess: [], extensionAccess: [],
httpHosts: [] httpHosts: []
@@ -244,6 +278,15 @@
item.invoicePolicies = publicInvoicePolicies(permission) 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 return item
} }
@@ -327,6 +370,9 @@
publicInvoicePolicySentence(policy) { publicInvoicePolicySentence(policy) {
return `Invoices will be created using ${policy.walletField} from ${policy.table}.` 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) { permissionAccessLabel(access) {
const key = `extension_permission_access_${access}` const key = `extension_permission_access_${access}`
const label = this.$t(key) const label = this.$t(key)
@@ -67,6 +67,35 @@
</ul> </ul>
</li> </li>
</ul> </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 v-if="permission.extensionAccess.length" class="q-mt-sm">
<div <div
class="text-caption text-grey" class="text-caption text-grey"
+131
View File
@@ -10,6 +10,7 @@ from lnbits.core.wasm_ext.api.models import (
CreateInvoicePublicRequest, CreateInvoicePublicRequest,
EmptyRequest, EmptyRequest,
PayInvoiceRequest, PayInvoiceRequest,
StorageAppendPublicRequest,
StorageGetRequest, StorageGetRequest,
WalletBalanceRequest, WalletBalanceRequest,
) )
@@ -77,6 +78,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 @pytest.mark.anyio
async def test_host_api_wallet_methods_require_permissions_and_user_wallets( async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
mocker: MockerFixture, mocker: MockerFixture,
@@ -102,6 +102,104 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
] ]
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(): def test_validate_wasm_permissions_rejects_broader_extension_api_access():
ext_info = make_installable_extension("demoext") ext_info = make_installable_extension("demoext")
extension_config = _wasm_config( extension_config = _wasm_config(
+80 -2
View File
@@ -22,6 +22,8 @@ from lnbits.core.wasm_ext.storage import crud as storage_crud
from lnbits.core.wasm_ext.storage.crud import ( from lnbits.core.wasm_ext.storage.crud import (
OWNER_ID_FIELD, OWNER_ID_FIELD,
migrate_wasm_extension_database, migrate_wasm_extension_database,
storage_append_public_row,
storage_count_rows,
storage_delete_row, storage_delete_row,
storage_get_paginated_rows, storage_get_paginated_rows,
storage_get_public_row, storage_get_public_row,
@@ -295,6 +297,59 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
assert deleted is None 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 @pytest.mark.anyio
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers( async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
tmp_path: Path, tmp_path: Path,
@@ -389,7 +444,20 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
{"name": "tags", "type": "string", "list": True}, {"name": "tags", "type": "string", "list": True},
{"name": "created_at", "type": "datetime"}, {"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 = { migration = {
@@ -398,7 +466,17 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
"op": "create_table", "op": "create_table",
"table": "notes", "table": "notes",
"fields": schema["tables"]["notes"]["fields"], "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") (storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")