diff --git a/README.md b/README.md index ecdd0bb..6618bc0 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Defaults: **`127.0.0.1:8000`**. Override with `--host` / `--port`. If you see ** A **config file is required**: use **`--config dev.toml`** or set **`MODULR_CORE_CONFIG`** to a TOML path. If the port is already taken, the CLI exits with a short error before starting uvicorn. -**Read-only:** **`GET /version`** returns JSON `target_module` and `version` (for UI connectivity). In **`dev_mode`**, CORS allows the local customer UI origins unless **`MODULR_CORE_CORS_ORIGINS`** is set (comma-separated list). +**Read-only:** **`GET /version`** returns JSON `target_module`, `version`, **`network_environment`** (`local` | `testnet` | `production`, default `production` if omitted in config), **`network_name`** (operator display string — set `network_name` in TOML or get a default like `Modulr (local)`), and **`genesis_operations_allowed`** (boolean; `true` only on `local` / `testnet`). In **`dev_mode`**, CORS allows the local customer UI origins unless **`MODULR_CORE_CORS_ORIGINS`** is set (comma-separated list). **`network_environment = "production"`** cannot be combined with **`dev_mode = true`** (configuration is rejected at startup). ### Customer web UI (stage 1) diff --git a/dev.toml b/dev.toml index 75d4b86..71ce020 100644 --- a/dev.toml +++ b/dev.toml @@ -3,4 +3,7 @@ [modulr_core] dev_mode = true +network_environment = "local" +# Optional human label for UIs and GET /version (like an Ethereum network name). +# network_name = "Modulr Dev" bootstrap_public_keys = [] diff --git a/frontend/components/shell/AppShell.tsx b/frontend/components/shell/AppShell.tsx index 2e6f249..335c6c2 100644 --- a/frontend/components/shell/AppShell.tsx +++ b/frontend/components/shell/AppShell.tsx @@ -40,7 +40,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { href="/" aria-label="Modulr.Core home" aria-current={pathname === "/" ? "page" : undefined} - className="flex min-w-0 shrink-0 items-start gap-3 rounded-xl outline-offset-2 transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--modulr-accent)]" + className="flex min-w-0 shrink-0 items-center gap-3 rounded-xl outline-offset-2 transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--modulr-accent)]" >
@@ -48,18 +48,41 @@ export function AppShell({ children }: { children: React.ReactNode }) { Modulr.Core - {coreVersion.kind === "loading" - ? "v…" - : coreVersion.kind === "ok" - ? `v${coreVersion.version}` - : "unreachable"} + {coreVersion.kind === "loading" ? ( + "v…" + ) : coreVersion.kind === "ok" ? ( + <> + {`v${coreVersion.version}`} + {(coreVersion.networkDisplayName || + coreVersion.networkEnvironment) && ( + + {coreVersion.networkDisplayName ?? + coreVersion.networkEnvironment} + + )} + + ) : ( + "unreachable" + )}
diff --git a/frontend/components/shell/BrandMark.tsx b/frontend/components/shell/BrandMark.tsx index 91d96f5..2fe692c 100644 --- a/frontend/components/shell/BrandMark.tsx +++ b/frontend/components/shell/BrandMark.tsx @@ -3,13 +3,13 @@ import { ModulrSymbol } from "@/components/brand/ModulrSymbol"; /** - * Header mark: explicit SVG dimensions + max width. Slight scale-up from `sm` matches - * prior `27px` target without a second `` in the DOM. + * Header mark: height tuned to align with the brand title + wire version + network + * line in `AppShell` (three text rows on `sm+`). */ export function BrandMark() { return ( -
- +
+
); } diff --git a/frontend/hooks/useCoreVersion.ts b/frontend/hooks/useCoreVersion.ts index 178ccd1..2067320 100644 --- a/frontend/hooks/useCoreVersion.ts +++ b/frontend/hooks/useCoreVersion.ts @@ -9,7 +9,13 @@ import { formatClientError } from "@/lib/formatClientError"; export type CoreVersionState = | { kind: "loading" } - | { kind: "ok"; version: string } + | { + kind: "ok"; + version: string; + networkEnvironment?: string; + networkDisplayName?: string; + genesisOperationsAllowed?: boolean; + } | { kind: "error"; message: string }; export function useCoreVersion(): CoreVersionState { @@ -28,7 +34,15 @@ export function useCoreVersion(): CoreVersionState { setState({ kind: "loading" }); fetchCoreVersion(base) .then((v) => { - if (!cancelled) setState({ kind: "ok", version: v.version }); + if (!cancelled) { + setState({ + kind: "ok", + version: v.version, + networkEnvironment: v.network_environment, + networkDisplayName: v.network_name, + genesisOperationsAllowed: v.genesis_operations_allowed, + }); + } }) .catch((e: unknown) => { if (!cancelled) { diff --git a/frontend/lib/coreApi.ts b/frontend/lib/coreApi.ts index 65198d5..0a5c11d 100644 --- a/frontend/lib/coreApi.ts +++ b/frontend/lib/coreApi.ts @@ -2,9 +2,15 @@ import { buildSignedMessageBody } from "@/lib/modulrWire/signCoreMessage"; import { primaryCoreBaseUrl } from "./coreBaseUrl"; +/** Parsed body of Core **GET /version** (not a `POST /message` operation). */ export type CoreVersionJson = { target_module: string; version: string; + /** Present on current Core: `local` | `testnet` | `production`. */ + network_environment?: string; + /** Resolved display label (custom `network_name` in TOML or tier default). */ + network_name?: string; + genesis_operations_allowed?: boolean; }; export async function fetchCoreVersion(baseUrl: string): Promise { @@ -30,7 +36,20 @@ export async function fetchCoreVersion(baseUrl: string): Promise, status: number): string { diff --git a/src/modulr_core/config/__init__.py b/src/modulr_core/config/__init__.py index 10993de..97db0f0 100644 --- a/src/modulr_core/config/__init__.py +++ b/src/modulr_core/config/__init__.py @@ -12,7 +12,10 @@ DEFAULT_MAX_EXPIRY_WINDOW_SECONDS, DEFAULT_MAX_HTTP_BODY_BYTES, DEFAULT_MAX_PAYLOAD_BYTES, + DEFAULT_NETWORK_ENVIRONMENT, DEFAULT_REPLAY_WINDOW_SECONDS, + NETWORK_NAME_MAX_LEN, + NetworkEnvironment, Settings, ) @@ -23,7 +26,10 @@ "DEFAULT_MAX_EXPIRY_WINDOW_SECONDS", "DEFAULT_MAX_HTTP_BODY_BYTES", "DEFAULT_MAX_PAYLOAD_BYTES", + "DEFAULT_NETWORK_ENVIRONMENT", "DEFAULT_REPLAY_WINDOW_SECONDS", + "NETWORK_NAME_MAX_LEN", + "NetworkEnvironment", "Settings", "load_settings", "load_settings_from_bytes", diff --git a/src/modulr_core/config/load.py b/src/modulr_core/config/load.py index be5cda7..5b1632b 100644 --- a/src/modulr_core/config/load.py +++ b/src/modulr_core/config/load.py @@ -15,7 +15,10 @@ DEFAULT_MAX_EXPIRY_WINDOW_SECONDS, DEFAULT_MAX_HTTP_BODY_BYTES, DEFAULT_MAX_PAYLOAD_BYTES, + DEFAULT_NETWORK_ENVIRONMENT, DEFAULT_REPLAY_WINDOW_SECONDS, + NETWORK_NAME_MAX_LEN, + NetworkEnvironment, Settings, ) from modulr_core.errors.exceptions import ConfigurationError, InvalidHexEncoding @@ -80,6 +83,13 @@ def _settings_from_root( ) dev_mode = _bool_opt(table, "dev_mode", DEFAULT_DEV_MODE, source) + network_environment = _network_environment_opt(table, source) + network_name = _network_name_opt(table, source) + if network_environment is NetworkEnvironment.PRODUCTION and dev_mode: + raise ConfigurationError( + f'network_environment "production" cannot be combined with dev_mode true ' + f"({source})", + ) keys = _bootstrap_public_keys(table.get("bootstrap_public_keys"), source, dev_mode) database_path = _path_opt( @@ -142,6 +152,8 @@ def _settings_from_root( future_timestamp_skew_seconds=skew, replay_window_seconds=replay, dev_mode=dev_mode, + network_environment=network_environment, + network_name=network_name, ) @@ -191,6 +203,46 @@ def _bootstrap_public_keys( return tuple(out) +def _network_environment_opt( + table: dict[str, Any], + source: str, +) -> NetworkEnvironment: + key = "network_environment" + if key not in table: + return DEFAULT_NETWORK_ENVIRONMENT + v = table[key] + if not isinstance(v, str) or not v.strip(): + raise ConfigurationError( + f"network_environment must be a non-empty string ({source})", + ) + raw = v.strip().lower() + try: + return NetworkEnvironment(raw) + except ValueError: + raise ConfigurationError( + f'network_environment must be "local", "testnet", or "production" ' + f"({source})", + ) from None + + +def _network_name_opt(table: dict[str, Any], source: str) -> str: + key = "network_name" + if key not in table: + return "" + v = table[key] + if not isinstance(v, str): + raise ConfigurationError( + f"network_name must be a string ({source})", + ) + name = v.strip() + if len(name) > NETWORK_NAME_MAX_LEN: + raise ConfigurationError( + f"network_name must be at most {NETWORK_NAME_MAX_LEN} characters " + f"({source})", + ) + return name + + def _bool_opt(table: dict[str, Any], key: str, default: bool, source: str) -> bool: if key not in table: return default diff --git a/src/modulr_core/config/schema.py b/src/modulr_core/config/schema.py index 433f367..f87d4a9 100644 --- a/src/modulr_core/config/schema.py +++ b/src/modulr_core/config/schema.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum from pathlib import Path # Default path segment when omitted in TOML. With :func:`load_settings`, a relative @@ -16,6 +17,26 @@ DEFAULT_REPLAY_WINDOW_SECONDS = 86_400 # 24 hours DEFAULT_DEV_MODE = False +NETWORK_NAME_MAX_LEN = 64 + + +class NetworkEnvironment(StrEnum): + """Deployment tier: genesis tooling allowed on local and testnet only.""" + + LOCAL = "local" + TESTNET = "testnet" + PRODUCTION = "production" + + +# Omitted in TOML → production (safe default for real deploys). +DEFAULT_NETWORK_ENVIRONMENT = NetworkEnvironment.PRODUCTION + +_DEFAULT_DISPLAY_NAMES: dict[NetworkEnvironment, str] = { + NetworkEnvironment.LOCAL: "Modulr (local)", + NetworkEnvironment.TESTNET: "Modulr (testnet)", + NetworkEnvironment.PRODUCTION: "Modulr (production)", +} + @dataclass(frozen=True) class Settings: @@ -31,3 +52,20 @@ class Settings: future_timestamp_skew_seconds: int replay_window_seconds: int dev_mode: bool + network_environment: NetworkEnvironment + """``local`` / ``testnet`` may expose genesis flows; ``production`` must not.""" + network_name: str + """Custom name for UIs (e.g. chain name). If empty, a tier default is used.""" + + def genesis_operations_allowed(self) -> bool: + """True when genesis wizard / reset may run (non-production tiers).""" + return self.network_environment in ( + NetworkEnvironment.LOCAL, + NetworkEnvironment.TESTNET, + ) + + def resolved_network_display_name(self) -> str: + """Operator-facing network title for UIs and :http:get:`/version`.""" + if self.network_name.strip(): + return self.network_name.strip() + return _DEFAULT_DISPLAY_NAMES[self.network_environment] diff --git a/src/modulr_core/http/app.py b/src/modulr_core/http/app.py index 4d787cf..8c8f738 100644 --- a/src/modulr_core/http/app.py +++ b/src/modulr_core/http/app.py @@ -10,6 +10,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path +from typing import Any from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware @@ -126,11 +127,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: ) @app.get("/version") - async def get_version() -> dict[str, str]: + async def get_version() -> dict[str, Any]: """Read-only metadata for connectivity checks (no signed envelope).""" + s = app.state.settings return { "target_module": TARGET_MODULE_CORE, "version": MODULE_VERSION, + "network_environment": s.network_environment.value, + "network_name": s.resolved_network_display_name(), + "genesis_operations_allowed": s.genesis_operations_allowed(), } @app.post("/message") diff --git a/tests/test_config.py b/tests/test_config.py index cf7a1f1..be7db02 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,6 +15,8 @@ DEFAULT_MAX_HTTP_BODY_BYTES, DEFAULT_MAX_PAYLOAD_BYTES, DEFAULT_REPLAY_WINDOW_SECONDS, + NETWORK_NAME_MAX_LEN, + NetworkEnvironment, Settings, load_settings, load_settings_from_bytes, @@ -44,6 +46,10 @@ def test_minimal_config_with_one_key() -> None: assert s.future_timestamp_skew_seconds == DEFAULT_FUTURE_TIMESTAMP_SKEW_SECONDS assert s.replay_window_seconds == DEFAULT_REPLAY_WINDOW_SECONDS assert s.dev_mode is DEFAULT_DEV_MODE + assert s.network_environment is NetworkEnvironment.PRODUCTION + assert s.network_name == "" + assert s.genesis_operations_allowed() is False + assert s.resolved_network_display_name() == "Modulr (production)" def test_full_config_overrides() -> None: @@ -59,6 +65,8 @@ def test_full_config_overrides() -> None: future_timestamp_skew_seconds = 60 replay_window_seconds = 120 dev_mode = true +network_environment = "testnet" +network_name = "Holesky-style" """, ) assert s.database_path == Path("custom/db.sqlite") @@ -68,6 +76,10 @@ def test_full_config_overrides() -> None: assert s.future_timestamp_skew_seconds == 60 assert s.replay_window_seconds == 120 assert s.dev_mode is True + assert s.network_environment is NetworkEnvironment.TESTNET + assert s.network_name == "Holesky-style" + assert s.genesis_operations_allowed() is True + assert s.resolved_network_display_name() == "Holesky-style" def test_dev_mode_allows_empty_bootstrap() -> None: @@ -75,11 +87,13 @@ def test_dev_mode_allows_empty_bootstrap() -> None: """ [modulr_core] dev_mode = true +network_environment = "local" bootstrap_public_keys = [] """, ) assert s.bootstrap_public_keys == () assert s.dev_mode is True + assert s.network_environment is NetworkEnvironment.LOCAL def test_missing_modulr_core_table() -> None: @@ -211,3 +225,40 @@ def test_load_settings_relative_database_path_is_resolved_vs_config_dir( ) s = load_settings(p) assert s.database_path == (cfg_dir / "state" / "db.sqlite").resolve() + + +def test_network_environment_invalid() -> None: + k = _valid_hex_pubkey() + with pytest.raises(ConfigurationError, match="network_environment must be"): + load_settings_from_str( + f""" +[modulr_core] +bootstrap_public_keys = ["{k}"] +network_environment = "mainnet" +""", + ) + + +def test_network_name_too_long() -> None: + k = _valid_hex_pubkey() + with pytest.raises(ConfigurationError, match="network_name must be at most"): + load_settings_from_str( + f""" +[modulr_core] +bootstrap_public_keys = ["{k}"] +network_name = "{'x' * (NETWORK_NAME_MAX_LEN + 1)}" +""", + ) + + +def test_production_with_dev_mode_rejected() -> None: + k = _valid_hex_pubkey() + with pytest.raises(ConfigurationError, match="cannot be combined"): + load_settings_from_str( + f""" +[modulr_core] +dev_mode = true +network_environment = "production" +bootstrap_public_keys = ["{k}"] +""", + ) diff --git a/tests/test_http.py b/tests/test_http.py index da2ef62..bfd2d8e 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -14,7 +14,7 @@ from fastapi.testclient import TestClient from modulr_core import MODULE_VERSION, ErrorCode, SuccessCode -from modulr_core.config.schema import Settings +from modulr_core.config.schema import NetworkEnvironment, Settings from modulr_core.errors.exceptions import ConfigurationError from modulr_core.http import create_app, resolve_config_path from modulr_core.messages.constants import CORE_OPERATIONS, PROTOCOL_METHOD_OPERATIONS @@ -36,6 +36,8 @@ def _settings(**overrides: Any) -> Settings: future_timestamp_skew_seconds=300, replay_window_seconds=86_400, dev_mode=True, + network_environment=NetworkEnvironment.LOCAL, + network_name="", ) return replace(base, **overrides) @@ -293,7 +295,10 @@ def test_playground_not_mounted_when_not_dev_mode() -> None: def test_get_version() -> None: app = create_app( - settings=_settings(), + settings=_settings( + network_environment=NetworkEnvironment.TESTNET, + network_name="Modulr Test", + ), conn=_conn(), clock=lambda: 1_700_000_010.0, ) @@ -303,6 +308,31 @@ def test_get_version() -> None: data = r.json() assert data["target_module"] == "modulr.core" assert data["version"] == MODULE_VERSION + assert data["network_environment"] == "testnet" + assert data["network_name"] == "Modulr Test" + assert data["genesis_operations_allowed"] is True + + +def test_get_version_production_no_genesis() -> None: + pk = Ed25519PrivateKey.generate() + pk_hex = pk.public_key().public_bytes( + encoding=Encoding.Raw, + format=PublicFormat.Raw, + ).hex() + app = create_app( + settings=_settings( + dev_mode=False, + network_environment=NetworkEnvironment.PRODUCTION, + bootstrap_public_keys=(pk_hex,), + ), + conn=_conn(), + clock=lambda: 1_700_000_010.0, + ) + client = TestClient(app) + data = client.get("/version").json() + assert data["network_environment"] == "production" + assert data["genesis_operations_allowed"] is False + assert data["network_name"] == "Modulr (production)" def test_post_message_get_protocol_version() -> None: diff --git a/tests/test_messages_pipeline.py b/tests/test_messages_pipeline.py index 9b809c9..af37d43 100644 --- a/tests/test_messages_pipeline.py +++ b/tests/test_messages_pipeline.py @@ -13,7 +13,7 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from modulr_core import MODULE_VERSION, ErrorCode, WireValidationError -from modulr_core.config.schema import Settings +from modulr_core.config.schema import NetworkEnvironment, Settings from modulr_core.messages import ValidatedInbound, validate_inbound_request from modulr_core.persistence import apply_migrations, connect_memory from modulr_core.validation import envelope_signing_bytes, payload_hash @@ -29,6 +29,8 @@ def _settings(**overrides: Any) -> Settings: future_timestamp_skew_seconds=300, replay_window_seconds=86_400, dev_mode=True, + network_environment=NetworkEnvironment.LOCAL, + network_name="", ) return replace(base, **overrides) diff --git a/tests/test_operations.py b/tests/test_operations.py index bc8b17e..40dc706 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -19,7 +19,7 @@ SuccessCode, WireValidationError, ) -from modulr_core.config.schema import Settings +from modulr_core.config.schema import NetworkEnvironment, Settings from modulr_core.messages.constants import CORE_OPERATIONS, PROTOCOL_METHOD_OPERATIONS from modulr_core.messages.types import ValidatedInbound from modulr_core.messages.wire_method_catalog import ( @@ -45,6 +45,8 @@ def _settings(**overrides: Any) -> Settings: future_timestamp_skew_seconds=300, replay_window_seconds=86_400, dev_mode=True, + network_environment=NetworkEnvironment.LOCAL, + network_name="", ) return replace(base, **overrides)