feat: add wallet list
This commit is contained in:
@@ -21,6 +21,7 @@ from .models import (
|
|||||||
KvListResponse,
|
KvListResponse,
|
||||||
KvSetRequest,
|
KvSetRequest,
|
||||||
KvSetResponse,
|
KvSetResponse,
|
||||||
|
ListUserWalletsResponse,
|
||||||
LogRequest,
|
LogRequest,
|
||||||
LogResponse,
|
LogResponse,
|
||||||
NowResponse,
|
NowResponse,
|
||||||
@@ -173,6 +174,20 @@ class ExtensionAPI:
|
|||||||
) -> CreateInvoiceResponse:
|
) -> CreateInvoiceResponse:
|
||||||
self._raise_unwired_runtime("wallet_create_invoice")
|
self._raise_unwired_runtime("wallet_create_invoice")
|
||||||
|
|
||||||
|
@extension_api_method(
|
||||||
|
method_id="wallet.list_user_wallets",
|
||||||
|
namespace="wallet",
|
||||||
|
name="List user wallets",
|
||||||
|
host_name="list_user_wallets",
|
||||||
|
sdk_name="listUserWallets",
|
||||||
|
description="List wallets available to the authenticated extension user.",
|
||||||
|
required_permission="wallet.list",
|
||||||
|
)
|
||||||
|
async def wallet_list_user_wallets(
|
||||||
|
self, request: EmptyRequest
|
||||||
|
) -> ListUserWalletsResponse:
|
||||||
|
self._raise_unwired_runtime("wallet_list_user_wallets")
|
||||||
|
|
||||||
@extension_api_method(
|
@extension_api_method(
|
||||||
method_id="payments.watch",
|
method_id="payments.watch",
|
||||||
namespace="payments",
|
namespace="payments",
|
||||||
|
|||||||
@@ -69,12 +69,31 @@ def _select_methods(
|
|||||||
|
|
||||||
def _collect_models(methods: Sequence[ExtensionAPIMethod]) -> list[type[BaseModel]]:
|
def _collect_models(methods: Sequence[ExtensionAPIMethod]) -> list[type[BaseModel]]:
|
||||||
models: dict[str, type[BaseModel]] = {}
|
models: dict[str, type[BaseModel]] = {}
|
||||||
for method in methods:
|
pending = [
|
||||||
models[method.request_model.__name__] = method.request_model
|
model
|
||||||
models[method.response_model.__name__] = method.response_model
|
for method in methods
|
||||||
|
for model in (method.request_model, method.response_model)
|
||||||
|
]
|
||||||
|
|
||||||
|
while pending:
|
||||||
|
model = pending.pop()
|
||||||
|
if model.__name__ in models:
|
||||||
|
continue
|
||||||
|
models[model.__name__] = model
|
||||||
|
for field in model.__fields__.values():
|
||||||
|
pending.extend(_nested_model_types(field.outer_type_))
|
||||||
return [models[name] for name in sorted(models)]
|
return [models[name] for name in sorted(models)]
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_model_types(type_: Any) -> list[type[BaseModel]]:
|
||||||
|
models: list[type[BaseModel]] = []
|
||||||
|
if _is_model_type(type_):
|
||||||
|
models.append(type_)
|
||||||
|
for arg in get_args(type_):
|
||||||
|
models.extend(_nested_model_types(arg))
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
def _render_model_type(model: type[BaseModel]) -> list[str]:
|
def _render_model_type(model: type[BaseModel]) -> list[str]:
|
||||||
name = _model_name(model)
|
name = _model_name(model)
|
||||||
fields = model.__fields__
|
fields = model.__fields__
|
||||||
@@ -105,7 +124,9 @@ def _python_type_to_ts(type_: Any, allow_none: bool = False) -> str:
|
|||||||
if origin is Literal:
|
if origin is Literal:
|
||||||
return " | ".join(_literal_to_ts(arg) for arg in args)
|
return " | ".join(_literal_to_ts(arg) for arg in args)
|
||||||
|
|
||||||
if origin in (list, Sequence):
|
if _is_model_type(type_):
|
||||||
|
ts = _model_name(type_)
|
||||||
|
elif origin in (list, Sequence):
|
||||||
item_type = _python_type_to_ts(args[0]) if args else "unknown"
|
item_type = _python_type_to_ts(args[0]) if args else "unknown"
|
||||||
ts = f"{item_type}[]"
|
ts = f"{item_type}[]"
|
||||||
elif origin is dict:
|
elif origin is dict:
|
||||||
@@ -145,6 +166,10 @@ def _is_subclass(type_: Any, class_: type) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_model_type(type_: Any) -> bool:
|
||||||
|
return _is_subclass(type_, BaseModel)
|
||||||
|
|
||||||
|
|
||||||
def _render_method_metadata(
|
def _render_method_metadata(
|
||||||
methods: Sequence[ExtensionAPIMethod],
|
methods: Sequence[ExtensionAPIMethod],
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
|||||||
@@ -192,16 +192,21 @@ def _add_wasm_extension_api_route(
|
|||||||
if _has_route(app, route_path, method):
|
if _has_route(app, route_path, method):
|
||||||
return
|
return
|
||||||
|
|
||||||
async def invoke_wasm_extension_export(request: Request) -> dict[str, Any]:
|
require_user = _require_wasm_user_extension(extension.id)
|
||||||
from .wasm import invoke_wasm_extension_export as invoke_export
|
|
||||||
|
async def invoke_wasm_api_request(
|
||||||
|
request: Request, user: Any | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from .wasm import invoke_wasm_extension_export as invoke_wasm_export
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = await _read_api_payload(request, path_params)
|
payload = await _read_api_payload(request, path_params)
|
||||||
return await invoke_export(
|
return await invoke_wasm_export(
|
||||||
app,
|
app,
|
||||||
extension.id,
|
extension.id,
|
||||||
export_name,
|
export_name,
|
||||||
payload,
|
payload,
|
||||||
|
user=user,
|
||||||
)
|
)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
@@ -210,12 +215,24 @@ def _add_wasm_extension_api_route(
|
|||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
async def invoke_private_wasm_extension_export(
|
||||||
|
request: Request,
|
||||||
|
user: Any = Depends(require_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await invoke_wasm_api_request(request, user)
|
||||||
|
|
||||||
|
async def invoke_public_wasm_extension_export(request: Request) -> dict[str, Any]:
|
||||||
|
return await invoke_wasm_api_request(request)
|
||||||
|
|
||||||
app.add_api_route(
|
app.add_api_route(
|
||||||
route_path,
|
route_path,
|
||||||
invoke_wasm_extension_export,
|
(
|
||||||
|
invoke_public_wasm_extension_export
|
||||||
|
if auth == "public"
|
||||||
|
else invoke_private_wasm_extension_export
|
||||||
|
),
|
||||||
methods=[method],
|
methods=[method],
|
||||||
name=f"{extension.id}:{method}:{route_path}",
|
name=f"{extension.id}:{method}:{route_path}",
|
||||||
dependencies=_wasm_extension_dependencies(extension, auth),
|
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -362,14 +379,8 @@ def _add_wasm_extension_frame_route(
|
|||||||
async def serve_wasm_extension_frame(request: Request) -> FileResponse:
|
async def serve_wasm_extension_frame(request: Request) -> FileResponse:
|
||||||
_consume_wasm_extension_frame_token(request, extension, frame_path)
|
_consume_wasm_extension_frame_token(request, extension, frame_path)
|
||||||
response = FileResponse(entrypoint)
|
response = FileResponse(entrypoint)
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = _wasm_extension_frame_csp(
|
||||||
"sandbox allow-scripts allow-forms; "
|
request, extension
|
||||||
"default-src 'self' data: blob:; "
|
|
||||||
"connect-src 'none'; "
|
|
||||||
"form-action 'none'; "
|
|
||||||
"object-src 'none'; "
|
|
||||||
"base-uri 'none'; "
|
|
||||||
"frame-ancestors 'self'"
|
|
||||||
)
|
)
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
||||||
@@ -423,6 +434,31 @@ def _wasm_extension_wrapper_response(
|
|||||||
return response
|
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}/"
|
||||||
|
static_assets = f"{origin}/static/"
|
||||||
|
return (
|
||||||
|
"sandbox allow-scripts; "
|
||||||
|
"default-src 'none'; "
|
||||||
|
f"script-src {extension_assets}; "
|
||||||
|
"script-src-attr 'none'; "
|
||||||
|
f"style-src {extension_assets} {static_assets}; "
|
||||||
|
"style-src-attr 'none'; "
|
||||||
|
f"img-src {extension_assets} {static_assets}; "
|
||||||
|
f"font-src {static_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) -> str:
|
def _wasm_extension_frame_url(extension: WasmExtension, frame_path: str) -> str:
|
||||||
token = _create_wasm_extension_frame_token(extension, frame_path)
|
token = _create_wasm_extension_frame_token(extension, frame_path)
|
||||||
return f"{frame_path}?frame_token={token}"
|
return f"{frame_path}?frame_token={token}"
|
||||||
@@ -506,15 +542,6 @@ def _path_template_pattern(path: str) -> str:
|
|||||||
return f"^{pattern}$"
|
return f"^{pattern}$"
|
||||||
|
|
||||||
|
|
||||||
def _wasm_extension_dependencies(
|
|
||||||
extension: WasmExtension,
|
|
||||||
auth: str,
|
|
||||||
) -> list[Any]:
|
|
||||||
if auth == "public":
|
|
||||||
return []
|
|
||||||
return [Depends(_require_wasm_user_extension(extension.id))]
|
|
||||||
|
|
||||||
|
|
||||||
def _require_wasm_user_extension(ext_id: str) -> Any:
|
def _require_wasm_user_extension(ext_id: str) -> Any:
|
||||||
async def require_wasm_user_extension(
|
async def require_wasm_user_extension(
|
||||||
user: Any = Depends(check_user_exists),
|
user: Any = Depends(check_user_exists),
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ class CreateInvoiceResponse(BaseModel):
|
|||||||
checking_id: str
|
checking_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserWalletSummary(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
currency: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ListUserWalletsResponse(BaseModel):
|
||||||
|
wallets: list[UserWalletSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class WatchPaymentRequest(BaseModel):
|
class WatchPaymentRequest(BaseModel):
|
||||||
payment_hash: str = Field(..., min_length=1, max_length=128)
|
payment_hash: str = Field(..., min_length=1, max_length=128)
|
||||||
callback_export: str = Field(..., min_length=1, max_length=128)
|
callback_export: str = Field(..., min_length=1, max_length=128)
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ from .api import ExtensionAPI
|
|||||||
from .models import (
|
from .models import (
|
||||||
CreateInvoiceRequest,
|
CreateInvoiceRequest,
|
||||||
CreateInvoiceResponse,
|
CreateInvoiceResponse,
|
||||||
|
EmptyRequest,
|
||||||
KvGetRequest,
|
KvGetRequest,
|
||||||
KvGetResponse,
|
KvGetResponse,
|
||||||
KvListRequest,
|
KvListRequest,
|
||||||
KvListResponse,
|
KvListResponse,
|
||||||
KvSetRequest,
|
KvSetRequest,
|
||||||
KvSetResponse,
|
KvSetResponse,
|
||||||
|
ListUserWalletsResponse,
|
||||||
|
UserWalletSummary,
|
||||||
WatchPaymentRequest,
|
WatchPaymentRequest,
|
||||||
WatchPaymentResponse,
|
WatchPaymentResponse,
|
||||||
)
|
)
|
||||||
@@ -35,6 +38,7 @@ class InMemoryExtensionAPI(ExtensionAPI):
|
|||||||
state: InMemoryExtensionState | None = None,
|
state: InMemoryExtensionState | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
wallet_id: str | None = None,
|
wallet_id: str | None = None,
|
||||||
|
user_wallets: list[UserWalletSummary] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
extension_id,
|
extension_id,
|
||||||
@@ -43,6 +47,7 @@ class InMemoryExtensionAPI(ExtensionAPI):
|
|||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
)
|
)
|
||||||
self.state = state or InMemoryExtensionState()
|
self.state = state or InMemoryExtensionState()
|
||||||
|
self.user_wallets = list(user_wallets) if user_wallets is not None else None
|
||||||
|
|
||||||
async def storage_get(self, request: KvGetRequest) -> KvGetResponse:
|
async def storage_get(self, request: KvGetRequest) -> KvGetResponse:
|
||||||
self.require_permission("ext.storage.read_write")
|
self.require_permission("ext.storage.read_write")
|
||||||
@@ -77,6 +82,16 @@ class InMemoryExtensionAPI(ExtensionAPI):
|
|||||||
checking_id=f"{self.extension_id}:{payment_hash}",
|
checking_id=f"{self.extension_id}:{payment_hash}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def wallet_list_user_wallets(
|
||||||
|
self, _request: EmptyRequest
|
||||||
|
) -> ListUserWalletsResponse:
|
||||||
|
self.require_permission("wallet.list")
|
||||||
|
if self.user_wallets is None:
|
||||||
|
raise PermissionError(
|
||||||
|
"Listing user wallets requires an authenticated user context."
|
||||||
|
)
|
||||||
|
return ListUserWalletsResponse(wallets=self.user_wallets)
|
||||||
|
|
||||||
async def payments_watch(
|
async def payments_watch(
|
||||||
self, request: WatchPaymentRequest
|
self, request: WatchPaymentRequest
|
||||||
) -> WatchPaymentResponse:
|
) -> WatchPaymentResponse:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from fastapi import FastAPI
|
|||||||
|
|
||||||
from .api import list_extension_api_methods
|
from .api import list_extension_api_methods
|
||||||
from .loader import WasmExtension, register_wasm_extension
|
from .loader import WasmExtension, register_wasm_extension
|
||||||
|
from .models import UserWalletSummary
|
||||||
from .prototype import InMemoryExtensionAPI, InMemoryExtensionState
|
from .prototype import InMemoryExtensionAPI, InMemoryExtensionState
|
||||||
from .runtime import ExtensionAPIHost
|
from .runtime import ExtensionAPIHost
|
||||||
|
|
||||||
@@ -20,11 +21,19 @@ async def invoke_wasm_extension_export(
|
|||||||
ext_id: str,
|
ext_id: str,
|
||||||
export_name: str,
|
export_name: str,
|
||||||
payload: Mapping[str, Any] | None = None,
|
payload: Mapping[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
user: Any | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
extension = _get_registered_extension(app, ext_id)
|
extension = _get_registered_extension(app, ext_id)
|
||||||
state = _get_extension_state(app)
|
state = _get_extension_state(app)
|
||||||
permissions = _extension_permissions(extension)
|
permissions = _extension_permissions(extension)
|
||||||
api = InMemoryExtensionAPI(extension.id, permissions, state=state)
|
api = InMemoryExtensionAPI(
|
||||||
|
extension.id,
|
||||||
|
permissions,
|
||||||
|
state=state,
|
||||||
|
user_id=_user_id(user),
|
||||||
|
user_wallets=_user_wallet_summaries(user),
|
||||||
|
)
|
||||||
|
|
||||||
return await asyncio.to_thread(
|
return await asyncio.to_thread(
|
||||||
_invoke_wasm_extension_export_sync,
|
_invoke_wasm_extension_export_sync,
|
||||||
@@ -143,10 +152,18 @@ def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
|
|||||||
|
|
||||||
record = component.Record()
|
record = component.Record()
|
||||||
for key, item in value.items():
|
for key, item in value.items():
|
||||||
setattr(record, _camel_to_kebab(key), item)
|
setattr(record, _camel_to_kebab(key), _to_component_value(item))
|
||||||
return record
|
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]:
|
def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]:
|
||||||
if isinstance(value, bytes):
|
if isinstance(value, bytes):
|
||||||
value = value.decode()
|
value = value.decode()
|
||||||
@@ -195,5 +212,31 @@ def _extension_permissions(extension: WasmExtension) -> set[str]:
|
|||||||
return permissions
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id(user: Any | None) -> str | None:
|
||||||
|
return getattr(user, "id", None) if user else None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_wallet_summaries(user: Any | None) -> list[UserWalletSummary] | None:
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
summaries: list[UserWalletSummary] = []
|
||||||
|
for wallet in getattr(user, "wallets", []) or []:
|
||||||
|
if not getattr(wallet, "can_receive_payments", False):
|
||||||
|
continue
|
||||||
|
wallet_id = getattr(wallet, "id", None)
|
||||||
|
wallet_name = getattr(wallet, "name", None)
|
||||||
|
if not wallet_id or not wallet_name:
|
||||||
|
continue
|
||||||
|
summaries.append(
|
||||||
|
UserWalletSummary(
|
||||||
|
id=wallet_id,
|
||||||
|
name=wallet_name,
|
||||||
|
currency=getattr(wallet, "currency", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
def _camel_to_kebab(value: str) -> str:
|
def _camel_to_kebab(value: str) -> str:
|
||||||
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
|
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
class="wasm-extension-frame"
|
class="wasm-extension-frame"
|
||||||
data-frame-url="{{ frame_url }}"
|
data-frame-url="{{ frame_url }}"
|
||||||
title="{{ extension.name }}"
|
title="{{ extension.name }}"
|
||||||
sandbox="allow-scripts allow-forms"
|
sandbox="allow-scripts"
|
||||||
allow="clipboard-write"
|
allow="clipboard-write"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|||||||
Reference in New Issue
Block a user