Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions dev.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
39 changes: 31 additions & 8 deletions frontend/components/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,49 @@ 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)]"
>
<BrandMark />
<div className="hidden min-w-0 flex-col gap-0.5 pt-px sm:flex">
<span className="modulr-text text-xs font-semibold leading-tight sm:text-sm">
Modulr.Core
</span>
<span
className="modulr-text-muted text-[10px] font-medium leading-tight tracking-wide"
className="modulr-text-muted flex flex-col gap-0.5 text-[10px] font-medium leading-tight tracking-wide"
title={
coreVersion.kind === "error"
? coreVersion.message
: "Wire version from Core GET /version"
: coreVersion.kind === "ok"
? [
"Wire version from Core GET /version (not a POST /message method).",
coreVersion.networkEnvironment
? `network_environment: ${coreVersion.networkEnvironment}`
: null,
coreVersion.genesisOperationsAllowed !== undefined
? `genesis_operations_allowed: ${coreVersion.genesisOperationsAllowed}`
: null,
]
.filter(Boolean)
.join("\n")
: "Wire version from Core GET /version"
}
>
{coreVersion.kind === "loading"
? "v…"
: coreVersion.kind === "ok"
? `v${coreVersion.version}`
: "unreachable"}
{coreVersion.kind === "loading" ? (
"v…"
) : coreVersion.kind === "ok" ? (
<>
<span>{`v${coreVersion.version}`}</span>
{(coreVersion.networkDisplayName ||
coreVersion.networkEnvironment) && (
<span className="text-[9px] font-normal opacity-90">
{coreVersion.networkDisplayName ??
coreVersion.networkEnvironment}
</span>
)}
</>
) : (
"unreachable"
)}
</span>
</div>
</Link>
Expand Down
8 changes: 4 additions & 4 deletions frontend/components/shell/BrandMark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svg>` 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 (
<div className="min-w-0 max-w-[140px] shrink-0 origin-left scale-100 sm:scale-[1.125]">
<ModulrSymbol pixelHeight={24} className="text-[var(--modulr-accent)]" />
<div className="min-w-0 max-w-[170px] shrink-0 origin-left">
<ModulrSymbol pixelHeight={40} className="text-[var(--modulr-accent)]" />
</div>
);
}
18 changes: 16 additions & 2 deletions frontend/hooks/useCoreVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
21 changes: 20 additions & 1 deletion frontend/lib/coreApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoreVersionJson> {
Expand All @@ -30,7 +36,20 @@ export async function fetchCoreVersion(baseUrl: string): Promise<CoreVersionJson
if (typeof version !== "string" || typeof target_module !== "string") {
throw new Error("Invalid /version response shape");
}
return { version, target_module };
const network_environment =
typeof o.network_environment === "string" ? o.network_environment : undefined;
const network_name = typeof o.network_name === "string" ? o.network_name : undefined;
const genesis_operations_allowed =
typeof o.genesis_operations_allowed === "boolean"
? o.genesis_operations_allowed
: undefined;
return {
version,
target_module,
network_environment,
network_name,
genesis_operations_allowed,
};
}

function errFromEnvelope(data: Record<string, unknown>, status: number): string {
Expand Down
6 changes: 6 additions & 0 deletions src/modulr_core/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand All @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions src/modulr_core/config/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions src/modulr_core/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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]
7 changes: 6 additions & 1 deletion src/modulr_core/http/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading