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)