diff --git a/octobot/community/authentication.py b/octobot/community/authentication.py index 22b11ddbfc..7684eb8322 100644 --- a/octobot/community/authentication.py +++ b/octobot/community/authentication.py @@ -692,6 +692,20 @@ def authenticate_wallet(self, address: str, passphrase: str) -> dict: def verify_wallet_passphrase(self, address: str, passphrase: str) -> bool: return self._wallet_backend.verify_wallet_passphrase(address, passphrase) + def recover_passphrase_from_ownership_proof( + self, + address: str, + new_passphrase: str, + seed: typing.Optional[str] = None, + private_key: typing.Optional[str] = None, + ) -> None: + return self._wallet_backend.recover_passphrase_from_ownership_proof( + address, + new_passphrase, + seed=seed, + private_key=private_key, + ) + def decrypt_wallet_by_address(self, address: str, passphrase: str): return self._wallet_backend.decrypt_wallet_by_address(address, passphrase) diff --git a/octobot/community/wallet_backend/__init__.py b/octobot/community/wallet_backend/__init__.py index 4216a560d9..8b6f73990e 100644 --- a/octobot/community/wallet_backend/__init__.py +++ b/octobot/community/wallet_backend/__init__.py @@ -31,6 +31,8 @@ CannotRemoveAdminWalletError, InvalidPrivateKeyError, PassphraseTooShortError, + WalletProofMismatchError, + WalletStorageReadOnlyError, ) from octobot.community.wallet_backend import wallet_storage from octobot.community.wallet_backend.wallet_storage import ( @@ -54,6 +56,8 @@ "CannotRemoveAdminWalletError", "InvalidPrivateKeyError", "PassphraseTooShortError", + "WalletProofMismatchError", + "WalletStorageReadOnlyError", "WalletStorage", "ConfigJsonWalletStorage", "DedicatedFileWalletStorage", diff --git a/octobot/community/wallet_backend/community_wallet.py b/octobot/community/wallet_backend/community_wallet.py index effa755507..fe11f578be 100644 --- a/octobot/community/wallet_backend/community_wallet.py +++ b/octobot/community/wallet_backend/community_wallet.py @@ -34,6 +34,8 @@ WalletAlreadyExistsError, WalletError, WalletNotFoundError, + WalletProofMismatchError, + WalletStorageReadOnlyError, ) from octobot.community.wallet_backend.wallet_storage import ( WalletStorage, @@ -323,3 +325,72 @@ def is_admin_wallet(self, address: str) -> bool: def get_wallet_name(self, address: str) -> typing.Optional[str]: entry = self._find_wallet_entry(address) return entry.name if entry else None + + def recover_passphrase_from_ownership_proof( + self, + address: str, + new_passphrase: str, + seed: typing.Optional[str] = None, + private_key: typing.Optional[str] = None, + ) -> None: + """Replace passphrase_hash after proving ownership via BIP39 seed or hex private key.""" + if len(new_passphrase) < 8: + raise PassphraseTooShortError("Passphrase must be at least 8 characters") + + seed_value = seed.strip() if seed else "" + key_value = private_key.strip() if private_key else "" + if bool(seed_value) == bool(key_value): + raise InvalidPrivateKeyError( + "Provide exactly one of seed phrase or private key" + ) + + normalized_target = address.lower() + if self._find_wallet_entry(address) is None: + wallet_error = WalletNotFoundError(f"Wallet {address} not found") + _record_wallet_operation_failure(operation="recover_passphrase", error=wallet_error) + raise wallet_error + + try: + if seed_value: + derived = sync_chain.wallet_from_mnemonic(seed_value) + else: + derived_address = sync_chain.address_from_evm_key(key_value) + derived = sync_chain.Wallet(private_key=key_value, address=derived_address) + except Exception as err: + raise InvalidPrivateKeyError("Invalid seed phrase or private key") from err + + if derived.address.lower() != normalized_target: + raise WalletProofMismatchError( + "Seed phrase or private key does not match this wallet" + ) + + new_hash = _hash_passphrase(new_passphrase) + with self._wallet_lock: + node_wallets = self._get_node_wallets_list() + updated: list[WalletEntry] = [] + found = False + for entry in node_wallets: + if entry.address == normalized_target: + found = True + updated.append( + WalletEntry( + address=entry.address, + name=entry.name, + is_admin=entry.is_admin, + private_key=entry.private_key, + passphrase_hash=new_hash, + seed=entry.seed, + ) + ) + else: + updated.append(entry) + if not found: + wallet_error = WalletNotFoundError(f"Wallet {address} not found") + _record_wallet_operation_failure(operation="recover_passphrase", error=wallet_error) + raise wallet_error + try: + self._save_node_wallets_list(updated) + except NotImplementedError as err: + raise WalletStorageReadOnlyError( + "Wallet storage is read-only; passphrase cannot be changed on this node" + ) from err diff --git a/octobot/community/wallet_backend/errors.py b/octobot/community/wallet_backend/errors.py index c523c4fbde..a9f0371348 100644 --- a/octobot/community/wallet_backend/errors.py +++ b/octobot/community/wallet_backend/errors.py @@ -49,3 +49,11 @@ class InvalidPrivateKeyError(WalletError): class PassphraseTooShortError(WalletError): pass + + +class WalletProofMismatchError(WalletError): + pass + + +class WalletStorageReadOnlyError(WalletError): + pass diff --git a/packages/commons/octobot_commons/in_process_rate_limit.py b/packages/commons/octobot_commons/in_process_rate_limit.py new file mode 100644 index 0000000000..100af106d3 --- /dev/null +++ b/packages/commons/octobot_commons/in_process_rate_limit.py @@ -0,0 +1,143 @@ +# Drakkar-Software OctoBot-Commons +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +import dataclasses +import threading +import time +import typing + +# RAM-only counters in this process. Not shared across workers, pods, or nodes. + + +@dataclasses.dataclass(frozen=True) +class FailureWindowPolicy: + """One failure-count window for a named request dimension (e.g. client IP).""" + + name: str + max_failures: int + window_seconds: float + normalize_key: typing.Callable[[str], str] = lambda key: key + + +@dataclasses.dataclass +class _FailureBucket: + failure_count: int = 0 + window_start: float = 0.0 + + +class InProcessFailureRateLimiter: + """In-process, multi-dimension failure rate limiter (fixed window per key).""" + + def __init__(self, policies: tuple[FailureWindowPolicy, ...]) -> None: + if not policies: + raise ValueError("At least one FailureWindowPolicy is required") + self._policies = policies + self._lock = threading.Lock() + self._stores: dict[str, dict[str, _FailureBucket]] = { + policy.name: {} for policy in policies + } + + def reset_all(self) -> None: + """Clear all failure counters for every policy dimension.""" + with self._lock: + for store in self._stores.values(): + store.clear() + + def _policy_value(self, policy: FailureWindowPolicy, **dimensions: str) -> str: + try: + raw = dimensions[policy.name] + except KeyError: + raise KeyError( + f"Missing rate-limit dimension '{policy.name}'" + ) from None + return policy.normalize_key(raw) + + def _is_limited_for_policy( + self, + policy: FailureWindowPolicy, + key: str, + now: float, + ) -> bool: + store = self._stores[policy.name] + bucket = store.get(key) + if bucket is None or now - bucket.window_start >= policy.window_seconds: + store[key] = _FailureBucket(failure_count=0, window_start=now) + return False + return bucket.failure_count >= policy.max_failures + + def _remaining_seconds_for_policy( + self, + policy: FailureWindowPolicy, + key: str, + now: float, + ) -> float: + """Read-only: seconds until this policy's window ends, or 0 if not limited.""" + store = self._stores[policy.name] + bucket = store.get(key) + if bucket is None: + return 0.0 + if now - bucket.window_start >= policy.window_seconds: + return 0.0 + if bucket.failure_count < policy.max_failures: + return 0.0 + remaining = bucket.window_start + policy.window_seconds - now + if remaining <= 0.0: + return 0.0 + return remaining + + def retry_after_seconds(self, **dimensions: str) -> float: + """Monotonic seconds until the strictest active limit expires; 0 if not limited.""" + now = time.monotonic() + max_remaining = 0.0 + with self._lock: + for policy in self._policies: + key = self._policy_value(policy, **dimensions) + remaining = self._remaining_seconds_for_policy(policy, key, now) + max_remaining = max(max_remaining, remaining) + return max_remaining + + def is_rate_limited(self, **dimensions: str) -> bool: + """Return True when any policy dimension has reached its failure budget.""" + now = time.monotonic() + with self._lock: + for policy in self._policies: + key = self._policy_value(policy, **dimensions) + if self._is_limited_for_policy(policy, key, now): + return True + return False + + def record_failure(self, **dimensions: str) -> None: + """Increment failure counts for all policy dimensions.""" + now = time.monotonic() + with self._lock: + for policy in self._policies: + key = self._policy_value(policy, **dimensions) + store = self._stores[policy.name] + bucket = store.get(key) + if ( + bucket is None + or now - bucket.window_start >= policy.window_seconds + ): + bucket = _FailureBucket(failure_count=0, window_start=now) + store[key] = bucket + bucket.failure_count += 1 + + def record_success(self, **dimensions: str) -> None: + """Clear failure counters for the given dimension keys.""" + with self._lock: + for policy in self._policies: + key = self._policy_value(policy, **dimensions) + self._stores[policy.name].pop(key, None) diff --git a/packages/commons/tests/test_in_process_rate_limit.py b/packages/commons/tests/test_in_process_rate_limit.py new file mode 100644 index 0000000000..ff5ff683f0 --- /dev/null +++ b/packages/commons/tests/test_in_process_rate_limit.py @@ -0,0 +1,220 @@ +# Drakkar-Software OctoBot-Commons +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. + +import mock +import pytest + +import octobot_commons.in_process_rate_limit as in_process_rate_limit + +_MONOTONIC_PATCH = "octobot_commons.in_process_rate_limit.time.monotonic" + + +def _client_ip_policy(**overrides): + policy_kwargs = { + "name": "client_ip", + "max_failures": 3, + "window_seconds": 60.0, + } + policy_kwargs.update(overrides) + return in_process_rate_limit.FailureWindowPolicy(**policy_kwargs) + + +def _address_policy(**overrides): + policy_kwargs = { + "name": "address", + "max_failures": 3, + "window_seconds": 60.0, + "normalize_key": str.lower, + } + policy_kwargs.update(overrides) + return in_process_rate_limit.FailureWindowPolicy(**policy_kwargs) + + +def _limiter(*policies): + return in_process_rate_limit.InProcessFailureRateLimiter(policies) + + +def _dual_policy_limiter(): + return _limiter( + _client_ip_policy(max_failures=2), + _address_policy(max_failures=4), + ) + + +class TestFailureWindowPolicyNormalizeKey: + def test_normalizes_keys_per_policy(self): + limiter = _limiter( + _address_policy(max_failures=2, window_seconds=60), + ) + limiter.record_failure(address="0xAbC") + limiter.record_failure(address="0xabc") + assert limiter.is_rate_limited(address="0xABC") is True + + +class TestInProcessFailureRateLimiterInit: + def test_raises_when_no_policies(self): + with pytest.raises(ValueError): + in_process_rate_limit.InProcessFailureRateLimiter(()) + + +class TestInProcessFailureRateLimiterMissingDimension: + def test_is_rate_limited_raises_key_error(self): + limiter = _limiter(_client_ip_policy()) + with pytest.raises(KeyError): + limiter.is_rate_limited() + + def test_record_failure_raises_key_error(self): + limiter = _limiter(_client_ip_policy()) + with pytest.raises(KeyError): + limiter.record_failure() + + def test_record_success_raises_key_error(self): + limiter = _limiter(_client_ip_policy()) + with pytest.raises(KeyError): + limiter.record_success() + + def test_retry_after_seconds_raises_key_error(self): + limiter = _limiter(_client_ip_policy()) + with pytest.raises(KeyError): + limiter.retry_after_seconds() + + +class TestInProcessFailureRateLimiterRetryAfterSeconds: + def test_zero_when_not_limited(self): + limiter = _limiter(_client_ip_policy(max_failures=3)) + assert limiter.retry_after_seconds(client_ip="1.2.3.4") == 0.0 + limiter.record_failure(client_ip="1.2.3.4") + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.retry_after_seconds(client_ip="1.2.3.4") == 0.0 + + def test_decay_over_window(self): + limiter = _limiter(_client_ip_policy(max_failures=2, window_seconds=10)) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0): + limiter.record_failure(client_ip="1.2.3.4") + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.retry_after_seconds(client_ip="1.2.3.4") == pytest.approx(10.0) + with mock.patch(_MONOTONIC_PATCH, return_value=5.0): + assert limiter.retry_after_seconds(client_ip="1.2.3.4") == pytest.approx(5.0) + with mock.patch(_MONOTONIC_PATCH, return_value=11.0): + assert limiter.retry_after_seconds(client_ip="1.2.3.4") == 0.0 + + def test_multi_policy_returns_max_remaining(self): + short_policy = _client_ip_policy(max_failures=2, window_seconds=10) + long_policy = _address_policy(max_failures=2, window_seconds=100) + limiter = _limiter(short_policy, long_policy) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0): + limiter.record_failure(client_ip="1.2.3.4", address="0xaaa") + limiter.record_failure(client_ip="1.2.3.4", address="0xaaa") + remaining = limiter.retry_after_seconds(client_ip="1.2.3.4", address="0xaaa") + assert remaining == pytest.approx(100.0) + + def test_does_not_reset_buckets(self): + limiter = _limiter(_client_ip_policy(max_failures=2, window_seconds=10)) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0): + limiter.record_failure(client_ip="1.2.3.4") + limiter.record_failure(client_ip="1.2.3.4") + limiter.retry_after_seconds(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is True + + +class TestInProcessFailureRateLimiterFailureBudget: + def test_not_limited_below_max_failures(self): + limiter = _limiter(_client_ip_policy(max_failures=3)) + limiter.record_failure(client_ip="1.2.3.4") + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is False + + def test_limited_at_max_failures(self): + limiter = _limiter(_client_ip_policy(max_failures=3)) + for _ in range(3): + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is True + + def test_is_rate_limited_does_not_increment(self): + limiter = _limiter(_client_ip_policy(max_failures=3)) + for _ in range(3): + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is True + assert limiter.is_rate_limited(client_ip="1.2.3.4") is True + + +class TestInProcessFailureRateLimiterMultiPolicy: + def test_limited_when_client_ip_exceeds_budget(self): + limiter = _dual_policy_limiter() + limiter.record_failure(client_ip="1.2.3.4", address="0xaaa") + limiter.record_failure(client_ip="1.2.3.4", address="0xbbb") + assert limiter.is_rate_limited(client_ip="1.2.3.4", address="0xccc") is True + + def test_limited_when_address_exceeds_budget(self): + limiter = _dual_policy_limiter() + address = "0xdef" + for index in range(4): + limiter.record_failure(client_ip=f"10.0.0.{index}", address=address) + assert limiter.is_rate_limited(client_ip="10.0.0.99", address=address) is True + + def test_record_failure_updates_all_policy_stores(self): + limiter = _dual_policy_limiter() + limiter.record_failure(client_ip="1.2.3.4", address="0xaaa") + limiter.record_failure(client_ip="1.2.3.4", address="0xbbb") + assert limiter.is_rate_limited(client_ip="1.2.3.4", address="0xaaa") is True + assert limiter.is_rate_limited(client_ip="9.9.9.9", address="0xaaa") is False + + +class TestInProcessFailureRateLimiterRecordSuccess: + def test_clears_buckets_for_given_keys(self): + limiter = _limiter(_client_ip_policy(max_failures=3)) + limiter.record_failure(client_ip="1.2.3.4") + limiter.record_failure(client_ip="1.2.3.4") + limiter.record_success(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is False + for _ in range(3): + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is True + + def test_success_on_one_address_does_not_clear_other_address(self): + limiter = _limiter(_address_policy(max_failures=3)) + limiter.record_failure(address="0xaaa") + limiter.record_failure(address="0xaaa") + limiter.record_failure(address="0xbbb") + limiter.record_failure(address="0xbbb") + limiter.record_success(address="0xaaa") + assert limiter.is_rate_limited(address="0xaaa") is False + assert limiter.is_rate_limited(address="0xbbb") is False + limiter.record_failure(address="0xbbb") + assert limiter.is_rate_limited(address="0xbbb") is True + + +class TestInProcessFailureRateLimiterResetAll: + def test_clears_every_policy_and_key(self): + limiter = _limiter(_client_ip_policy(max_failures=2)) + limiter.record_failure(client_ip="1.2.3.4") + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is True + limiter.reset_all() + assert limiter.is_rate_limited(client_ip="1.2.3.4") is False + + +class TestInProcessFailureRateLimiterWindowExpiry: + def test_is_rate_limited_opens_new_window_after_expiry(self): + limiter = _limiter(_client_ip_policy(max_failures=2, window_seconds=10)) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0): + limiter.record_failure(client_ip="1.2.3.4") + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is True + with mock.patch(_MONOTONIC_PATCH, return_value=11.0): + assert limiter.is_rate_limited(client_ip="1.2.3.4") is False + + def test_record_failure_starts_new_window_after_expiry(self): + limiter = _limiter(_client_ip_policy(max_failures=3, window_seconds=10)) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0): + for _ in range(3): + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is True + with mock.patch(_MONOTONIC_PATCH, return_value=11.0): + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is False diff --git a/packages/node/octobot_node/scheduler/__init__.py b/packages/node/octobot_node/scheduler/__init__.py index 9090e3d2b0..41a52fcaba 100644 --- a/packages/node/octobot_node/scheduler/__init__.py +++ b/packages/node/octobot_node/scheduler/__init__.py @@ -26,7 +26,7 @@ import octobot.community.node_journal.enums as journal_enums import octobot.community.node_journal.recording_context as journal_recording_context -scheduler_logger = logging.getLogger(__name__) +scheduler_logger = logging.getLogger("scheduler.init") SCHEDULER: scheduler_lib.Scheduler = scheduler_lib.Scheduler() @@ -93,14 +93,19 @@ async def initialize_scheduler(): on_failure=_record_scheduler_init_failed, ): SCHEDULER.start() + scheduler_logger.info("Scheduler: registering DBOS queues") await scheduler_queues.register_scheduler_queues_async() + scheduler_logger.info("Scheduler: DBOS queues registered") # apply_schedules requires DBOS launch (sys_db); must run after start(). with journal_recording_context.scheduler_init_phase( init_phase=journal_enums.JournalInitPhase.REGISTER_SCHEDULES, backend=backend, on_failure=_record_scheduler_init_failed, ): + scheduler_logger.info("Scheduler: applying schedules") await schedules.register_schedules(SCHEDULER) + scheduler_logger.info("Scheduler: schedules applied") + scheduler_logger.info("Scheduler: initialize_scheduler completed") async def shutdown_scheduler_and_trading_signal_channel() -> None: diff --git a/packages/services/octobot_services/managers/interface_manager.py b/packages/services/octobot_services/managers/interface_manager.py index d8a50eec9e..b61db972fc 100644 --- a/packages/services/octobot_services/managers/interface_manager.py +++ b/packages/services/octobot_services/managers/interface_manager.py @@ -16,10 +16,22 @@ import octobot_commons.logging as logging +def _interface_uses_background_thread(interface) -> bool: + return interface.__class__.__name__ == "NodeApiInterface" + + async def start_interfaces(interfaces: list): + logger = logging.get_logger(__name__) started_interfaces = [] for interface in interfaces: - if await interface.start(): + interface_name = interface.get_name() + background_thread = _interface_uses_background_thread(interface) + if background_thread: + logger.info("Starting %s (threaded=True)", interface_name) + started = await interface.start() + if background_thread: + logger.info("Started %s (spawned=%s)", interface_name, started) + if started: started_interfaces.append(interface) return started_interfaces diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/deps.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/deps.py index 9ade55372b..ecf1b59e3b 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/api/deps.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/deps.py @@ -17,7 +17,7 @@ import uuid import typing -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPBasic, HTTPBasicCredentials import octobot_node.models @@ -29,37 +29,56 @@ auth_http_exception, node_not_configured_exception, ) +try: + from tentacles.Services.Interfaces.node_api_interface.api.rate_limits.login import ( + get_login_rate_limiter, + login_client_ip, + ) + from tentacles.Services.Interfaces.node_api_interface.core.http_rate_limit import ( + run_with_failure_rate_limit, + ) +except ImportError: + from api.rate_limits.login import get_login_rate_limiter, login_client_ip # type: ignore[no-redef] + from core.http_rate_limit import run_with_failure_rate_limit # type: ignore[no-redef] security_basic = HTTPBasic(auto_error=False) +_LOGIN_COUNTABLE_FAILURE_CODES = frozenset( + { + AuthErrorCode.AUTH_INVALID_PASSPHRASE, + AuthErrorCode.AUTH_WALLET_NOT_FOUND, + } +) -def get_current_user( - credentials: typing.Annotated[typing.Optional[HTTPBasicCredentials], Depends(security_basic)], -) -> octobot_node.models.User: - auth = community_auth.CommunityAuthentication.instance() - if auth is None: - raise node_not_configured_exception() - # Multi-wallet path: username = wallet address, password = passphrase - if credentials is None or not credentials.username: - # Check whether the node is configured at all (no credentials → can't auth anyway) - if not auth.list_wallets(): - raise node_not_configured_exception() - raise auth_http_exception( - status.HTTP_401_UNAUTHORIZED, - AuthErrorCode.AUTH_WALLET_ADDRESS_REQUIRED, - message="Wallet address required as username", - ) +def _should_count_login_failure(err: HTTPException) -> bool: + if err.status_code != status.HTTP_401_UNAUTHORIZED: + return False + detail = err.detail + if not isinstance(detail, dict): + return False + raw_code = detail.get("code") + if raw_code is None: + return False + try: + auth_code = AuthErrorCode(raw_code) + except ValueError: + return False + return auth_code in _LOGIN_COUNTABLE_FAILURE_CODES - # Normalize to lowercase so wallet_address == task.wallet_address always + +def _login_failure_counts_toward_limit(err: BaseException) -> bool: + if not isinstance(err, HTTPException): + return False + return _should_count_login_failure(err) + + +def _user_from_wallet_credentials( + auth: community_auth.CommunityAuthentication, + credentials: HTTPBasicCredentials, +) -> octobot_node.models.User: wallet_address = credentials.username.lower() passphrase = credentials.password - if not passphrase: - raise auth_http_exception( - status.HTTP_401_UNAUTHORIZED, - AuthErrorCode.AUTH_PASSPHRASE_REQUIRED, - message="Passphrase required", - ) try: wallet_info = auth.authenticate_wallet(wallet_address, passphrase) @@ -93,7 +112,57 @@ def get_current_user( ) +def get_current_user( + credentials: typing.Annotated[typing.Optional[HTTPBasicCredentials], Depends(security_basic)], +) -> octobot_node.models.User: + auth = community_auth.CommunityAuthentication.instance() + if auth is None: + raise node_not_configured_exception() + + # Multi-wallet path: username = wallet address, password = passphrase + if credentials is None or not credentials.username: + # Check whether the node is configured at all (no credentials → can't auth anyway) + if not auth.list_wallets(): + raise node_not_configured_exception() + raise auth_http_exception( + status.HTTP_401_UNAUTHORIZED, + AuthErrorCode.AUTH_WALLET_ADDRESS_REQUIRED, + message="Wallet address required as username", + ) + + passphrase = credentials.password + if not passphrase: + raise auth_http_exception( + status.HTTP_401_UNAUTHORIZED, + AuthErrorCode.AUTH_PASSPHRASE_REQUIRED, + message="Passphrase required", + ) + + return _user_from_wallet_credentials(auth, credentials) + + +def get_login_rate_limited_user( + request: Request, + credentials: typing.Annotated[typing.Optional[HTTPBasicCredentials], Depends(security_basic)], +) -> octobot_node.models.User: + """Wallet login with in-process rate limit (see api/rate_limits/login.py).""" + client_ip = login_client_ip(request) + return run_with_failure_rate_limit( + get_login_rate_limiter(), + dimensions={"client_ip": client_ip}, + action=lambda: get_current_user(credentials), + should_record_failure=_login_failure_counts_toward_limit, + ) + + +# Route parameter aliases: Annotated[User, Depends(fn)] gives static type User and tells +# FastAPI to run fn before the handler. A plain `user: User` annotation would not inject auth. CurrentUser = typing.Annotated[octobot_node.models.User, Depends(get_current_user)] +# Same as CurrentUser, but only for GET /login/test — counts failed passphrases per client IP. +LoginRateLimitedUser = typing.Annotated[ + octobot_node.models.User, + Depends(get_login_rate_limited_user), +] def get_optional_current_user( diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/__init__.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/__init__.py new file mode 100644 index 0000000000..ad25c24157 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/__init__.py @@ -0,0 +1,15 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/login.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/login.py new file mode 100644 index 0000000000..4c1962575e --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/login.py @@ -0,0 +1,50 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +from fastapi import Request + +import octobot_commons.in_process_rate_limit as in_process_rate_limit + +try: + from tentacles.Services.Interfaces.node_api_interface.core.http_rate_limit import ( + HTTPRateLimiter, + ) +except ImportError: + from core.http_rate_limit import HTTPRateLimiter # type: ignore[no-redef] + + +_LOGIN_CLIENT_IP_POLICY = in_process_rate_limit.FailureWindowPolicy( + name="client_ip", + max_failures=10, + window_seconds=15 * 60, +) + +LOGIN_RATE_LIMITED_DETAIL = "Too many login attempts. Try again later." + +_login_rate_limiter = HTTPRateLimiter( + (_LOGIN_CLIENT_IP_POLICY,), + rate_limited_detail=LOGIN_RATE_LIMITED_DETAIL, +) + + +def get_login_rate_limiter() -> HTTPRateLimiter: + return _login_rate_limiter + + +def login_client_ip(request: Request) -> str: + if request.client is not None: + return request.client.host + return "unknown" diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/rate_limit_response.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/rate_limit_response.py new file mode 100644 index 0000000000..03b963f321 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/rate_limit_response.py @@ -0,0 +1,25 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +import pydantic + + +class RateLimitedDetail(pydantic.BaseModel): + message: str + unblock_at: int = pydantic.Field( + ..., + description="Unix epoch seconds when the client may retry.", + ) diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/recover_passphrase.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/recover_passphrase.py new file mode 100644 index 0000000000..ffc1609109 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/rate_limits/recover_passphrase.py @@ -0,0 +1,74 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +import typing + +from fastapi import Request + +import octobot.community.wallet_backend as wallet_backend +import octobot_commons.in_process_rate_limit as in_process_rate_limit + +try: + from tentacles.Services.Interfaces.node_api_interface.core.http_rate_limit import ( + HTTPRateLimiter, + ) +except ImportError: + from core.http_rate_limit import HTTPRateLimiter # type: ignore[no-redef] + + +_RECOVER_CLIENT_IP_POLICY = in_process_rate_limit.FailureWindowPolicy( + name="client_ip", + max_failures=5, + window_seconds=15 * 60, +) +_RECOVER_ADDRESS_POLICY = in_process_rate_limit.FailureWindowPolicy( + name="address", + max_failures=10, + window_seconds=60 * 60, + normalize_key=str.lower, +) + +RECOVER_PASSPHRASE_RATE_LIMITED_DETAIL = ( + "Too many recovery attempts. Try again later." +) + +RECOVER_PASSPHRASE_FAILURE_EXCEPTIONS: tuple[type[Exception], ...] = ( + wallet_backend.WalletNotFoundError, + wallet_backend.WalletProofMismatchError, + wallet_backend.InvalidPrivateKeyError, + wallet_backend.PassphraseTooShortError, + wallet_backend.WalletError, +) + +_recover_passphrase_rate_limiter = HTTPRateLimiter( + (_RECOVER_CLIENT_IP_POLICY, _RECOVER_ADDRESS_POLICY), + rate_limited_detail=RECOVER_PASSPHRASE_RATE_LIMITED_DETAIL, +) + + +def get_recover_passphrase_rate_limiter() -> HTTPRateLimiter: + return _recover_passphrase_rate_limiter + + +def recover_passphrase_rate_dimensions( + body: typing.Any, + request: Request, +) -> dict[str, str]: + if request.client is not None: + client_ip = request.client.host + else: + client_ip = "unknown" + return {"client_ip": client_ip, "address": body.address} diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/login.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/login.py index 75068e3204..f900de56fc 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/login.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/login.py @@ -20,10 +20,14 @@ try: from api.auth_errors import NodeAuthErrorDetail # type: ignore[no-redef] - from api.deps import CurrentUser + from api.deps import LoginRateLimitedUser + from api.rate_limits.rate_limit_response import RateLimitedDetail except ImportError: from tentacles.Services.Interfaces.node_api_interface.api.auth_errors import NodeAuthErrorDetail - from tentacles.Services.Interfaces.node_api_interface.api.deps import CurrentUser # type: ignore[no-redef] + from tentacles.Services.Interfaces.node_api_interface.api.deps import LoginRateLimitedUser # type: ignore[no-redef] + from tentacles.Services.Interfaces.node_api_interface.api.rate_limits.rate_limit_response import ( # type: ignore[no-redef] + RateLimitedDetail, + ) import octobot_node.models router = APIRouter(tags=["login"]) @@ -34,8 +38,9 @@ response_model=octobot_node.models.User, responses={ 401: {"model": NodeAuthErrorDetail, "description": "Authentication failed"}, + 429: {"model": RateLimitedDetail, "description": "Too many login attempts"}, 503: {"model": NodeAuthErrorDetail, "description": "Node not configured"}, }, ) -def test_auth(current_user: CurrentUser) -> typing.Any: +def test_auth(current_user: LoginRateLimitedUser) -> typing.Any: return current_user diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py index 85e5b858b9..1fe42f5812 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py @@ -14,10 +14,11 @@ # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . +import functools import typing import pydantic -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import HTTPBasicCredentials import octobot_node.config as node_config @@ -28,11 +29,34 @@ import octobot.community.node_journal.recording_context as journal_recording_context try: + from tentacles.Services.Interfaces.node_api_interface.api.deps import CurrentUser, security_basic + from tentacles.Services.Interfaces.node_api_interface.core import network +except ImportError: + from api.deps import CurrentUser, security_basic # type: ignore[no-redef] from core import network +try: + from tentacles.Services.Interfaces.node_api_interface.api.rate_limits.recover_passphrase import ( + RECOVER_PASSPHRASE_FAILURE_EXCEPTIONS, + get_recover_passphrase_rate_limiter, + recover_passphrase_rate_dimensions, + ) + from tentacles.Services.Interfaces.node_api_interface.core.http_rate_limit import ( + http_failure_rate_limited, + ) except ImportError: - from tentacles.Services.Interfaces.node_api_interface.api.deps import CurrentUser, security_basic - from tentacles.Services.Interfaces.node_api_interface.core import network + from api.rate_limits.recover_passphrase import ( # type: ignore[no-redef] + RECOVER_PASSPHRASE_FAILURE_EXCEPTIONS, + get_recover_passphrase_rate_limiter, + recover_passphrase_rate_dimensions, + ) + from core.http_rate_limit import http_failure_rate_limited # type: ignore[no-redef] +try: + from tentacles.Services.Interfaces.node_api_interface.api.rate_limits.rate_limit_response import ( + RateLimitedDetail, + ) +except ImportError: + from api.rate_limits.rate_limit_response import RateLimitedDetail # type: ignore[no-redef] router = APIRouter(tags=["setup"]) @@ -66,6 +90,23 @@ class VPNNetworkAddress(pydantic.BaseModel): vpn_network_ip: typing.Optional[str] = None +class RecoverWalletFromSeedBody(pydantic.BaseModel): + address: str + new_passphrase: str + seed: typing.Optional[str] = None + private_key: typing.Optional[str] = None + + +class RecoverWalletFromSeedResult(pydantic.BaseModel): + success: bool = True + + +def _client_ip(request: Request) -> str: + if request.client is not None: + return request.client.host + return "unknown" + + @router.get("/setup/status", response_model=SetupStatus) def get_setup_status() -> SetupStatus: auth = community_auth.CommunityAuthentication.instance() @@ -131,7 +172,7 @@ def init_setup(body: SetupInit) -> SetupResult: ) except wallet_backend.WalletError as err: journal_recording_context.raise_wallet_setup_http_error( - http_status=status.HTTP_422_UNPROCESSABLE_ENTITY, + http_status=status.HTTP_422_UNPROCESSABLE_CONTENT, failure_reason=journal_enums.WalletSetupFailureReason.WALLET_ERROR, setup_method=setup_method, detail=str(err), @@ -165,7 +206,7 @@ def export_wallet( target_passphrase = credentials.password if is_own_wallet else passphrase if not target_passphrase: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Passphrase required", ) try: @@ -181,3 +222,71 @@ def export_wallet( detail="Invalid passphrase", ) return WalletExport(address=entry.address, private_key=entry.private_key, seed=entry.seed or None) + + +def _recover_wallet_from_seed_http_errors( + wrapped: typing.Callable[..., RecoverWalletFromSeedResult], +) -> typing.Callable[..., RecoverWalletFromSeedResult]: + """Map wallet backend errors to HTTP responses after rate-limit accounting.""" + + @functools.wraps(wrapped) + def wrapper( + body: RecoverWalletFromSeedBody, + request: Request, + ) -> RecoverWalletFromSeedResult: + try: + return wrapped(body, request) + except wallet_backend.WalletNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Wallet not found", + ) + except wallet_backend.WalletProofMismatchError as err: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(err), + ) + except wallet_backend.WalletStorageReadOnlyError as err: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(err), + ) + except (wallet_backend.InvalidPrivateKeyError, wallet_backend.PassphraseTooShortError, wallet_backend.WalletError) as err: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(err), + ) + + return wrapper + + +@router.post( + "/setup/wallet/recover-from-seed", + response_model=RecoverWalletFromSeedResult, + responses={ + 429: {"model": RateLimitedDetail, "description": "Too many recovery attempts"}, + }, +) +@_recover_wallet_from_seed_http_errors +@http_failure_rate_limited( + get_recover_passphrase_rate_limiter(), + get_dimensions=recover_passphrase_rate_dimensions, + record_failure_on=RECOVER_PASSPHRASE_FAILURE_EXCEPTIONS, +) +def recover_wallet_from_seed_route( + body: RecoverWalletFromSeedBody, + request: Request, +) -> RecoverWalletFromSeedResult: + auth = community_auth.CommunityAuthentication.instance() + if auth is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Node not configured", + ) + auth.recover_passphrase_from_ownership_proof( + address=body.address, + new_passphrase=body.new_passphrase, + seed=body.seed, + private_key=body.private_key, + ) + return RecoverWalletFromSeedResult() diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/wallets.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/wallets.py index cd32c8dd4f..86a03e2d48 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/wallets.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/wallets.py @@ -53,12 +53,15 @@ def list_wallets( credentials: typing.Annotated[typing.Optional[HTTPBasicCredentials], Depends(security_basic)], ) -> list[WalletInfo]: """Return configured wallets (no auth required for login page). - Names and is_admin are only revealed to verified callers to avoid PII disclosure.""" + + Addresses and wallet names are always returned for login and recovery UX. + is_admin is hidden unless Basic auth verifies a wallet passphrase. + """ auth = community_auth.CommunityAuthentication.instance() if auth is None: return [] wallets_data = auth.list_wallets() - # Gate is_admin behind credential verification; names are labels visible on login page + # Always return name; gate is_admin only reveal_admin = ( credentials is not None and bool(credentials.username) @@ -68,7 +71,7 @@ def list_wallets( return [ WalletInfo( address=w.address, - name=w.name if reveal_admin else None, + name=w.name, is_admin=w.is_admin if reveal_admin else False, ) for w in wallets_data @@ -117,7 +120,7 @@ def create_wallet(body: CreateWalletBody, current_user: CurrentUser) -> WalletIn ) from err except wallet_backend.WalletError as err: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(err), ) from err return WalletInfo(address=wallet.address, name=body.name or None, is_admin=False) diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/core/http_rate_limit.py b/packages/tentacles/Services/Interfaces/node_api_interface/core/http_rate_limit.py new file mode 100644 index 0000000000..473636415e --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/core/http_rate_limit.py @@ -0,0 +1,120 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +import functools +import inspect +import math +import time +import typing + +from fastapi import HTTPException, status + +import octobot_commons.in_process_rate_limit as in_process_rate_limit + +try: + from tentacles.Services.Interfaces.node_api_interface.api.rate_limits.rate_limit_response import ( + RateLimitedDetail, + ) +except ImportError: + from api.rate_limits.rate_limit_response import RateLimitedDetail # type: ignore[no-redef] + +FailureWindowPolicy = in_process_rate_limit.FailureWindowPolicy + +_T = typing.TypeVar("_T") + + +class HTTPRateLimiter(in_process_rate_limit.InProcessFailureRateLimiter): + """HTTP-facing failure rate limiter; raises FastAPI 429 when a budget is exceeded.""" + + def __init__( + self, + policies: tuple[FailureWindowPolicy, ...], + *, + rate_limited_detail: str = "Too many requests. Try again later.", + ) -> None: + super().__init__(policies) + self._rate_limited_detail = rate_limited_detail + + def raise_if_rate_limited( + self, + *, + detail: str | None = None, + **dimensions: str, + ) -> None: + if not self.is_rate_limited(**dimensions): + return + remaining = self.retry_after_seconds(**dimensions) + now_epoch = int(time.time()) + unblock_at = int(math.ceil(time.time() + remaining)) + if remaining > 0.0: + unblock_at = max(unblock_at, now_epoch + 1) + message = detail or self._rate_limited_detail + payload = RateLimitedDetail(message=message, unblock_at=unblock_at).model_dump() + retry_header = max(1, unblock_at - now_epoch) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=payload, + headers={"Retry-After": str(retry_header)}, + ) + + +def run_with_failure_rate_limit( + rate_limiter: HTTPRateLimiter, + *, + dimensions: dict[str, str], + action: typing.Callable[[], _T], + should_record_failure: typing.Callable[[BaseException], bool], +) -> _T: + """Check budgets, run action, record failure/success on the limiter's policies.""" + rate_limiter.raise_if_rate_limited(**dimensions) + try: + result = action() + except BaseException as err: + if should_record_failure(err): + rate_limiter.record_failure(**dimensions) + raise + rate_limiter.record_success(**dimensions) + return result + + +def http_failure_rate_limited( + rate_limiter: HTTPRateLimiter, + *, + get_dimensions: typing.Callable[..., dict[str, str]], + record_failure_on: tuple[type[Exception], ...], +) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Callable[..., typing.Any]]: + """Decorate a route handler; budgets live on the limiter's policies.""" + + def decorator( + wrapped: typing.Callable[..., typing.Any], + ) -> typing.Callable[..., typing.Any]: + signature = inspect.signature(wrapped) + + @functools.wraps(wrapped) + def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + bound = signature.bind(*args, **kwargs) + bound.apply_defaults() + dimensions = get_dimensions(**bound.arguments) + return run_with_failure_rate_limit( + rate_limiter, + dimensions=dimensions, + action=lambda: wrapped(*args, **kwargs), + should_record_failure=lambda err: isinstance(err, record_failure_on), + ) + + return wrapper + + return decorator diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/node_api.py b/packages/tentacles/Services/Interfaces/node_api_interface/node_api.py index 1700dd5ade..8e5fecebdf 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/node_api.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/node_api.py @@ -86,11 +86,31 @@ def __init__(self, config): async def _inner_start(self) -> bool: return self.threaded_start() + def run(self) -> None: + thread_name = threading.current_thread().name + self.logger.info("Node API thread: run() started (thread=%s)", thread_name) + try: + asyncio.run(self._async_run()) + except Exception as exc: + self.logger.exception( + exc, + True, + f"Node API thread: _async_run failed: {exc}", + ) + finally: + self.logger.info("Node API thread: run() exited (thread=%s)", thread_name) + async def _async_run(self) -> bool: + self.logger.info("Node API thread: _async_run started") if self.node_api_service is None: self.node_api_service = Service_bases.NodeApiService.instance() self.host = self.node_api_service.get_bind_host() self.port = self.node_api_service.get_bind_port() + self.logger.info( + "Node API thread: binding uvicorn on %s:%s", + self.host, + self.port, + ) node_sqlite_file = self.node_api_service.get_node_sqlite_file() node_postgres_url = self.node_api_service.get_node_postgres_url() if node_sqlite_file: @@ -105,6 +125,15 @@ async def _async_run(self) -> bool: host = self.host port = self.port self.app = self.create_app(external_host=self.node_api_service.get_node_external_host()) + dist_dir_for_log = get_dist_directory() + if dist_dir_for_log is not None: + dist_log_value = str(dist_dir_for_log) + else: + dist_log_value = "none" + self.logger.info( + "Node API thread: FastAPI app created (dist=%s)", + dist_log_value, + ) # Set CORS from service config cors_origins_str = self.node_api_service.get_backend_cors_origins() cors_origins = [i.strip() for i in cors_origins_str.split(",") if i.strip()] if cors_origins_str else [] @@ -125,8 +154,10 @@ async def _async_run(self) -> bool: if dist_dir and self._should_open_node_ui_in_browser(): self._open_node_ui_on_browser() try: + self.logger.info("Node API thread: entering uvicorn serve()") await self.server.serve() finally: + self.logger.info("Node API thread: uvicorn serve() exited") if self._serve_finished is not None: self._serve_finished.set() return True @@ -170,6 +201,9 @@ def _open_node_ui_on_browser(self): def create_app(cls, external_host: str | None = None) -> FastAPI: @asynccontextmanager async def lifespan(app: FastAPI): + octobot_commons_logging.get_logger("NodeApiInterface").info( + "Node API thread: FastAPI lifespan startup (uvicorn accepting requests)", + ) yield # Shutdown: trading signal channel first, then DBOS await scheduler.shutdown_scheduler_and_trading_signal_channel() diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/core/test_http_rate_limit.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/core/test_http_rate_limit.py new file mode 100644 index 0000000000..b4df43343d --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/core/test_http_rate_limit.py @@ -0,0 +1,238 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +import mock +import pytest +from fastapi import HTTPException, status + +import octobot_commons.in_process_rate_limit as in_process_rate_limit + +try: + import tentacles.Services.Interfaces.node_api_interface.core.http_rate_limit as http_rate_limit +except ImportError: + from core import http_rate_limit # type: ignore[no-redef] + +_DEFAULT_DETAIL = "Too many requests. Try again later." +_CLIENT_IP = "1.2.3.4" +_MONOTONIC_PATCH = "octobot_commons.in_process_rate_limit.time.monotonic" +_TIME_PATCH = "tentacles.Services.Interfaces.node_api_interface.core.http_rate_limit.time.time" + + +def _rate_limit_detail(exc: HTTPException) -> dict: + assert isinstance(exc.detail, dict) + return exc.detail + + +class CountedError(Exception): + pass + + +class OtherError(Exception): + pass + + +def _limiter(max_failures=2, *, rate_limited_detail=_DEFAULT_DETAIL): + policy = in_process_rate_limit.FailureWindowPolicy( + name="client_ip", + max_failures=max_failures, + window_seconds=60.0, + ) + return http_rate_limit.HTTPRateLimiter( + (policy,), + rate_limited_detail=rate_limited_detail, + ) + + +def _decorated_handler(limiter, inner_handler): + return http_rate_limit.http_failure_rate_limited( + limiter, + get_dimensions=lambda **dimensions: {"client_ip": dimensions["client_ip"]}, + record_failure_on=(CountedError,), + )(inner_handler) + + +class TestHTTPRateLimiterRaiseIfRateLimited: + def test_raises_429_with_default_detail(self): + limiter = _limiter(max_failures=2) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0): + limiter.record_failure(client_ip=_CLIENT_IP) + limiter.record_failure(client_ip=_CLIENT_IP) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0), mock.patch( + _TIME_PATCH, + return_value=1000.0, + ): + with pytest.raises(HTTPException) as exc_info: + limiter.raise_if_rate_limited(client_ip=_CLIENT_IP) + assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS + detail = _rate_limit_detail(exc_info.value) + assert detail["message"] == _DEFAULT_DETAIL + assert detail["unblock_at"] == 1060 + assert exc_info.value.headers["Retry-After"] == "60" + + def test_raises_429_with_detail_override(self): + limiter = _limiter(max_failures=2) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0): + limiter.record_failure(client_ip=_CLIENT_IP) + limiter.record_failure(client_ip=_CLIENT_IP) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0), mock.patch( + _TIME_PATCH, + return_value=1000.0, + ): + with pytest.raises(HTTPException) as exc_info: + limiter.raise_if_rate_limited(client_ip=_CLIENT_IP, detail="Custom") + assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS + detail = _rate_limit_detail(exc_info.value) + assert detail["message"] == "Custom" + assert detail["unblock_at"] == 1060 + + def test_no_op_when_not_limited(self): + limiter = _limiter(max_failures=2) + limiter.raise_if_rate_limited(client_ip=_CLIENT_IP) + + +class TestHttpFailureRateLimited: + def test_returns_wrapped_result_when_not_limited(self): + limiter = _limiter(max_failures=2) + + def inner(client_ip: str = _CLIENT_IP): + return "ok" + + handler = _decorated_handler(limiter, inner) + assert handler(client_ip=_CLIENT_IP) == "ok" + assert limiter.is_rate_limited(client_ip=_CLIENT_IP) is False + + def test_skips_wrapped_when_already_limited(self): + limiter = _limiter(max_failures=2) + limiter.record_failure(client_ip=_CLIENT_IP) + limiter.record_failure(client_ip=_CLIENT_IP) + inner_mock = mock.Mock() + + def inner(client_ip: str = _CLIENT_IP): + inner_mock() + return "ok" + + handler = _decorated_handler(limiter, inner) + with pytest.raises(HTTPException) as exc_info: + handler(client_ip=_CLIENT_IP) + assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS + inner_mock.assert_not_called() + + def test_records_failure_on_listed_exception(self): + limiter = _limiter(max_failures=2) + + def inner(client_ip: str = _CLIENT_IP): + raise CountedError("fail") + + handler = _decorated_handler(limiter, inner) + with pytest.raises(CountedError): + handler(client_ip=_CLIENT_IP) + with pytest.raises(CountedError): + handler(client_ip=_CLIENT_IP) + with pytest.raises(HTTPException): + handler(client_ip=_CLIENT_IP) + + def test_does_not_record_failure_on_unlisted_exception(self): + limiter = _limiter(max_failures=2) + limiter.record_failure(client_ip=_CLIENT_IP) + + def inner(client_ip: str = _CLIENT_IP): + raise OtherError("fail") + + handler = _decorated_handler(limiter, inner) + with pytest.raises(OtherError): + handler(client_ip=_CLIENT_IP) + assert limiter.is_rate_limited(client_ip=_CLIENT_IP) is False + + def test_records_success_on_normal_return(self): + limiter = _limiter(max_failures=3) + limiter.record_failure(client_ip=_CLIENT_IP) + limiter.record_failure(client_ip=_CLIENT_IP) + assert limiter.is_rate_limited(client_ip=_CLIENT_IP) is False + + def inner(client_ip: str = _CLIENT_IP): + return "ok" + + handler = _decorated_handler(limiter, inner) + assert handler(client_ip=_CLIENT_IP) == "ok" + assert limiter.is_rate_limited(client_ip=_CLIENT_IP) is False + for _ in range(3): + limiter.record_failure(client_ip=_CLIENT_IP) + assert limiter.is_rate_limited(client_ip=_CLIENT_IP) is True + + def test_get_dimensions_receives_bound_defaults(self): + limiter = _limiter(max_failures=2) + captured_dimensions = {} + + def inner(client_ip: str = "9.9.9.9"): + return "ok" + + def get_dimensions(**dimensions): + captured_dimensions["client_ip"] = dimensions["client_ip"] + return {"client_ip": dimensions["client_ip"]} + + handler = http_rate_limit.http_failure_rate_limited( + limiter, + get_dimensions=get_dimensions, + record_failure_on=(CountedError,), + )(inner) + handler() + assert captured_dimensions["client_ip"] == "9.9.9.9" + + +class TestRunWithFailureRateLimit: + def test_records_failure_when_predicate_matches(self): + limiter = _limiter(max_failures=2) + + def action(): + raise CountedError("fail") + + with pytest.raises(CountedError): + http_rate_limit.run_with_failure_rate_limit( + limiter, + dimensions={"client_ip": _CLIENT_IP}, + action=action, + should_record_failure=lambda err: isinstance(err, CountedError), + ) + with pytest.raises(CountedError): + http_rate_limit.run_with_failure_rate_limit( + limiter, + dimensions={"client_ip": _CLIENT_IP}, + action=action, + should_record_failure=lambda err: isinstance(err, CountedError), + ) + with pytest.raises(HTTPException): + http_rate_limit.run_with_failure_rate_limit( + limiter, + dimensions={"client_ip": _CLIENT_IP}, + action=action, + should_record_failure=lambda err: isinstance(err, CountedError), + ) + + def test_skips_failure_recording_when_predicate_false(self): + limiter = _limiter(max_failures=2) + limiter.record_failure(client_ip=_CLIENT_IP) + + def action(): + raise OtherError("fail") + + with pytest.raises(OtherError): + http_rate_limit.run_with_failure_rate_limit( + limiter, + dimensions={"client_ip": _CLIENT_IP}, + action=action, + should_record_failure=lambda err: isinstance(err, CountedError), + ) + assert limiter.is_rate_limited(client_ip=_CLIENT_IP) is False diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_login_rate_limit.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_login_rate_limit.py new file mode 100644 index 0000000000..15d1eb3e20 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_login_rate_limit.py @@ -0,0 +1,36 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +from tentacles.Services.Interfaces.node_api_interface.api.rate_limits.login import ( + get_login_rate_limiter, +) + + +def test_ip_bucket_limits_after_ten_failures(): + limiter = get_login_rate_limiter() + limiter.reset_all() + for _ in range(10): + limiter.record_failure(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is True + + +def test_success_clears_ip_bucket(): + limiter = get_login_rate_limiter() + limiter.reset_all() + for _ in range(9): + limiter.record_failure(client_ip="1.2.3.4") + limiter.record_success(client_ip="1.2.3.4") + assert limiter.is_rate_limited(client_ip="1.2.3.4") is False diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_recover_passphrase.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_recover_passphrase.py new file mode 100644 index 0000000000..9586712bfa --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_recover_passphrase.py @@ -0,0 +1,69 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +import mock +import pytest +from fastapi import HTTPException + +from tentacles.Services.Interfaces.node_api_interface.api.rate_limits.recover_passphrase import ( + get_recover_passphrase_rate_limiter, +) + +_MONOTONIC_PATCH = "octobot_commons.in_process_rate_limit.time.monotonic" +_TIME_PATCH = "tentacles.Services.Interfaces.node_api_interface.core.http_rate_limit.time.time" + + +def test_ip_bucket_limits_after_five_failures(): + limiter = get_recover_passphrase_rate_limiter() + address = "0xabc" + for _ in range(5): + limiter.record_failure(client_ip="1.2.3.4", address=address) + assert limiter.is_rate_limited(client_ip="1.2.3.4", address=address) is True + + +def test_address_bucket_limits_after_ten_failures(): + limiter = get_recover_passphrase_rate_limiter() + address = "0xdef" + for index in range(10): + limiter.record_failure(client_ip=f"10.0.0.{index}", address=address) + assert limiter.is_rate_limited(client_ip="10.0.0.99", address=address) is True + + +def test_success_resets_buckets(): + limiter = get_recover_passphrase_rate_limiter() + address = "0xabc" + for _ in range(4): + limiter.record_failure(client_ip="1.2.3.4", address=address) + limiter.record_success(client_ip="1.2.3.4", address=address) + assert limiter.is_rate_limited(client_ip="1.2.3.4", address=address) is False + + +def test_raise_if_rate_limited_unblock_at_uses_address_window(): + limiter = get_recover_passphrase_rate_limiter() + limiter.reset_all() + address = "0xdef" + with mock.patch(_MONOTONIC_PATCH, return_value=0.0): + for index in range(10): + limiter.record_failure(client_ip=f"10.0.0.{index}", address=address) + with mock.patch(_MONOTONIC_PATCH, return_value=0.0), mock.patch( + _TIME_PATCH, + return_value=1000.0, + ): + with pytest.raises(HTTPException) as exc_info: + limiter.raise_if_rate_limited(client_ip="10.0.0.99", address=address) + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["unblock_at"] == 4600 diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_login.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_login.py new file mode 100644 index 0000000000..f94f5dc6a3 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_login.py @@ -0,0 +1,90 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +import base64 +import time + +from tentacles.Services.Interfaces.node_api_interface.api.rate_limits.login import ( + LOGIN_RATE_LIMITED_DETAIL, + get_login_rate_limiter, +) + +from .conftest import ADMIN_ADDRESS, ADMIN_PASSPHRASE + +_LOGIN_TEST_URL = "/api/v1/login/test" +_LOGIN_WINDOW_SECONDS = 15 * 60 + + +def _auth_header(address: str, passphrase: str) -> dict: + token = base64.b64encode(f"{address}:{passphrase}".encode()).decode() + return {"Authorization": f"Basic {token}"} + + +def _assert_structured_rate_limit_response( + response, + *, + expected_message: str, + max_window_seconds: int, +) -> None: + assert response.status_code == 429 + detail = response.json()["detail"] + assert isinstance(detail, dict) + assert detail["message"] == expected_message + now_epoch = int(time.time()) + assert now_epoch <= detail["unblock_at"] <= now_epoch + max_window_seconds + 5 + assert int(response.headers["Retry-After"]) >= 1 + + +def test_login_success(client, mock_auth): + get_login_rate_limiter().reset_all() + resp = client.get( + _LOGIN_TEST_URL, + headers=_auth_header(ADMIN_ADDRESS, ADMIN_PASSPHRASE), + ) + assert resp.status_code == 200 + assert resp.json()["email"] == ADMIN_ADDRESS + + +def test_login_rate_limited_after_ten_failures(client, mock_auth): + limiter = get_login_rate_limiter() + limiter.reset_all() + wrong_headers = _auth_header(ADMIN_ADDRESS, "wrong-passphrase") + for _ in range(10): + resp = client.get(_LOGIN_TEST_URL, headers=wrong_headers) + assert resp.status_code == 401 + resp = client.get(_LOGIN_TEST_URL, headers=wrong_headers) + assert resp.status_code == 429 + _assert_structured_rate_limit_response( + resp, + expected_message=LOGIN_RATE_LIMITED_DETAIL, + max_window_seconds=_LOGIN_WINDOW_SECONDS, + ) + + +def test_login_success_resets_rate_limit(client, mock_auth): + limiter = get_login_rate_limiter() + limiter.reset_all() + wrong_headers = _auth_header(ADMIN_ADDRESS, "wrong-passphrase") + for _ in range(9): + resp = client.get(_LOGIN_TEST_URL, headers=wrong_headers) + assert resp.status_code == 401 + ok_resp = client.get( + _LOGIN_TEST_URL, + headers=_auth_header(ADMIN_ADDRESS, ADMIN_PASSPHRASE), + ) + assert ok_resp.status_code == 200 + resp = client.get(_LOGIN_TEST_URL, headers=wrong_headers) + assert resp.status_code == 401 diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_recover_wallet.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_recover_wallet.py new file mode 100644 index 0000000000..93d9fdc3f9 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_recover_wallet.py @@ -0,0 +1,122 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +from unittest import mock + +import time + +import octobot.community.wallet_backend as wallet_backend + +from tentacles.Services.Interfaces.node_api_interface.api.rate_limits.recover_passphrase import ( + RECOVER_PASSPHRASE_RATE_LIMITED_DETAIL, + get_recover_passphrase_rate_limiter, +) + +from .conftest import ADMIN_ADDRESS + +_TEST_MNEMONIC = "test test test test test test test test test test test junk" +_RECOVER_URL = "/api/v1/setup/wallet/recover-from-seed" +_RECOVER_IP_WINDOW_SECONDS = 15 * 60 + + +def _assert_structured_rate_limit_response( + response, + *, + expected_message: str, + max_window_seconds: int, +) -> None: + assert response.status_code == 429 + detail = response.json()["detail"] + assert isinstance(detail, dict) + assert detail["message"] == expected_message + now_epoch = int(time.time()) + assert now_epoch <= detail["unblock_at"] <= now_epoch + max_window_seconds + 5 + assert int(response.headers["Retry-After"]) >= 1 + + +def _recover_body(**overrides): + body = { + "address": ADMIN_ADDRESS, + "new_passphrase": "new-passphrase99", + "seed": _TEST_MNEMONIC, + } + body.update(overrides) + return body + + +def test_recover_wallet_success(client): + auth = mock.MagicMock() + with mock.patch( + "octobot.community.authentication.CommunityAuthentication.instance", + return_value=auth, + ): + get_recover_passphrase_rate_limiter().reset_all() + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 200 + assert resp.json() == {"success": True} + auth.recover_passphrase_from_ownership_proof.assert_called_once() + + +def test_recover_wallet_mismatch_returns_401(client): + auth = mock.MagicMock() + auth.recover_passphrase_from_ownership_proof.side_effect = ( + wallet_backend.WalletProofMismatchError("mismatch") + ) + with mock.patch( + "octobot.community.authentication.CommunityAuthentication.instance", + return_value=auth, + ): + get_recover_passphrase_rate_limiter().reset_all() + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 401 + + +def test_recover_wallet_read_only_returns_503(client): + auth = mock.MagicMock() + auth.recover_passphrase_from_ownership_proof.side_effect = ( + wallet_backend.WalletStorageReadOnlyError("read-only") + ) + with mock.patch( + "octobot.community.authentication.CommunityAuthentication.instance", + return_value=auth, + ): + get_recover_passphrase_rate_limiter().reset_all() + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 503 + assert "read-only" in resp.json()["detail"].lower() + + +def test_recover_wallet_rate_limited_after_ip_failures(client): + auth = mock.MagicMock() + auth.recover_passphrase_from_ownership_proof.side_effect = ( + wallet_backend.WalletProofMismatchError("mismatch") + ) + with mock.patch( + "octobot.community.authentication.CommunityAuthentication.instance", + return_value=auth, + ): + limiter = get_recover_passphrase_rate_limiter() + limiter.reset_all() + for _ in range(5): + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 401 + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 429 + _assert_structured_rate_limit_response( + resp, + expected_message=RECOVER_PASSPHRASE_RATE_LIMITED_DETAIL, + max_window_seconds=_RECOVER_IP_WINDOW_SECONDS, + ) diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_wallets.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_wallets.py index 38d35b711d..fe6ac8c405 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_wallets.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_wallets.py @@ -36,6 +36,10 @@ def test_list_wallets_unauthenticated(client, mock_auth): # Admin flags must not be revealed without credentials for w in data: assert w["is_admin"] is False + admin_entry = next(w for w in data if w["address"] == ADMIN_ADDRESS) + tenant_entry = next(w for w in data if w["address"] == TENANT_ADDRESS) + assert admin_entry["name"] == "Admin" + assert tenant_entry["name"] == "Alice" def test_list_wallets_authenticated_reveals_admin_flags(client, mock_auth): @@ -63,12 +67,11 @@ def test_list_wallets_bad_credentials_hides_admin_flags(client, mock_auth): def test_list_wallets_unknown_address_hides_admin_flags(client, mock_auth): - """Credentials for an address not in the wallet list must not reveal names/is_admin.""" + """Credentials for an address not in the wallet list must not reveal is_admin.""" resp = client.get("/api/v1/wallets/", auth=("0xunknown000000000000000000000000000001", "anypass")) assert resp.status_code == 200 for w in resp.json(): assert w["is_admin"] is False - assert w["name"] is None def test_list_wallets_no_auth_service(client): diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/__tests__/utils.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/__tests__/utils.test.ts index e4112a1f4a..09933966be 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/__tests__/utils.test.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/__tests__/utils.test.ts @@ -23,4 +23,49 @@ describe("extractErrorMessage", () => { ) expect(extractErrorMessage(err)).toBe("Passphrase verification failed") }) + + it("returns string detail from FastAPI error body", () => { + const err = new ApiError( + { method: "POST", url: "/api/v1/setup/wallet/recover-from-seed" }, + { + url: "/api/v1/setup/wallet/recover-from-seed", + ok: false, + status: 422, + statusText: "Unprocessable Content", + body: { detail: "Invalid seed phrase or private key" }, + }, + "Invalid seed phrase or private key", + ) + expect(extractErrorMessage(err)).toBe("Invalid seed phrase or private key") + }) + + it("returns plain string body when detail is absent", () => { + const err = new ApiError( + { method: "POST", url: "/x" }, + { + url: "/x", + ok: false, + status: 500, + statusText: "Error", + body: "Server failure text", + }, + "Error", + ) + expect(extractErrorMessage(err)).toBe("Server failure text") + }) + + it("falls back to ApiError message when body has no detail", () => { + const err = new ApiError( + { method: "POST", url: "/x" }, + { + url: "/x", + ok: false, + status: 500, + statusText: "Internal Server Error", + body: {}, + }, + "Custom error message", + ) + expect(extractErrorMessage(err)).toBe("Custom error message") + }) }) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/auth-error-messages.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/auth-error-messages.test.ts index 87ceaf5115..06a94de8a4 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/auth-error-messages.test.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/auth-error-messages.test.ts @@ -143,6 +143,45 @@ describe("resolveLoginAuthPresentation", () => { expect(presentation.title).toBe("Can't connect") expect(presentation.guidance).toHaveLength(1) }) + + it("maps 429 to login rate limit presentation with unblock_at", () => { + const error = new ApiError( + { method: "GET", url: "/login/test" }, + { + url: "/login/test", + ok: false, + status: 429, + statusText: "Too Many Requests", + body: { + detail: { + message: "Too many login attempts. Try again later.", + unblock_at: 1_704_067_200, + }, + }, + }, + "Too Many Requests", + ) + const presentation = resolveLoginAuthPresentation(error, "pw") + expect(presentation.title).toBe("Too many login attempts") + expect(presentation.explanation).toMatch(/^Try again after /) + expect(presentation.guidance).toHaveLength(0) + }) + + it("maps 429 with legacy string detail to static fallback", () => { + const error = new ApiError( + { method: "GET", url: "/login/test" }, + { + url: "/login/test", + ok: false, + status: 429, + statusText: "Too Many Requests", + body: { detail: "Too many login attempts. Try again later." }, + }, + "Too Many Requests", + ) + const presentation = resolveLoginAuthPresentation(error, "pw") + expect(presentation.explanation).toMatch(/Try again later/i) + }) }) describe("shouldSuppressLoginErrorToast", () => { @@ -194,6 +233,21 @@ describe("shouldSuppressLoginErrorToast", () => { ) expect(shouldSuppressLoginErrorToast(error)).toBe(true) }) + + it("suppresses 429 rate limit", () => { + const error = new ApiError( + { method: "GET", url: "/login/test" }, + { + url: "/login/test", + ok: false, + status: 429, + statusText: "Too Many Requests", + body: { detail: "Too many login attempts. Try again later." }, + }, + "Too Many Requests", + ) + expect(shouldSuppressLoginErrorToast(error)).toBe(true) + }) }) describe("resolveLoginFormAuthError", () => { diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/login-passphrase-recovery-hint.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/login-passphrase-recovery-hint.test.ts new file mode 100644 index 0000000000..74f63fde0e --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/login-passphrase-recovery-hint.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { + LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY, + LOGIN_PASSPHRASE_RECOVERY_SUCCESS_TOAST_DESCRIPTION, + LOGIN_PASSPHRASE_RECOVERY_SUCCESS_TOAST_TITLE, + consumeLoginPassphraseRecoverySuccessHint, + markLoginPassphraseRecoverySuccess, + showLoginPassphraseRecoverySuccessToast, +} from "@/lib/login-passphrase-recovery-hint" + +const toastSuccess = vi.fn() + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + }, +})) + +const sessionStorageStore: Record = {} +const sessionStorageMock = { + getItem: (key: string) => sessionStorageStore[key] ?? null, + setItem: (key: string, value: string) => { + sessionStorageStore[key] = value + }, + removeItem: (key: string) => { + delete sessionStorageStore[key] + }, + clear: () => { + Object.keys(sessionStorageStore).forEach((key) => { + delete sessionStorageStore[key] + }) + }, +} + +vi.stubGlobal("sessionStorage", sessionStorageMock) + +describe("login-passphrase-recovery-hint", () => { + beforeEach(() => { + sessionStorageMock.clear() + }) + + afterEach(() => { + toastSuccess.mockClear() + }) + + it("marks and consumes the session hint once", () => { + expect(consumeLoginPassphraseRecoverySuccessHint()).toBe(false) + markLoginPassphraseRecoverySuccess() + expect( + sessionStorage.getItem(LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY), + ).toBe("1") + expect(consumeLoginPassphraseRecoverySuccessHint()).toBe(true) + expect(consumeLoginPassphraseRecoverySuccessHint()).toBe(false) + }) + + it("shows the passphrase updated success toast", () => { + showLoginPassphraseRecoverySuccessToast() + expect(toastSuccess).toHaveBeenCalledWith( + LOGIN_PASSPHRASE_RECOVERY_SUCCESS_TOAST_TITLE, + { + description: LOGIN_PASSPHRASE_RECOVERY_SUCCESS_TOAST_DESCRIPTION, + }, + ) + }) +}) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/open-api-401-login-redirect.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/open-api-401-login-redirect.test.ts index 33e7b1388a..87eda56633 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/open-api-401-login-redirect.test.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/open-api-401-login-redirect.test.ts @@ -50,6 +50,15 @@ describe("shouldRedirectToLoginOn401", () => { ).toBe(false) }) + it("returns false for 401 on recover-seed login child route", () => { + expect( + shouldRedirectToLoginOn401(create401Response(), { + pathname: "/app/login/recover-seed", + isRedirectingOnAuthFailure: false, + }), + ).toBe(false) + }) + it("returns false when already redirecting", () => { expect( shouldRedirectToLoginOn401(create401Response(), { diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/parse-rate-limit-api-error.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/parse-rate-limit-api-error.test.ts new file mode 100644 index 0000000000..b23f1139e0 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/parse-rate-limit-api-error.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest" + +import { ApiError } from "@/client/core/ApiError" +import { + formatRateLimitUnblockLocalTime, + parseRateLimitDetail, +} from "@/lib/parse-rate-limit-api-error" + +function makeRateLimitApiError(detail: unknown): ApiError { + return new ApiError( + { method: "POST", url: "/x" }, + { + url: "/x", + ok: false, + status: 429, + statusText: "Too Many Requests", + body: { detail }, + }, + "Too Many Requests", + ) +} + +describe("parseRateLimitDetail", () => { + it("parses message and unblock_at", () => { + const error = makeRateLimitApiError({ + message: "Too many login attempts. Try again later.", + unblock_at: 1_700_000_000, + }) + expect(parseRateLimitDetail(error)).toEqual({ + message: "Too many login attempts. Try again later.", + unblockAt: 1_700_000_000, + }) + }) + + it("returns null for string detail", () => { + const error = makeRateLimitApiError("Too many attempts") + expect(parseRateLimitDetail(error)).toBeNull() + }) + + it("returns null when unblock_at is missing or invalid", () => { + expect( + parseRateLimitDetail( + makeRateLimitApiError({ message: "Limited", unblock_at: "bad" }), + ), + ).toBeNull() + expect( + parseRateLimitDetail(makeRateLimitApiError({ message: "Limited" })), + ).toBeNull() + }) +}) + +describe("formatRateLimitUnblockLocalTime", () => { + it("formats a fixed epoch in en-US", () => { + const unblockAt = 1_704_067_200 + const text = formatRateLimitUnblockLocalTime(unblockAt, "en-US") + expect(text).toMatch(/^Try again after /) + expect(text.endsWith(".")).toBe(true) + expect(text).toContain(":") + }) +}) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-errors.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-errors.test.ts new file mode 100644 index 0000000000..87de85cba3 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-errors.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest" + +import { ApiError } from "@/client" +import { resolveRecoverSeedSubmitError } from "@/lib/passphrase-recovery-errors" + +function makeApiError(status: number, body: unknown, message: string): ApiError { + return new ApiError( + { method: "POST", url: "/api/v1/setup/wallet/recover-from-seed" }, + { + url: "/api/v1/setup/wallet/recover-from-seed", + ok: false, + status, + statusText: "Error", + body, + }, + message, + ) +} + +describe("resolveRecoverSeedSubmitError", () => { + it("maps 401 to wallet mismatch copy", () => { + const result = resolveRecoverSeedSubmitError( + makeApiError(401, { detail: "mismatch" }, "Unauthorized"), + ) + expect(result.unavailable).toBe(false) + expect(result.message).toBe( + "That seed phrase or private key does not match this wallet.", + ) + }) + + it("maps 422 to unverified seed copy", () => { + const result = resolveRecoverSeedSubmitError( + makeApiError( + 422, + { detail: "Invalid seed phrase or private key" }, + "Invalid seed phrase or private key", + ), + ) + expect(result.unavailable).toBe(false) + expect(result.message).toContain("could not be verified") + }) + + it("maps 404 to wallet not on node copy", () => { + const result = resolveRecoverSeedSubmitError( + makeApiError(404, { detail: "Wallet not found" }, "Not Found"), + ) + expect(result.message).toBe("This wallet is not set up on this node.") + }) + + it("maps 429 with unblock_at to local time message", () => { + const result = resolveRecoverSeedSubmitError( + makeApiError( + 429, + { + detail: { + message: "Too many recovery attempts. Try again later.", + unblock_at: 1_704_067_200, + }, + }, + "Too Many Requests", + ), + ) + expect(result.message).toMatch(/^Try again after /) + }) + + it("maps 429 with legacy string detail to static copy", () => { + const result = resolveRecoverSeedSubmitError( + makeApiError(429, { detail: "rate limited" }, "Too Many Requests"), + ) + expect(result.message).toMatch(/Too many attempts/) + }) + + it("maps 503 with unavailable flag and server detail", () => { + const result = resolveRecoverSeedSubmitError( + makeApiError(503, { detail: "read-only" }, "read-only"), + ) + expect(result.unavailable).toBe(true) + expect(result.message).toBe("read-only") + }) + + it("maps non-ApiError to generic copy", () => { + const result = resolveRecoverSeedSubmitError(new Error("network")) + expect(result).toEqual({ + message: "Something went wrong.", + unavailable: false, + }) + }) +}) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-validation.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-validation.test.ts new file mode 100644 index 0000000000..ec41b92056 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-validation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" + +import { + countSeedWords, + isPassphraseLongEnough, + isValidEvmPrivateKeyHex, + isValidSeedPhrase, + passphrasesMatch, +} from "@/lib/passphrase-recovery-validation" + +describe("passphrase-recovery-validation", () => { + it("counts seed words", () => { + expect(countSeedWords("one two three")).toBe(3) + }) + + it("validates seed phrase length", () => { + const twelve = "a b c d e f g h i j k l" + expect(isValidSeedPhrase(twelve)).toBe(true) + expect(isValidSeedPhrase("too short")).toBe(false) + }) + + it("validates hex private key", () => { + const key = "0".repeat(64) + expect(isValidEvmPrivateKeyHex(key)).toBe(true) + expect(isValidEvmPrivateKeyHex("0x" + key)).toBe(true) + expect(isValidEvmPrivateKeyHex("abc")).toBe(false) + }) + + it("validates passphrase length and match", () => { + expect(isPassphraseLongEnough("12345678")).toBe(true) + expect(isPassphraseLongEnough("short")).toBe(false) + expect(passphrasesMatch("a", "a")).toBe(true) + expect(passphrasesMatch("a", "b")).toBe(false) + }) +}) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/recover-wallet-identity-header.test.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/recover-wallet-identity-header.test.tsx new file mode 100644 index 0000000000..f6ed0ba0e6 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/recover-wallet-identity-header.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" +import { renderToStaticMarkup } from "react-dom/server" + +import { RecoverWalletIdentityHeader } from "@/routes/login/-RecoverWalletIdentityHeader" + +const SAMPLE_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678" + +describe("RecoverWalletIdentityHeader", () => { + it("shows_name_before_address_when_name_provided", () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('data-testid="recover-wallet-name"') + expect(markup).toContain('data-testid="recover-wallet-address"') + const aliceIndex = markup.indexOf("Alice") + const addressIndex = markup.indexOf("0x1234") + expect(aliceIndex).toBeGreaterThan(-1) + expect(addressIndex).toBeGreaterThan(-1) + expect(aliceIndex).toBeLessThan(addressIndex) + }) + + it("shows_no_name_placeholder_when_name_missing", () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain("No name") + expect(markup).toContain('data-testid="recover-wallet-address"') + }) + + it("reserves_name_line_while_pending", () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('data-testid="recover-wallet-name"') + expect(markup).toContain('data-wallet-name-pending="true"') + expect(markup).toContain('aria-busy="true"') + expect(markup).not.toContain("No name") + }) +}) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/auth-error-messages.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/auth-error-messages.ts index 040a42d836..85083db6b5 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/auth-error-messages.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/auth-error-messages.ts @@ -11,6 +11,10 @@ import { CLIENT_AUTH_ERROR_CODES, } from "@/lib/auth-error-codes" import { parseAuthErrorCodeFromApiError } from "@/lib/parse-auth-api-error" +import { + formatRateLimitUnblockLocalTime, + parseRateLimitDetail, +} from "@/lib/parse-rate-limit-api-error" import { passphraseHasEdgeWhitespace } from "@/lib/passphrase-edge" export type AuthErrorPresentation = { @@ -23,6 +27,23 @@ export type LoginAuthContext = { multiWallet: boolean } +const LOGIN_RATE_LIMIT_PRESENTATION: AuthErrorPresentation = { + title: "Too many login attempts", + explanation: "Try again later.", + guidance: [], +} + +function loginRateLimitPresentation(error: ApiError): AuthErrorPresentation { + const parsed = parseRateLimitDetail(error) + if (parsed === null) { + return LOGIN_RATE_LIMIT_PRESENTATION + } + return { + ...LOGIN_RATE_LIMIT_PRESENTATION, + explanation: formatRateLimitUnblockLocalTime(parsed.unblockAt), + } +} + const API_AUTH_MESSAGES: Record = { [API_AUTH_ERROR_CODES.AUTH_PASSPHRASE_REQUIRED]: { title: "Enter your passphrase", @@ -192,6 +213,10 @@ export function resolveLoginAuthPresentation( ) } + if (error.status === 429) { + return loginRateLimitPresentation(error) + } + if (error.status === 401) { const code = parseAuthErrorCodeFromApiError(error) ?? @@ -217,6 +242,9 @@ export function shouldSuppressLoginErrorToast(error: unknown): boolean { if (error.status === 401) { return true } + if (error.status === 429) { + return true + } if (error.status === 503) { const code = parseAuthErrorCodeFromApiError(error) return code === API_AUTH_ERROR_CODES.AUTH_NODE_NOT_CONFIGURED diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/login-passphrase-recovery-hint.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/login-passphrase-recovery-hint.ts new file mode 100644 index 0000000000..5756e2eb80 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/login-passphrase-recovery-hint.ts @@ -0,0 +1,29 @@ +import { toast } from "sonner" + +export const LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY = + "octobot_login_passphrase_recovery_success" + +export const LOGIN_PASSPHRASE_RECOVERY_SUCCESS_TOAST_TITLE = + "Passphrase updated" + +export const LOGIN_PASSPHRASE_RECOVERY_SUCCESS_TOAST_DESCRIPTION = + "Unlock with your new passphrase." + +export function markLoginPassphraseRecoverySuccess(): void { + sessionStorage.setItem(LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY, "1") +} + +export function consumeLoginPassphraseRecoverySuccessHint(): boolean { + const value = sessionStorage.getItem(LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY) + if (!value) { + return false + } + sessionStorage.removeItem(LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY) + return true +} + +export function showLoginPassphraseRecoverySuccessToast(): void { + toast.success(LOGIN_PASSPHRASE_RECOVERY_SUCCESS_TOAST_TITLE, { + description: LOGIN_PASSPHRASE_RECOVERY_SUCCESS_TOAST_DESCRIPTION, + }) +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/open-api-401-login-redirect.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/open-api-401-login-redirect.ts index de9284d45b..2cedd93e17 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/open-api-401-login-redirect.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/open-api-401-login-redirect.ts @@ -19,7 +19,10 @@ export function shouldRedirectToLoginOn401( if (options.isRedirectingOnAuthFailure) { return false } - if (options.pathname.endsWith("/login")) { + if ( + options.pathname === "/app/login" || + options.pathname.startsWith("/app/login/") + ) { return false } if (shouldSkipLoginRedirectOn401()) { diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/parse-rate-limit-api-error.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/parse-rate-limit-api-error.ts new file mode 100644 index 0000000000..2d165385d9 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/parse-rate-limit-api-error.ts @@ -0,0 +1,43 @@ +import type { ApiError } from "@/client" + +export type ParsedRateLimitDetail = { + message: string + unblockAt: number +} + +function readDetailRecord(error: ApiError): Record | null { + const detail = (error.body as { detail?: unknown })?.detail + if (detail === null || detail === undefined || typeof detail !== "object") { + return null + } + return detail as Record +} + +export function parseRateLimitDetail( + error: ApiError, +): ParsedRateLimitDetail | null { + const detail = readDetailRecord(error) + if (detail === null) { + return null + } + const rawMessage = detail.message + if (typeof rawMessage !== "string" || rawMessage.length === 0) { + return null + } + const rawUnblockAt = detail.unblock_at + if (typeof rawUnblockAt !== "number" || !Number.isFinite(rawUnblockAt)) { + return null + } + return { message: rawMessage, unblockAt: rawUnblockAt } +} + +export function formatRateLimitUnblockLocalTime( + unblockAt: number, + locale = "en-US", +): string { + const formatted = new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + }).format(new Date(unblockAt * 1000)) + return `Try again after ${formatted}.` +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-errors.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-errors.ts new file mode 100644 index 0000000000..69042de2de --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-errors.ts @@ -0,0 +1,76 @@ +import { ApiError } from "@/client" +import { + formatRateLimitUnblockLocalTime, + parseRateLimitDetail, +} from "@/lib/parse-rate-limit-api-error" +import { extractErrorMessage } from "@/utils" + +const RECOVER_SEED_MISMATCH = + "That seed phrase or private key does not match this wallet." + +const RECOVER_SEED_UNVERIFIED = + "That seed phrase or private key could not be verified. Check spelling, word order, and that you entered the phrase for this wallet." + +const RECOVER_WALLET_NOT_ON_NODE = "This wallet is not set up on this node." + +const RECOVER_RATE_LIMIT = + "Too many attempts. Wait a few minutes, then try again." + +const RECOVER_UNAVAILABLE_DEFAULT = + "Passphrase recovery is not available on this node." + +const GENERIC_SUBMIT_ERROR = "Something went wrong." + +export type RecoverSeedSubmitErrorResult = { + message: string + unavailable: boolean +} + +function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError +} + +export function resolveRecoverSeedSubmitError( + error: unknown, +): RecoverSeedSubmitErrorResult { + if (!isApiError(error)) { + return { message: GENERIC_SUBMIT_ERROR, unavailable: false } + } + + if (error.status === 503) { + const detail = extractErrorMessage(error) + return { + message: + detail !== GENERIC_SUBMIT_ERROR ? detail : RECOVER_UNAVAILABLE_DEFAULT, + unavailable: true, + } + } + + if (error.status === 429) { + const parsed = parseRateLimitDetail(error) + if (parsed !== null) { + return { + message: formatRateLimitUnblockLocalTime(parsed.unblockAt), + unavailable: false, + } + } + return { message: RECOVER_RATE_LIMIT, unavailable: false } + } + + if (error.status === 401) { + return { message: RECOVER_SEED_MISMATCH, unavailable: false } + } + + if (error.status === 422) { + return { message: RECOVER_SEED_UNVERIFIED, unavailable: false } + } + + if (error.status === 404) { + return { message: RECOVER_WALLET_NOT_ON_NODE, unavailable: false } + } + + return { + message: extractErrorMessage(error), + unavailable: false, + } +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-validation.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-validation.ts new file mode 100644 index 0000000000..d772d4a4b9 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-validation.ts @@ -0,0 +1,26 @@ +const EVM_PRIVATE_KEY_PATTERN = /^(0x)?[0-9a-fA-F]{64}$/ + +export type PassphraseRecoveryProofMode = "seed" | "hex" + +export function countSeedWords(seed: string): number { + return seed.trim().split(/\s+/).filter(Boolean).length +} + +export function isValidSeedPhrase(seed: string): boolean { + return countSeedWords(seed) >= 12 +} + +export function isValidEvmPrivateKeyHex(privateKey: string): boolean { + return EVM_PRIVATE_KEY_PATTERN.test(privateKey.trim()) +} + +export function isPassphraseLongEnough(passphrase: string): boolean { + return passphrase.length >= 8 +} + +export function passphrasesMatch( + passphrase: string, + confirmPassphrase: string, +): boolean { + return passphrase === confirmPassphrase +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/routeTree.gen.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/routeTree.gen.ts index 9644542172..1055a9a2ba 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/routeTree.gen.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/routeTree.gen.ts @@ -18,6 +18,8 @@ import { Route as LayoutDslKeywordsRouteImport } from './routes/_layout/dsl-keyw import { Route as LayoutOctobotsRouteImport } from './routes/_layout/octobots' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutSupportRouteImport } from './routes/_layout/support' +import { Route as LoginIndexRouteImport } from './routes/login/index' +import { Route as LoginRecoverSeedRouteImport } from './routes/login/recover-seed' import { Route as SetupIndexRouteImport } from './routes/setup/index' import { Route as SetupConnectRouteImport } from './routes/setup/connect' import { Route as SetupFirstBotRouteImport } from './routes/setup/first-bot' @@ -77,6 +79,16 @@ const LayoutSupportRoute = LayoutSupportRouteImport.update({ path: '/support', getParentRoute: () => LayoutRoute, } as any) +const LoginIndexRoute = LoginIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => LoginRoute, +} as any) +const LoginRecoverSeedRoute = LoginRecoverSeedRouteImport.update({ + id: '/recover-seed', + path: '/recover-seed', + getParentRoute: () => LoginRoute, +} as any) const SetupIndexRoute = SetupIndexRouteImport.update({ id: '/', path: '/', @@ -153,17 +165,19 @@ const LayoutOctobotsNewPresetsRoute = export interface FileRoutesByFullPath { '/': typeof LayoutIndexRoute - '/login': typeof LoginRoute + '/login': typeof LoginRouteWithChildren '/setup': typeof SetupRouteWithChildren '/debug': typeof LayoutDebugRoute '/dsl-keywords': typeof LayoutDslKeywordsRoute '/octobots': typeof LayoutOctobotsRouteWithChildren '/settings': typeof LayoutSettingsRouteWithChildren '/support': typeof LayoutSupportRoute + '/login/recover-seed': typeof LoginRecoverSeedRoute '/setup/connect': typeof SetupConnectRoute '/setup/first-bot': typeof SetupFirstBotRoute '/setup/mobile-app': typeof SetupMobileAppRoute '/setup/welcome': typeof SetupWelcomeRoute + '/login/': typeof LoginIndexRoute '/setup/': typeof SetupIndexRoute '/octobots/export': typeof LayoutOctobotsExportRoute '/octobots/import': typeof LayoutOctobotsImportRoute @@ -176,15 +190,16 @@ export interface FileRoutesByFullPath { '/octobots/new/presets': typeof LayoutOctobotsNewPresetsRoute } export interface FileRoutesByTo { - '/login': typeof LoginRoute '/debug': typeof LayoutDebugRoute '/dsl-keywords': typeof LayoutDslKeywordsRoute '/support': typeof LayoutSupportRoute + '/login/recover-seed': typeof LoginRecoverSeedRoute '/setup/connect': typeof SetupConnectRoute '/setup/first-bot': typeof SetupFirstBotRoute '/setup/mobile-app': typeof SetupMobileAppRoute '/setup/welcome': typeof SetupWelcomeRoute '/': typeof LayoutIndexRoute + '/login': typeof LoginIndexRoute '/setup': typeof SetupIndexRoute '/octobots/export': typeof LayoutOctobotsExportRoute '/octobots/import': typeof LayoutOctobotsImportRoute @@ -199,18 +214,20 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_layout': typeof LayoutRouteWithChildren - '/login': typeof LoginRoute + '/login': typeof LoginRouteWithChildren '/setup': typeof SetupRouteWithChildren '/_layout/debug': typeof LayoutDebugRoute '/_layout/dsl-keywords': typeof LayoutDslKeywordsRoute '/_layout/octobots': typeof LayoutOctobotsRouteWithChildren '/_layout/settings': typeof LayoutSettingsRouteWithChildren '/_layout/support': typeof LayoutSupportRoute + '/login/recover-seed': typeof LoginRecoverSeedRoute '/setup/connect': typeof SetupConnectRoute '/setup/first-bot': typeof SetupFirstBotRoute '/setup/mobile-app': typeof SetupMobileAppRoute '/setup/welcome': typeof SetupWelcomeRoute '/_layout/': typeof LayoutIndexRoute + '/login/': typeof LoginIndexRoute '/setup/': typeof SetupIndexRoute '/_layout/octobots/export': typeof LayoutOctobotsExportRoute '/_layout/octobots/import': typeof LayoutOctobotsImportRoute @@ -233,10 +250,12 @@ export interface FileRouteTypes { | '/octobots' | '/settings' | '/support' + | '/login/recover-seed' | '/setup/connect' | '/setup/first-bot' | '/setup/mobile-app' | '/setup/welcome' + | '/login/' | '/setup/' | '/octobots/export' | '/octobots/import' @@ -249,15 +268,16 @@ export interface FileRouteTypes { | '/octobots/new/presets' fileRoutesByTo: FileRoutesByTo to: - | '/login' | '/debug' | '/dsl-keywords' | '/support' + | '/login/recover-seed' | '/setup/connect' | '/setup/first-bot' | '/setup/mobile-app' | '/setup/welcome' | '/' + | '/login' | '/setup' | '/octobots/export' | '/octobots/import' @@ -278,11 +298,13 @@ export interface FileRouteTypes { | '/_layout/octobots' | '/_layout/settings' | '/_layout/support' + | '/login/recover-seed' | '/setup/connect' | '/setup/first-bot' | '/setup/mobile-app' | '/setup/welcome' | '/_layout/' + | '/login/' | '/setup/' | '/_layout/octobots/export' | '/_layout/octobots/import' @@ -297,7 +319,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { LayoutRoute: typeof LayoutRouteWithChildren - LoginRoute: typeof LoginRoute + LoginRoute: typeof LoginRouteWithChildren SetupRoute: typeof SetupRouteWithChildren } @@ -366,6 +388,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutSupportRouteImport parentRoute: typeof LayoutRoute } + '/login/': { + id: '/login/' + path: '/' + fullPath: '/login/' + preLoaderRoute: typeof LoginIndexRouteImport + parentRoute: typeof LoginRoute + } + '/login/recover-seed': { + id: '/login/recover-seed' + path: '/recover-seed' + fullPath: '/login/recover-seed' + preLoaderRoute: typeof LoginRecoverSeedRouteImport + parentRoute: typeof LoginRoute + } '/setup/': { id: '/setup/' path: '/' @@ -535,6 +571,18 @@ const LayoutRouteChildren: LayoutRouteChildren = { const LayoutRouteWithChildren = LayoutRoute._addFileChildren(LayoutRouteChildren) +interface LoginRouteChildren { + LoginRecoverSeedRoute: typeof LoginRecoverSeedRoute + LoginIndexRoute: typeof LoginIndexRoute +} + +const LoginRouteChildren: LoginRouteChildren = { + LoginRecoverSeedRoute: LoginRecoverSeedRoute, + LoginIndexRoute: LoginIndexRoute, +} + +const LoginRouteWithChildren = LoginRoute._addFileChildren(LoginRouteChildren) + interface SetupRouteChildren { SetupConnectRoute: typeof SetupConnectRoute SetupFirstBotRoute: typeof SetupFirstBotRoute @@ -555,7 +603,7 @@ const SetupRouteWithChildren = SetupRoute._addFileChildren(SetupRouteChildren) const rootRouteChildren: RootRouteChildren = { LayoutRoute: LayoutRouteWithChildren, - LoginRoute: LoginRoute, + LoginRoute: LoginRouteWithChildren, SetupRoute: SetupRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login.tsx index 6784035a05..ce351dabdd 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login.tsx +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login.tsx @@ -1,275 +1,15 @@ -import { zodResolver } from "@hookform/resolvers/zod" -import { useQuery } from "@tanstack/react-query" -import { createFileRoute, redirect } from "@tanstack/react-router" -import { ShieldCheck } from "lucide-react" -import { useEffect, useState } from "react" -import { useForm } from "react-hook-form" -import { z } from "zod" +import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" -import { type WalletInfo, WalletsService } from "@/client" -import { AuthLayout } from "@/components/Common/AuthLayout" -import { - LoginAuthErrorDisplay, - loginAuthFieldDescribedBy, -} from "@/components/Common/LoginAuthErrorDisplay" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { LoadingButton } from "@/components/ui/loading-button" -import { PasswordInput } from "@/components/ui/password-input" -import useAuth, { isLoggedIn } from "@/hooks/useAuth" -import { - type AuthErrorPresentation, - applyLoginAuthPresentation, - getAuthErrorPresentation, - resolveLoginAuthPresentation, -} from "@/lib/auth-error-messages" -import { CLIENT_AUTH_ERROR_CODES } from "@/lib/auth-error-codes" -import { consumeLoginSessionClearedHint } from "@/lib/login-session-hint" -import { truncateAddress } from "@/lib/wallet-utils" - -const formSchema = z.object({ - passphrase: z.string().min(1, { message: "Passphrase is required" }), -}) - -type FormData = z.infer +import { isLoggedIn } from "@/hooks/useAuth" export const Route = createFileRoute("/login")({ - component: Login, beforeLoad: async () => { if (isLoggedIn()) { throw redirect({ to: "/" }) } }, + component: () => , head: () => ({ meta: [{ title: "Log In" }], }), }) - -function Login() { - const { loginMutation } = useAuth() - const [selectedWallet, setSelectedWallet] = useState(null) - const [sessionClearedBanner, setSessionClearedBanner] = useState(false) - const [loginAuthError, setLoginAuthError] = - useState(null) - - useEffect(() => { - if (consumeLoginSessionClearedHint()) { - setSessionClearedBanner(true) - } - }, []) - - const { - data: wallets = [], - isPending: walletsLoading, - isError: walletsError, - } = useQuery({ - queryKey: ["wallets"], - queryFn: () => WalletsService.listWallets(), - staleTime: 0, - }) - - const multiWallet = !walletsLoading && wallets.length > 1 - const sessionExpiredCopy = getAuthErrorPresentation( - CLIENT_AUTH_ERROR_CODES.SESSION_CLEARED, - ) - - const form = useForm({ - resolver: zodResolver(formSchema), - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - passphrase: "", - }, - }) - - const onSubmit = (data: FormData) => { - if (loginMutation.isPending) return - setLoginAuthError(null) - - // Determine which wallet address to use as username - let username: string - if (multiWallet) { - if (!selectedWallet) return - username = selectedWallet.address - } else if (wallets.length === 1) { - username = wallets[0].address - } else { - // No wallets configured — nothing to authenticate against. The root route - // guard should have already redirected to /setup/welcome before this can - // render; bail out rather than sending a fabricated address. - setLoginAuthError( - applyLoginAuthPresentation( - CLIENT_AUTH_ERROR_CODES.NETWORK_ERROR, - { multiWallet }, - false, - ), - ) - return - } - - loginMutation.mutate( - { username, password: data.passphrase }, - { - onError: (err) => { - setLoginAuthError( - resolveLoginAuthPresentation(err, data.passphrase, { - multiWallet, - }), - ) - }, - }, - ) - } - - // Wallet list failed to load — can't determine auth mode - if (walletsError) { - return ( - - - Unable to connect - - Could not reach the node. Please check your connection and reload. - - - - ) - } - - // Multi-wallet: wallet selection step - if (multiWallet && selectedWallet === null) { - return ( - - - - Choose a wallet - - Select the wallet you want to connect with. - - - - {wallets.map((wallet) => ( - setSelectedWallet(wallet)} - className="flex items-center gap-3 rounded-lg border p-4 text-left transition-colors hover:bg-muted" - > - - - - {wallet.name || ( - - No name - - )} - - {wallet.is_admin && ( - - )} - - - {truncateAddress(wallet.address)} - - - - ))} - - - - ) - } - - // Single-wallet or after wallet selection: passphrase step - return ( - - - - - {multiWallet && selectedWallet ? ( - <> - - {selectedWallet.name || - truncateAddress(selectedWallet.address)} - - - Enter the passphrase for this wallet. - - { - setSelectedWallet(null) - setLoginAuthError(null) - form.reset() - }} - className="text-xs text-muted-foreground underline underline-offset-2" - > - ← Choose a different wallet - - > - ) : ( - <> - Unlock your node - - Enter your passphrase to continue. - - > - )} - - - {sessionClearedBanner ? ( - - - {sessionExpiredCopy.title} - - {sessionExpiredCopy.explanation} - - ) : null} - - - ( - - Passphrase - - { - setLoginAuthError(null) - field.onChange(event) - }} - /> - - - - - )} - /> - - - Unlock - - - - - - ) -} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login/-RecoverWalletIdentityHeader.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login/-RecoverWalletIdentityHeader.tsx new file mode 100644 index 0000000000..4ce96d2b7d --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login/-RecoverWalletIdentityHeader.tsx @@ -0,0 +1,44 @@ +import { truncateAddress } from "@/lib/wallet-utils" + +export type RecoverWalletIdentityHeaderProps = { + address: string + walletName?: string | null + walletNamePending?: boolean +} + +export function RecoverWalletIdentityHeader({ + address, + walletName, + walletNamePending = false, +}: RecoverWalletIdentityHeaderProps) { + const trimmedName = walletName?.trim() + + return ( + + + {walletNamePending ? ( + + {"\u00a0"} + + ) : trimmedName ? ( + {trimmedName} + ) : ( + + No name + + )} + + + {truncateAddress(address)} + + + ) +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login/index.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login/index.tsx new file mode 100644 index 0000000000..3a148f3446 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login/index.tsx @@ -0,0 +1,286 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useQuery } from "@tanstack/react-query" +import { createFileRoute, Link } from "@tanstack/react-router" +import { ShieldCheck } from "lucide-react" +import { useEffect, useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { type WalletInfo, WalletsService } from "@/client" +import { AuthLayout } from "@/components/Common/AuthLayout" +import { + LoginAuthErrorDisplay, + loginAuthFieldDescribedBy, +} from "@/components/Common/LoginAuthErrorDisplay" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { LoadingButton } from "@/components/ui/loading-button" +import { PasswordInput } from "@/components/ui/password-input" +import useAuth from "@/hooks/useAuth" +import { + type AuthErrorPresentation, + applyLoginAuthPresentation, + getAuthErrorPresentation, + resolveLoginAuthPresentation, +} from "@/lib/auth-error-messages" +import { CLIENT_AUTH_ERROR_CODES } from "@/lib/auth-error-codes" +import { + consumeLoginPassphraseRecoverySuccessHint, + showLoginPassphraseRecoverySuccessToast, +} from "@/lib/login-passphrase-recovery-hint" +import { consumeLoginSessionClearedHint } from "@/lib/login-session-hint" +import { truncateAddress } from "@/lib/wallet-utils" + +const formSchema = z.object({ + passphrase: z.string().min(1, { message: "Passphrase is required" }), +}) + +type FormData = z.infer + +export const Route = createFileRoute("/login/")({ + component: Login, +}) + +function Login() { + const { loginMutation } = useAuth() + const [selectedWallet, setSelectedWallet] = useState(null) + const [sessionClearedBanner, setSessionClearedBanner] = useState(false) + const [loginAuthError, setLoginAuthError] = + useState(null) + + useEffect(() => { + if (consumeLoginSessionClearedHint()) { + setSessionClearedBanner(true) + } + if (consumeLoginPassphraseRecoverySuccessHint()) { + showLoginPassphraseRecoverySuccessToast() + } + }, []) + + const { + data: wallets = [], + isPending: walletsLoading, + isError: walletsError, + } = useQuery({ + queryKey: ["wallets"], + queryFn: () => WalletsService.listWallets(), + staleTime: 0, + }) + + const multiWallet = !walletsLoading && wallets.length > 1 + const sessionExpiredCopy = getAuthErrorPresentation( + CLIENT_AUTH_ERROR_CODES.SESSION_CLEARED, + ) + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + criteriaMode: "all", + defaultValues: { + passphrase: "", + }, + }) + + const onSubmit = (data: FormData) => { + if (loginMutation.isPending) return + setLoginAuthError(null) + + let username: string + if (multiWallet) { + if (!selectedWallet) return + username = selectedWallet.address + } else if (wallets.length === 1) { + username = wallets[0].address + } else { + setLoginAuthError( + applyLoginAuthPresentation( + CLIENT_AUTH_ERROR_CODES.NETWORK_ERROR, + { multiWallet }, + false, + ), + ) + return + } + + loginMutation.mutate( + { username, password: data.passphrase }, + { + onError: (err) => { + setLoginAuthError( + resolveLoginAuthPresentation(err, data.passphrase, { + multiWallet, + }), + ) + }, + }, + ) + } + + if (walletsError) { + return ( + + + Unable to connect + + Could not reach the node. Please check your connection and reload. + + + + ) + } + + if (multiWallet && selectedWallet === null) { + return ( + + + + Choose a wallet + + Select the wallet you want to connect with. + + + + {wallets.map((wallet) => ( + setSelectedWallet(wallet)} + className="flex items-center gap-3 rounded-lg border p-4 text-left transition-colors hover:bg-muted" + > + + + + {wallet.name || ( + + No name + + )} + + {wallet.is_admin && ( + + )} + + + {truncateAddress(wallet.address)} + + + + ))} + + + + ) + } + + const unlockWalletAddress = multiWallet + ? selectedWallet?.address + : wallets.length === 1 + ? wallets[0].address + : null + + return ( + + + + + {multiWallet && selectedWallet ? ( + <> + + {selectedWallet.name || + truncateAddress(selectedWallet.address)} + + + Enter the passphrase for this wallet. + + { + setSelectedWallet(null) + setLoginAuthError(null) + form.reset() + }} + className="text-xs text-muted-foreground underline underline-offset-2" + > + ← Choose a different wallet + + > + ) : ( + <> + Unlock your node + + Enter your passphrase to continue. + + > + )} + + + {sessionClearedBanner ? ( + + + {sessionExpiredCopy.title} + + {sessionExpiredCopy.explanation} + + ) : null} + + + ( + + Passphrase + + { + setLoginAuthError(null) + field.onChange(event) + }} + /> + + + + + )} + /> + + + Unlock + + + {unlockWalletAddress ? ( + + + Forgot passphrase? + + + ) : null} + + + + + ) +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login/recover-seed.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login/recover-seed.tsx new file mode 100644 index 0000000000..19b2ace72b --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login/recover-seed.tsx @@ -0,0 +1,329 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useQuery } from "@tanstack/react-query" +import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router" +import { TriangleAlert } from "lucide-react" +import { useMemo, useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { SetupService, WalletsService } from "@/client" +import { AuthLayout } from "@/components/Common/AuthLayout" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { LoadingButton } from "@/components/ui/loading-button" +import { PasswordInput } from "@/components/ui/password-input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { isLoggedIn } from "@/hooks/useAuth" +import { markLoginPassphraseRecoverySuccess } from "@/lib/login-passphrase-recovery-hint" +import { resolveRecoverSeedSubmitError } from "@/lib/passphrase-recovery-errors" +import type { PassphraseRecoveryProofMode } from "@/lib/passphrase-recovery-validation" +import { cryptoSecretTextareaProps } from "@/lib/crypto-secret-input" +import { RecoverWalletIdentityHeader } from "@/routes/login/-RecoverWalletIdentityHeader" + +const searchSchema = z.object({ + address: z.string().min(1), +}) + +const baseSchema = z.object({ + newPassphrase: z + .string() + .min(8, { message: "Passphrase must be at least 8 characters" }), + confirmPassphrase: z.string(), + seed: z.string().optional(), + privateKey: z.string().optional(), +}) + +const formSchema = baseSchema + .refine((data) => data.newPassphrase === data.confirmPassphrase, { + message: "Passphrases do not match", + path: ["confirmPassphrase"], + }) + .superRefine((data, ctx) => { + const mode: PassphraseRecoveryProofMode | null = data.privateKey?.trim() + ? "hex" + : data.seed?.trim() + ? "seed" + : null + if (mode === "hex") { + if (!/^(0x)?[0-9a-fA-F]{64}$/.test(data.privateKey?.trim() ?? "")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Must be a valid 64-hex-char EVM private key", + path: ["privateKey"], + }) + } + } else if (mode === "seed") { + const words = (data.seed?.trim() ?? "").split(/\s+/).filter(Boolean) + if (words.length < 12) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter your full seed phrase (at least 12 words)", + path: ["seed"], + }) + } + } else { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter your seed phrase or private key", + path: ["seed"], + }) + } + }) + +type FormData = z.infer + +export const Route = createFileRoute("/login/recover-seed")({ + validateSearch: searchSchema, + beforeLoad: ({ search }) => { + if (!search.address?.trim()) { + throw redirect({ to: "/login" }) + } + }, + component: RecoverPassphrase, + head: () => ({ + meta: [{ title: "Recover passphrase" }], + }), +}) + +function RecoverPassphrase() { + const navigate = useNavigate() + const { address } = Route.useSearch() + const [proofMode, setProofMode] = useState("seed") + const [submitError, setSubmitError] = useState(null) + const [unavailable, setUnavailable] = useState(false) + + const { + data: wallets = [], + isPending: walletsPending, + } = useQuery({ + queryKey: ["wallets"], + queryFn: () => WalletsService.listWallets(), + }) + + const matchedWallet = useMemo( + () => + wallets.find( + (wallet) => wallet.address.toLowerCase() === address.trim().toLowerCase(), + ), + [wallets, address], + ) + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + defaultValues: { + newPassphrase: "", + confirmPassphrase: "", + seed: "", + privateKey: "", + }, + }) + + const onSubmit = async (data: FormData) => { + setSubmitError(null) + setUnavailable(false) + try { + await SetupService.recoverWalletFromSeedRoute({ + requestBody: { + address: address.trim(), + new_passphrase: data.newPassphrase, + seed: proofMode === "seed" ? data.seed?.trim() : null, + private_key: proofMode === "hex" ? data.privateKey?.trim() : null, + }, + }) + markLoginPassphraseRecoverySuccess() + await navigate({ to: "/login" }) + } catch (error) { + const { message, unavailable: recoveryUnavailable } = + resolveRecoverSeedSubmitError(error) + setUnavailable(recoveryUnavailable) + setSubmitError(message) + } + } + + return ( + + + + + Reset wallet passphrase + + + + + + + + This replaces the passphrase for this wallet on this node. You + will need the new passphrase to unlock. + + + + + {unavailable ? ( + + Recovery unavailable + {submitError} + + ) : null} + + { + const mode = value as PassphraseRecoveryProofMode + setProofMode(mode) + setSubmitError(null) + form.setValue("seed", "") + form.setValue("privateKey", "") + }} + > + + + Seed phrase + + + Private key + + + + ( + + Seed phrase + + { + setSubmitError(null) + field.onChange(event) + }} + /> + + + + )} + /> + + + ( + + Private key (hex) + + { + setSubmitError(null) + field.onChange(event) + }} + /> + + + + )} + /> + + + + + ( + + New passphrase + + + + + + )} + /> + ( + + Confirm new passphrase + + + + + + )} + /> + + + {submitError && !unavailable ? ( + + {submitError} + + ) : null} + + + Reset passphrase + + + + + Back to unlock + + + + + + ) +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/utils.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/utils.ts index 62363553bd..b411b804a4 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/utils.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/utils.ts @@ -20,6 +20,16 @@ export function extractErrorMessage(err: ApiError): string { if (typeof errDetail === "string") { return errDetail } + if (typeof err.body === "string" && err.body.trim().length > 0) { + return err.body + } + if ( + typeof err.message === "string" && + err.message.trim().length > 0 && + err.message !== err.statusText + ) { + return err.message + } return "Something went wrong." } diff --git a/packages/tentacles/Services/Services_bases/node_api_service/node_api.py b/packages/tentacles/Services/Services_bases/node_api_service/node_api.py index 420e1e42a2..56383c5762 100644 --- a/packages/tentacles/Services/Services_bases/node_api_service/node_api.py +++ b/packages/tentacles/Services/Services_bases/node_api_service/node_api.py @@ -121,6 +121,7 @@ async def stop(self): self.api_app.stop() async def prepare(self) -> None: + self.logger.info("Node API service: prepare started") try: node_config = self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_NODE_API] self.node_api_url = node_config.get(services_constants.NODE_API_URL) @@ -141,10 +142,12 @@ async def prepare(self) -> None: self._sync_config() self._register_mirror_context_provider() if self.get_is_enabled(self.config) and not octobot_node.scheduler.is_initialized(): + self.logger.info("Node API service: initializing scheduler from prepare()") await octobot_node.scheduler.initialize_scheduler() await internal_trading_signals.subscribe_internal_trading_signal_consumer() if octobot_node.scheduler.is_initialized(): await journal_startup.complete_reconcile_automations() + self.logger.info("Node API service: prepare finished") def _sync_config(self): defaults = self.get_default_value() @@ -191,7 +194,11 @@ def _get_node_api_server_url(self): return f"{network_module.LOCAL_HOST_IP}:{port}" def get_successful_startup_message(self): - return f"Node API interface successfully initialized and accessible at: http://{self._get_node_api_server_url()}.", True + return ( + f"Node API service configured at: http://{self._get_node_api_server_url()} " + f"(HTTP served by NodeApiInterface when its thread reaches uvicorn serve()).", + True, + ) def get_bind_host(self): return os.getenv(services_constants.ENV_NODE_API_ADDRESS, services_constants.DEFAULT_NODE_API_IP) diff --git a/tests/unit_tests/community/test_wallet_backend.py b/tests/unit_tests/community/test_wallet_backend.py index a13db8df31..206e2b2c84 100644 --- a/tests/unit_tests/community/test_wallet_backend.py +++ b/tests/unit_tests/community/test_wallet_backend.py @@ -18,7 +18,14 @@ from unittest import mock from octobot.community.wallet_backend.community_wallet import WalletBackend, WalletEntry -from octobot.community.wallet_backend.errors import InvalidPrivateKeyError, WalletAlreadyExistsError +from octobot.community.wallet_backend.errors import ( + InvalidPrivateKeyError, + PassphraseTooShortError, + WalletAlreadyExistsError, + WalletNotFoundError, + WalletProofMismatchError, + WalletStorageReadOnlyError, +) # BIP-39 test mnemonic (well-known test vector) @@ -196,6 +203,189 @@ def test_records_journal_on_unknown_address(self): assert "http_status" not in record_mock.call_args.kwargs +class TestRecoverPassphraseFromOwnershipProof: + def test_recover_with_bip39_seed_updates_passphrase_hash(self): + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + seed=_TEST_MNEMONIC, + ) + assert backend.verify_wallet_passphrase(_TEST_MNEMONIC_ADDRESS, "new-passphrase99") + assert not backend.verify_wallet_passphrase(_TEST_MNEMONIC_ADDRESS, "old-passphrase123") + + def test_recover_with_hex_private_key(self): + from octobot_sync.chain.evm import create_evm_wallet + + backend, _ = _make_backend() + wallet = create_evm_wallet() + backend.import_wallet(wallet.private_key, "old-passphrase123", name=None) + backend.recover_passphrase_from_ownership_proof( + wallet.address, + "new-passphrase99", + private_key=wallet.private_key, + ) + assert backend.verify_wallet_passphrase(wallet.address, "new-passphrase99") + + def test_recover_mismatch_does_not_change_passphrase(self): + from octobot_sync.chain.evm import create_evm_wallet + + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + other = create_evm_wallet() + with pytest.raises(WalletProofMismatchError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + private_key=other.private_key, + ) + assert backend.verify_wallet_passphrase(_TEST_MNEMONIC_ADDRESS, "old-passphrase123") + + def test_recover_invalid_seed_raises(self): + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + with pytest.raises(InvalidPrivateKeyError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + seed="not a valid mnemonic phrase", + ) + + def test_recover_short_passphrase_raises(self): + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + with pytest.raises(PassphraseTooShortError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "short", + seed=_TEST_MNEMONIC, + ) + + def test_recover_read_only_storage_raises(self): + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + backend._storage.save.side_effect = NotImplementedError("read-only") + with pytest.raises(WalletStorageReadOnlyError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + seed=_TEST_MNEMONIC, + ) + + def test_recover_both_seed_and_private_key_raises(self): + from octobot_sync.chain.evm import create_evm_wallet + + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + other = create_evm_wallet() + with pytest.raises(InvalidPrivateKeyError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + seed=_TEST_MNEMONIC, + private_key=other.private_key, + ) + assert backend.verify_wallet_passphrase(_TEST_MNEMONIC_ADDRESS, "old-passphrase123") + + def test_recover_neither_seed_nor_private_key_raises(self): + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + with pytest.raises(InvalidPrivateKeyError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + ) + assert backend.verify_wallet_passphrase(_TEST_MNEMONIC_ADDRESS, "old-passphrase123") + + def test_recover_unknown_address_raises(self): + backend, _ = _make_backend() + with pytest.raises(WalletNotFoundError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + seed=_TEST_MNEMONIC, + ) + + def test_recover_invalid_private_key_raises(self): + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + with pytest.raises(InvalidPrivateKeyError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + private_key="not-a-valid-key", + ) + assert backend.verify_wallet_passphrase(_TEST_MNEMONIC_ADDRESS, "old-passphrase123") + + +class TestRecoverPassphraseFromOwnershipProofJournal: + def test_records_journal_on_unknown_address(self): + backend, _ = _make_backend() + with mock.patch(_JOURNAL_PATCH) as record_mock: + with pytest.raises(WalletNotFoundError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + seed=_TEST_MNEMONIC, + ) + record_mock.assert_called_once() + assert record_mock.call_args.kwargs["operation"] == "recover_passphrase" + assert isinstance(record_mock.call_args.kwargs["error"], WalletNotFoundError) + assert "http_status" not in record_mock.call_args.kwargs + + def test_does_not_record_journal_on_proof_mismatch(self): + from octobot_sync.chain.evm import create_evm_wallet + + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + other = create_evm_wallet() + with mock.patch(_JOURNAL_PATCH) as record_mock: + with pytest.raises(WalletProofMismatchError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + private_key=other.private_key, + ) + record_mock.assert_not_called() + + def test_does_not_record_journal_on_invalid_seed(self): + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + with mock.patch(_JOURNAL_PATCH) as record_mock: + with pytest.raises(InvalidPrivateKeyError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + seed="not a valid mnemonic phrase", + ) + record_mock.assert_not_called() + + def test_does_not_record_journal_on_read_only_storage(self): + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + backend._storage.save.side_effect = NotImplementedError("read-only") + with mock.patch(_JOURNAL_PATCH) as record_mock: + with pytest.raises(WalletStorageReadOnlyError): + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + seed=_TEST_MNEMONIC, + ) + record_mock.assert_not_called() + + def test_success_does_not_record_wallet_operation_failed(self): + backend, _ = _make_backend() + backend.import_wallet_from_seed(_TEST_MNEMONIC, "old-passphrase123", name=None) + with mock.patch(_JOURNAL_PATCH) as record_mock: + backend.recover_passphrase_from_ownership_proof( + _TEST_MNEMONIC_ADDRESS, + "new-passphrase99", + seed=_TEST_MNEMONIC, + ) + record_mock.assert_not_called() + + class TestRenameWalletJournal: def test_records_journal_on_unknown_address(self): from octobot.community.wallet_backend.errors import WalletNotFoundError
- Could not reach the node. Please check your connection and reload. -
- Select the wallet you want to connect with. -
- Enter the passphrase for this wallet. -
- Enter your passphrase to continue. -
- {sessionExpiredCopy.title} -
{sessionExpiredCopy.explanation}
+ Could not reach the node. Please check your connection and reload. +
+ Select the wallet you want to connect with. +
+ Enter the passphrase for this wallet. +
+ Enter your passphrase to continue. +
+ {sessionExpiredCopy.title} +
+ + Forgot passphrase? + +
+ This replaces the passphrase for this wallet on this node. You + will need the new passphrase to unlock. +
Recovery unavailable
{submitError}
+ {submitError} +
+ + Back to unlock + +