diff --git a/README.md b/README.md
index 30257b1..1c840df 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
Verae MCP timestamping server. Hash-only stamps via leaf locald/NATS.
-The MCP process never hashes content. Clients send a SHA-256 hex digest. `scope=local` writes `~/.verae/mcp-timestamp-local.json`. `scope=global` POSTs to `https://api.veraetime.net/api/timestamp` (same wire as `verae-timestamp/global.Client`). `want_scope=global` verifies against that API as the central source of truth. Bearer token: `VERAE_GLOBAL_TOKEN` or `~/.verae/auth.properties`.
+The MCP process never hashes content. Clients send a SHA-256 hex digest. `scope=local` writes `~/.verae/mcp-timestamp-local.json`. `scope=global` POSTs to `https://api.veraetime.net/api/timestamp` (same wire as `verae-timestamp/global.Client`). `want_scope=global` verifies against that API as the central source of truth. `signup` creates a `user` account with no existing session. `login` stores the VeraeTime JWT on this host. `session` checks it. `create_user` adds an account when that JWT is an admin.
## Install
@@ -40,10 +40,10 @@ uv run python scripts/transaction_log_test.py
open debug-trace/2026-09-20T143541Z-verae-debug-test/transaction-log-test.html
```
-Global stamp (central VeraeTime). Token lives at `~/.verae/auth.properties` (mode 600):
+Global stamp (central VeraeTime). `login` writes `~/.verae/auth.properties` (mode 600) and does not return the JWT:
```sh
-# refresh: POST https://api.veraetime.net/auth/login → access_token=
+# MCP tools: signup, login, session, create_user
# or: export VERAE_GLOBAL_TOKEN=...
# then stamp with scope=global
```
diff --git a/docs/api.md b/docs/api.md
index 9e8411d..097fee5 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -36,6 +36,42 @@ Scope rank: local < shared < global.
Returns `{ "receipts": [ ... ] }` from the local file. Global-only certificates are not listed.
+## login
+
+| Argument | Default | Meaning |
+| --- | --- | --- |
+| `username` | required | VeraeTime account |
+| `password` | required | VeraeTime password |
+
+`POST /auth/login`. Stores `access_token`, `id_token`, `auth_token`, `expires_at`, `username`, and `password` in the auth properties file (mode `0600`) and replaces the in-memory JWT. Returns `username`, `role`, and `expires_at`. The JWT is not returned.
+
+## session
+
+No arguments. `GET /auth/validate` with the stored JWT.
+
+Returns `valid`, `username`, `role`, and `expires_at`.
+
+## signup
+
+| Argument | Default | Meaning |
+| --- | --- | --- |
+| `username` | required | New account |
+| `password` | required | New password |
+
+`POST /auth/signup` with no bearer token. The timestamping service creates the account with role `user`. Returns `id`, `username`, and `role`. Does not replace the JWT stored for `stamp`. A new account calls `login` to become the stored session. `create_user` remains the admin path and can set `admin`.
+
+## create_user
+
+| Argument | Default | Meaning |
+| --- | --- | --- |
+| `username` | required | New account |
+| `password` | required | New password |
+| `role` | required | `user` or `admin` |
+
+`POST /auth/users` with the stored JWT. Refuses unless `session` reports role `admin`. Returns `id`, `username`, and `role`. There is no public signup; the first admin already exists on the timestamping service.
+
+A stored login is refreshed when `stamp` or `verify` runs and `expires_at` is missing or within 5 minutes, if `username` and `password` are in the auth file.
+
## Environment
| Name | Default |
diff --git a/docs/html/api.html b/docs/html/api.html
index c8a6f1e..61836ce 100644
--- a/docs/html/api.html
+++ b/docs/html/api.html
@@ -201,6 +201,104 @@ when the stored scope ranks lower
Returns { "receipts": [ ... ] } from the local file.
Global-only certificates are not listed.
+login
+
+
+
+| Argument |
+Default |
+Meaning |
+
+
+
+
+username |
+required |
+VeraeTime account |
+
+
+password |
+required |
+VeraeTime password |
+
+
+
+POST /auth/login. Stores access_token,
+id_token, auth_token, expires_at,
+username, and password in the auth properties
+file (mode 0600) and replaces the in-memory JWT. Returns
+username, role, and expires_at.
+The JWT is not returned.
+session
+No arguments. GET /auth/validate with the stored
+JWT.
+Returns valid, username, role,
+and expires_at.
+signup
+
+
+
+| Argument |
+Default |
+Meaning |
+
+
+
+
+username |
+required |
+New account |
+
+
+password |
+required |
+New password |
+
+
+
+POST /auth/signup with no bearer token. The timestamping
+service creates the account with role user. Returns
+id, username, and role. Does not
+replace the JWT stored for stamp. A new account calls
+login to become the stored session.
+create_user remains the admin path and can set
+admin.
+create_user
+
+
+
+| Argument |
+Default |
+Meaning |
+
+
+
+
+username |
+required |
+New account |
+
+
+password |
+required |
+New password |
+
+
+role |
+required |
+user or admin |
+
+
+
+POST /auth/users with the stored JWT. Refuses unless
+session reports role admin. Returns
+id, username, and role. There is
+no public signup; the first admin already exists on the timestamping
+service.
+A stored login is refreshed when stamp or
+verify runs and expires_at is missing or
+within 5 minutes, if username and password are
+in the auth file.
Environment
diff --git a/docs/html/operator-guide.html b/docs/html/operator-guide.html
index 677ed10..148073b 100644
--- a/docs/html/operator-guide.html
+++ b/docs/html/operator-guide.html
@@ -112,9 +112,14 @@
site/index.html on the same machine, or put TLS and an
access control in front of a non-loopback bind you choose yourself.
Global token
-# POST https://api.veraetime.net/auth/login and store access_token=
-# in ~/.verae/auth.properties (mode 600), or:
-export VERAE_GLOBAL_TOKEN=...
+signup creates a role user account with no
+bearer token. Call login with that username and password to
+store the JWT in ~/.verae/auth.properties (mode 600). The
+server uses it for scope=global without a restart.
+session checks that JWT. create_user adds an
+account when the stored role is admin.
+# or skip the tool and export a token yourself:
+export VERAE_GLOBAL_TOKEN=...
Tests
Stamp traces (live VeraeTime) are a separate script. They write
diff --git a/docs/html/standards.html b/docs/html/standards.html
index 94f16ab..1228b78 100644
--- a/docs/html/standards.html
+++ b/docs/html/standards.html
@@ -90,6 +90,17 @@ means “not in the local file,” not “VeraeTime has no record.”
access_token / id_token /
auth_token in ~/.verae/auth.properties
(VERAE_AUTH_PROPERTIES overrides the path).
+login writes that file (mode 0600),
+including username, password, and
+expires_at. The tool result does not include the JWT.
+signup calls POST /auth/signup with no
+bearer token. The account role is user. It does not replace
+the stored JWT.
+create_user calls POST /auth/users only
+when the stored token’s role is admin.
+Before a global stamp or verify, a stored username and password are
+used to log in again when expires_at is missing or within 5
+minutes.
Do not commit tokens. .env is gitignored.
The local receipt file is mode 0600 after each
save.
diff --git a/docs/operator-guide.md b/docs/operator-guide.md
index c8e9cd0..7a47a93 100644
--- a/docs/operator-guide.md
+++ b/docs/operator-guide.md
@@ -31,9 +31,10 @@ A page served from the public internet still cannot call `127.0.0.1` from curren
## Global token
+`signup` creates a role `user` account with no bearer token. Call `login` with that username and password to store the JWT in `~/.verae/auth.properties` (mode 600). The server uses it for `scope=global` without a restart. `session` checks that JWT. `create_user` adds an account when the stored role is `admin`.
+
```sh
-# POST https://api.veraetime.net/auth/login and store access_token=
-# in ~/.verae/auth.properties (mode 600), or:
+# or skip the tool and export a token yourself:
export VERAE_GLOBAL_TOKEN=...
```
diff --git a/docs/pdf/api.pdf b/docs/pdf/api.pdf
index b7ce20e..84eac75 100644
Binary files a/docs/pdf/api.pdf and b/docs/pdf/api.pdf differ
diff --git a/docs/pdf/architecture.pdf b/docs/pdf/architecture.pdf
index 19b61e3..ed2f271 100644
Binary files a/docs/pdf/architecture.pdf and b/docs/pdf/architecture.pdf differ
diff --git a/docs/pdf/operator-guide.pdf b/docs/pdf/operator-guide.pdf
index e262c2f..8ae4214 100644
Binary files a/docs/pdf/operator-guide.pdf and b/docs/pdf/operator-guide.pdf differ
diff --git a/docs/pdf/standards.pdf b/docs/pdf/standards.pdf
index 68d9803..3c38a76 100644
Binary files a/docs/pdf/standards.pdf and b/docs/pdf/standards.pdf differ
diff --git a/docs/standards.md b/docs/standards.md
index 6b1db11..27cf627 100644
--- a/docs/standards.md
+++ b/docs/standards.md
@@ -29,6 +29,10 @@ This process is an MCP server (Model Context Protocol). Clients are assistants a
## Credentials
- Token: `VERAE_GLOBAL_TOKEN`, else `access_token` / `id_token` / `auth_token` in `~/.verae/auth.properties` (`VERAE_AUTH_PROPERTIES` overrides the path).
+- `login` writes that file (mode `0600`), including `username`, `password`, and `expires_at`. The tool result does not include the JWT.
+- `signup` calls `POST /auth/signup` with no bearer token. The account role is `user`. It does not replace the stored JWT.
+- `create_user` calls `POST /auth/users` only when the stored token's role is `admin`.
+- Before a global stamp or verify, a stored username and password are used to log in again when `expires_at` is missing or within 5 minutes.
- Do not commit tokens. `.env` is gitignored.
- The local receipt file is mode `0600` after each save.
diff --git a/src/verae_mcp_timestamping_server/global_client.py b/src/verae_mcp_timestamping_server/global_client.py
index 42becab..0192aee 100644
--- a/src/verae_mcp_timestamping_server/global_client.py
+++ b/src/verae_mcp_timestamping_server/global_client.py
@@ -9,12 +9,16 @@ from __future__ import annotations
import asyncio
import json
import os
+import re
import time
import urllib.error
import urllib.request
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Protocol
+from pydantic import BaseModel
+
from verae_mcp_timestamping_server.ledger import (
Receipt,
VerifyResult,
@@ -22,6 +26,8 @@ from verae_mcp_timestamping_server.ledger import (
)
DEFAULT_GLOBAL_URL = "https://api.veraetime.net"
+# Refresh a stored login this long before expires_at.
+TOKEN_REFRESH_SKEW = timedelta(minutes=5)
class HttpTransport(Protocol):
@@ -81,20 +87,86 @@ class UrllibHttp:
return _UrllibResp(status, body, raw, resp_headers)
+class LoginResult(BaseModel):
+ username: str
+ role: str
+ expires_at: str
+
+
+class SessionResult(BaseModel):
+ valid: bool = False
+ username: str = ""
+ role: str = ""
+ expires_at: str = ""
+
+
+class CreateUserResult(BaseModel):
+ id: str = ""
+ username: str
+ role: str
+
+
+def default_auth_path() -> Path:
+ override = os.environ.get("VERAE_AUTH_PROPERTIES")
+ if override:
+ return Path(override)
+ return Path.home() / ".verae" / "auth.properties"
+
+
+def read_properties(path: Path) -> dict[str, str]:
+ if not path.is_file():
+ return {}
+ props: dict[str, str] = {}
+ for line in path.read_text().splitlines():
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
+ continue
+ key, value = stripped.split("=", 1)
+ props[key.strip()] = value.strip()
+ return props
+
+
+def write_properties(path: Path, props: dict[str, str]) -> None:
+ path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
+ merged = read_properties(path)
+ merged.update(props)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text("".join(f"{key}={value}\n" for key, value in merged.items()))
+ os.chmod(tmp, 0o600)
+ tmp.replace(path)
+ try:
+ os.chmod(path, 0o600)
+ except OSError:
+ pass
+
+
+def parse_expiry(value: str) -> datetime | None:
+ text = value.strip()
+ if not text:
+ return None
+ if text.endswith("Z"):
+ text = text[:-1] + "+00:00"
+ match = re.match(r"^(.*T\d{2}:\d{2}:\d{2})(\.\d+)?([+-]\d{2}:\d{2})?$", text)
+ if match is None:
+ return None
+ frac = match.group(2) or ""
+ if frac:
+ frac = "." + frac[1:7]
+ zone = match.group(3) or "+00:00"
+ try:
+ parsed = datetime.fromisoformat(match.group(1) + frac + zone)
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
def load_auth_token() -> str:
env = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip()
if env:
return env
- path = Path(os.environ.get("VERAE_AUTH_PROPERTIES", Path.home() / ".verae" / "auth.properties"))
- if not path.is_file():
- return ""
- props: dict[str, str] = {}
- for line in path.read_text().splitlines():
- line = line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, value = line.split("=", 1)
- props[key.strip()] = value.strip()
+ props = read_properties(default_auth_path())
return props.get("access_token") or props.get("id_token") or props.get("auth_token") or ""
@@ -104,16 +176,161 @@ class GlobalClient:
base_url: str = DEFAULT_GLOBAL_URL,
token: str = "",
http: HttpTransport | None = None,
+ auth_path: Path | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.http = http or UrllibHttp()
+ self.auth_path = Path(auth_path) if auth_path is not None else None
+ self.expires_at: datetime | None = None
+ self._username = ""
+ self._password = ""
+ if self.auth_path is not None:
+ self._load_stored(keep_token=bool(token))
@classmethod
def from_env(cls) -> GlobalClient:
+ env_token = os.environ.get("VERAE_GLOBAL_TOKEN", "").strip()
return cls(
base_url=os.environ.get("VERAE_GLOBAL_URL", DEFAULT_GLOBAL_URL),
- token=load_auth_token(),
+ token=env_token,
+ auth_path=default_auth_path(),
+ )
+
+ def _load_stored(self, keep_token: bool) -> None:
+ if self.auth_path is None:
+ return
+ props = read_properties(self.auth_path)
+ if not keep_token:
+ self.token = (
+ props.get("access_token")
+ or props.get("id_token")
+ or props.get("auth_token")
+ or self.token
+ )
+ self._username = props.get("username") or ""
+ self._password = props.get("password") or ""
+ self.expires_at = parse_expiry(props.get("expires_at") or "")
+
+ def needs_refresh(self) -> bool:
+ if not self._username or not self._password:
+ return False
+ if not self.token or self.expires_at is None:
+ return True
+ return self.expires_at <= datetime.now(timezone.utc) + TOKEN_REFRESH_SKEW
+
+ def ensure_token(self) -> None:
+ if self.needs_refresh():
+ self.login(self._username, self._password)
+
+ def login(self, username: str, password: str) -> LoginResult:
+ if not username or not password:
+ raise ValueError("username and password are required")
+ res = self.http.request(
+ "POST",
+ f"{self.base_url}/auth/login",
+ payload={"username": username, "password": password},
+ headers={"Content-Type": "application/json"},
+ )
+ parsed = res.json() if hasattr(res, "json") else {}
+ if not isinstance(parsed, dict):
+ parsed = {}
+ if res.status_code >= 300:
+ detail = str(parsed.get("error") or res.text or "").strip()
+ raise ValueError(f"login HTTP {res.status_code}: {detail}")
+ token = str(parsed.get("token") or "")
+ if not token:
+ raise ValueError("login response missing token")
+ user = parsed.get("user") if isinstance(parsed.get("user"), dict) else {}
+ expires_at = str(parsed.get("expiresAt") or "")
+ self.token = token
+ self._username = username
+ self._password = password
+ self.expires_at = parse_expiry(expires_at)
+ path = self.auth_path or default_auth_path()
+ self.auth_path = path
+ write_properties(
+ path,
+ {
+ "access_token": token,
+ "id_token": token,
+ "auth_token": token,
+ "expires_at": expires_at,
+ "username": username,
+ "password": password,
+ },
+ )
+ return LoginResult(
+ username=str(user.get("username") or username),
+ role=str(user.get("role") or ""),
+ expires_at=expires_at,
+ )
+
+ def session(self) -> SessionResult:
+ if not self.token:
+ return SessionResult()
+ res = self.http.request(
+ "GET",
+ f"{self.base_url}/auth/validate",
+ payload={},
+ headers=self._headers(),
+ )
+ parsed = res.json() if hasattr(res, "json") else {}
+ if not isinstance(parsed, dict) or res.status_code >= 300 or not parsed.get("valid"):
+ return SessionResult()
+ return SessionResult(
+ valid=True,
+ username=str(parsed.get("username") or ""),
+ role=str(parsed.get("role") or ""),
+ expires_at=str(parsed.get("expiresAt") or ""),
+ )
+
+ def signup(self, username: str, password: str) -> CreateUserResult:
+ """Create a role=user account. Does not replace this process's JWT."""
+ if not username or not password:
+ raise ValueError("username and password are required")
+ kept = self.token
+ res = self.http.request(
+ "POST",
+ f"{self.base_url}/auth/signup",
+ payload={"username": username, "password": password},
+ headers={"Content-Type": "application/json"},
+ )
+ self.token = kept
+ parsed = res.json() if hasattr(res, "json") else {}
+ if not isinstance(parsed, dict):
+ parsed = {}
+ if res.status_code >= 300:
+ detail = str(parsed.get("error") or res.text or "").strip()
+ raise ValueError(f"signup HTTP {res.status_code}: {detail}")
+ return CreateUserResult(
+ id=str(parsed.get("id") or ""),
+ username=str(parsed.get("username") or username),
+ role=str(parsed.get("role") or "user"),
+ )
+
+ def create_user(self, username: str, password: str, role: str) -> CreateUserResult:
+ if not username or not password or not role:
+ raise ValueError("username, password, and role are required")
+ current = self.session()
+ if not current.valid or current.role != "admin":
+ raise ValueError("admin login required")
+ res = self.http.request(
+ "POST",
+ f"{self.base_url}/auth/users",
+ payload={"username": username, "password": password, "role": role},
+ headers=self._headers(),
+ )
+ parsed = res.json() if hasattr(res, "json") else {}
+ if not isinstance(parsed, dict):
+ parsed = {}
+ if res.status_code >= 300:
+ detail = str(parsed.get("error") or res.text or "").strip()
+ raise ValueError(f"create user HTTP {res.status_code}: {detail}")
+ return CreateUserResult(
+ id=str(parsed.get("id") or ""),
+ username=str(parsed.get("username") or username),
+ role=str(parsed.get("role") or role),
)
def _headers(self) -> dict[str, str]:
@@ -123,6 +340,7 @@ class GlobalClient:
return headers
def submit(self, digest: str, cube_id: str = "") -> Receipt:
+ self.ensure_token()
url = f"{self.base_url}/api/timestamp"
res = self.http.request(
"POST",
@@ -132,13 +350,18 @@ class GlobalClient:
"digest": digest,
"hashAlg": "SHA256",
"restamp": False,
- "wait": True,
},
headers=self._headers(),
)
parsed = res.json() if hasattr(res, "json") else {}
- if res.status_code == 200 and isinstance(parsed, dict):
- cid = str(parsed.get("certificate") or parsed.get("jobId") or "")
+ if not isinstance(parsed, dict):
+ parsed = {}
+ # 200 + certificate is the first CID for a digest already in the queue.
+ # jobId on that response is the original job, not the CID.
+ if res.status_code == 200 and (
+ parsed.get("certificate") or parsed.get("existing")
+ ):
+ cid = str(parsed.get("certificate") or "")
if cid:
return Receipt(
receipt_id=cid,
@@ -189,6 +412,7 @@ class GlobalClient:
)
def verify_certificate(self, receipt_id: str) -> VerifyResult:
+ self.ensure_token()
url = f"{self.base_url}/api/verify"
res = self.http.request(
"POST",
diff --git a/src/verae_mcp_timestamping_server/server.py b/src/verae_mcp_timestamping_server/server.py
index 16d078b..00afa25 100644
--- a/src/verae_mcp_timestamping_server/server.py
+++ b/src/verae_mcp_timestamping_server/server.py
@@ -2,12 +2,19 @@
from __future__ import annotations
+import asyncio
import os
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
-from verae_mcp_timestamping_server.global_client import GlobalClient, GlobalLedger
+from verae_mcp_timestamping_server.global_client import (
+ CreateUserResult,
+ GlobalClient,
+ GlobalLedger,
+ LoginResult,
+ SessionResult,
+)
from verae_mcp_timestamping_server.guard import reject_content_keys, strict_arg_model
from verae_mcp_timestamping_server.ledger import (
FileLedger,
@@ -20,15 +27,25 @@ from verae_mcp_timestamping_server.ledger import (
mcp = MCPServer("verae-timestamping")
_ledger: Ledger = MemoryLedger()
+_global_client: GlobalClient = GlobalClient()
-def default_ledger() -> Ledger:
+def default_ledger(client: GlobalClient | None = None) -> Ledger:
+ held = client or GlobalClient.from_env()
return RoutingLedger(
local=FileLedger(),
- global_ledger=GlobalLedger(GlobalClient.from_env()),
+ global_ledger=GlobalLedger(held),
)
+def _client_of(ledger: Ledger) -> GlobalClient | None:
+ inner = getattr(ledger, "global_ledger", None)
+ client = getattr(inner, "client", None)
+ if isinstance(client, GlobalClient):
+ return client
+ return None
+
+
_HARDENED_TOOLS: set[int] = set()
@@ -57,9 +74,18 @@ def _harden_hash_only_tools(server: MCPServer) -> None:
_HARDENED_TOOLS.add(tid)
-def build_mcp(ledger: Ledger | None = None) -> MCPServer:
- global _ledger
- _ledger = ledger or default_ledger()
+def build_mcp(
+ ledger: Ledger | None = None,
+ global_client: GlobalClient | None = None,
+) -> MCPServer:
+ global _ledger, _global_client
+ if ledger is None:
+ client = global_client or GlobalClient.from_env()
+ _ledger = default_ledger(client)
+ _global_client = client
+ else:
+ _ledger = ledger
+ _global_client = global_client or _client_of(ledger) or GlobalClient()
_harden_hash_only_tools(mcp)
return mcp
@@ -72,7 +98,7 @@ def _tool_error(exc: ValueError) -> ToolError:
async def stamp(
sha256: str, scope: str = "local", cube_id: str = "", org_id: str = ""
) -> Receipts:
- """Commit a SHA-256. scope=local is the leaf file ledger; scope=global is api.veraetime.net."""
+ """Commit a SHA-256. scope=local is the leaf file ledger; scope=global posts digest and restamp=false to api.veraetime.net and reuses the first CID."""
try:
receipts = await _ledger.stamp(
sha256, scope=scope, cube_id=cube_id, org_id=org_id
@@ -88,6 +114,41 @@ async def verify(receipt_id: str, want_scope: str = "") -> VerifyResult:
return await _ledger.verify(receipt_id, want_scope=want_scope)
+@mcp.tool()
+async def login(username: str, password: str) -> LoginResult:
+ """Log in to api.veraetime.net. Stores the JWT on this host and does not return it."""
+ try:
+ return await asyncio.to_thread(_global_client.login, username, password)
+ except ValueError as exc:
+ raise _tool_error(exc) from exc
+
+
+@mcp.tool()
+async def session() -> SessionResult:
+ """Validate the stored VeraeTime JWT. Returns valid, username, role, and expires_at."""
+ return await asyncio.to_thread(_global_client.session)
+
+
+@mcp.tool()
+async def signup(username: str, password: str) -> CreateUserResult:
+ """Create a VeraeTime user account. Role is user. Does not replace the stored JWT."""
+ try:
+ return await asyncio.to_thread(_global_client.signup, username, password)
+ except ValueError as exc:
+ raise _tool_error(exc) from exc
+
+
+@mcp.tool()
+async def create_user(username: str, password: str, role: str) -> CreateUserResult:
+ """Create a VeraeTime user. The stored JWT must belong to an admin."""
+ try:
+ return await asyncio.to_thread(
+ _global_client.create_user, username, password, role
+ )
+ except ValueError as exc:
+ raise _tool_error(exc) from exc
+
+
@mcp.tool()
async def hash_report(
sha256: str, org_id: str = "", want_scope: str = ""
@@ -103,7 +164,8 @@ async def hash_report(
def main() -> None:
- build_mcp(default_ledger())
+ client = GlobalClient.from_env()
+ build_mcp(default_ledger(client), global_client=client)
transport = os.environ.get("VERAE_MCP_TRANSPORT", "stdio")
if transport == "streamable-http":
host = os.environ.get("VERAE_MCP_HOST", "127.0.0.1")
@@ -115,7 +177,7 @@ def main() -> None:
h.strip()
for h in os.environ.get(
"VERAE_MCP_ALLOWED_HOSTS",
- "mcp.georgelambert.org,localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,10.1.10.128,10.1.10.128:8000",
+ "mcp.veraetime.net,mcp.georgelambert.org,localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,10.1.10.128,10.1.10.128:8000",
).split(",")
if h.strip()
]
diff --git a/tests/test_global_client.py b/tests/test_global_client.py
index 0a0f74e..2502755 100644
--- a/tests/test_global_client.py
+++ b/tests/test_global_client.py
@@ -64,7 +64,8 @@ async def test_global_stamp_posts_hash_sync_and_maps_receipt():
assert payload["digest"] == SHA256_EMPTY
assert payload["hashAlg"] == "SHA256"
assert payload["restamp"] is False
- assert payload["wait"] is True
+ assert "wait" not in payload
+ assert "digest" in payload
assert headers["Authorization"] == "Bearer secret"
assert rec.scope == "global"
assert rec.receipt_id == "job-1"
@@ -72,6 +73,49 @@ async def test_global_stamp_posts_hash_sync_and_maps_receipt():
assert rec.cube_id == "cube-1"
+async def test_existing_digest_returns_first_certificate_without_status_poll():
+ class ExistingHttp(FakeHttp):
+ def request(self, method, url, payload, headers):
+ self.calls.append((method, url, payload, headers))
+ if url.endswith("/api/timestamp"):
+ return _Resp(
+ 200,
+ {
+ "jobId": "28df9952-094b-45a9-b6e5-e3ff81389bdd",
+ "existing": True,
+ "certificate": "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA",
+ },
+ )
+ raise AssertionError(f"unexpected {method} {url}")
+
+ http = ExistingHttp()
+ client = GlobalClient("https://api.veraetime.net", http=http)
+ rec = client.submit(SHA256_EMPTY)
+ assert rec.receipt_id == "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA"
+ assert http.calls[0][2]["digest"] == SHA256_EMPTY
+ assert http.calls[0][2]["restamp"] is False
+ assert len(http.calls) == 1
+
+
+async def test_existing_without_certificate_polls_job():
+ class PendingExisting(FakeHttp):
+ def request(self, method, url, payload, headers):
+ self.calls.append((method, url, payload, headers))
+ if url.endswith("/api/timestamp"):
+ return _Resp(200, {"jobId": "job-9", "existing": True})
+ if url.endswith("/api/status/job-9"):
+ return _Resp(
+ 200,
+ {"id": "job-9", "status": "completed", "result": "QmPending"},
+ )
+ return _Resp(404, {"error": "nope"})
+
+ http = PendingExisting()
+ rec = GlobalClient("https://api.veraetime.net", http=http).submit(SHA256_EMPTY)
+ assert rec.receipt_id == "QmPending"
+ assert http.calls[1][0] == "GET"
+
+
async def test_global_verify_posts_certificate():
http = FakeHttp()
ledger = GlobalLedger(GlobalClient("https://api.veraetime.net", http=http))
@@ -106,3 +150,159 @@ async def test_routing_sends_only_global_scope_to_central():
weak = await routed.verify(local_rec.receipt_id, want_scope="global")
assert weak.valid is False
+
+
+def _auth_http(role: str = "admin"):
+ class AuthHttp(FakeHttp):
+ def request(self, method, url, payload, headers):
+ self.calls.append((method, url, payload, headers))
+ if url.endswith("/auth/login"):
+ return _Resp(
+ 200,
+ {
+ "token": "fresh-token",
+ "expiresAt": "2099-01-01T00:00:00Z",
+ "user": {"id": "u1", "username": "admin", "role": "admin"},
+ },
+ )
+ if url.endswith("/auth/validate"):
+ if headers.get("Authorization") != "Bearer fresh-token":
+ return _Resp(401, {"error": "Unauthorized"})
+ return _Resp(
+ 200,
+ {
+ "valid": True,
+ "userId": "u1",
+ "username": "admin",
+ "role": role,
+ "expiresAt": "2099-01-01T00:00:00Z",
+ },
+ )
+ if url.endswith("/auth/signup"):
+ assert "Authorization" not in headers
+ return _Resp(
+ 201,
+ {"id": "u3", "username": payload["username"], "role": "user"},
+ )
+ if url.endswith("/auth/users"):
+ return _Resp(
+ 201,
+ {"id": "u2", "username": payload["username"], "role": payload["role"]},
+ )
+ if url.endswith("/api/timestamp"):
+ return _Resp(
+ 200,
+ {
+ "jobId": "job-1",
+ "existing": True,
+ "certificate": "QmFresh",
+ },
+ )
+ return _Resp(404, {"error": "nope"})
+
+ return AuthHttp()
+
+
+async def test_login_stores_token_and_hides_it_from_result(tmp_path):
+ http = _auth_http()
+ path = tmp_path / "auth.properties"
+ client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path)
+ result = client.login("admin", "secret-pass")
+ assert result.username == "admin"
+ assert result.role == "admin"
+ assert result.expires_at == "2099-01-01T00:00:00Z"
+ assert "fresh-token" not in result.model_dump().values()
+ stored = path.read_text()
+ assert "access_token=fresh-token" in stored
+ assert "username=admin" in stored
+ assert "password=secret-pass" in stored
+ assert path.stat().st_mode & 0o777 == 0o600
+ assert client.token == "fresh-token"
+ assert http.calls[0][2] == {"username": "admin", "password": "secret-pass"}
+
+
+async def test_session_and_create_user_require_admin(tmp_path):
+ http = _auth_http(role="user")
+ client = GlobalClient(
+ "https://api.veraetime.net",
+ token="fresh-token",
+ http=http,
+ auth_path=tmp_path / "auth.properties",
+ )
+ seen = client.session()
+ assert seen.valid is True
+ assert seen.role == "user"
+ with pytest.raises(ValueError, match="admin login required"):
+ client.create_user("newuser", "secret-pass", "user")
+ assert not any(url.endswith("/auth/users") for _, url, _, _ in http.calls)
+
+
+async def test_create_user_posts_when_admin(tmp_path):
+ http = _auth_http(role="admin")
+ client = GlobalClient(
+ "https://api.veraetime.net",
+ token="fresh-token",
+ http=http,
+ auth_path=tmp_path / "auth.properties",
+ )
+ created = client.create_user("newuser", "secret-pass", "user")
+ assert created.id == "u2"
+ assert created.username == "newuser"
+ assert created.role == "user"
+ assert "secret-pass" not in created.model_dump().values()
+ method, url, payload, headers = http.calls[-1]
+ assert method == "POST"
+ assert url.endswith("/auth/users")
+ assert payload["password"] == "secret-pass"
+ assert headers["Authorization"] == "Bearer fresh-token"
+
+
+async def test_signup_creates_user_without_replacing_token(tmp_path):
+ http = _auth_http()
+ client = GlobalClient(
+ "https://api.veraetime.net",
+ token="service-token",
+ http=http,
+ auth_path=tmp_path / "auth.properties",
+ )
+ created = client.signup("newcomer", "secret-pass")
+ assert created == type(created)(id="u3", username="newcomer", role="user")
+ assert client.token == "service-token"
+ method, url, payload, headers = http.calls[0]
+ assert method == "POST"
+ assert url.endswith("/auth/signup")
+ assert payload == {"username": "newcomer", "password": "secret-pass"}
+ assert "Authorization" not in headers
+ assert "role" not in payload
+
+
+async def test_submit_refreshes_expired_stored_login(tmp_path):
+ path = tmp_path / "auth.properties"
+ path.write_text(
+ "access_token=old-token\n"
+ "expires_at=2020-01-01T00:00:00Z\n"
+ "username=admin\n"
+ "password=secret-pass\n"
+ )
+ http = _auth_http()
+ client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path)
+ rec = client.submit(SHA256_EMPTY)
+ assert rec.receipt_id == "QmFresh"
+ assert http.calls[0][1].endswith("/auth/login")
+ assert http.calls[1][3]["Authorization"] == "Bearer fresh-token"
+
+
+async def test_submit_skips_login_when_token_is_fresh(tmp_path):
+ path = tmp_path / "auth.properties"
+ path.write_text(
+ "access_token=still-good\n"
+ "expires_at=2099-01-01T00:00:00Z\n"
+ "username=admin\n"
+ "password=secret-pass\n"
+ )
+ http = _auth_http()
+ client = GlobalClient("https://api.veraetime.net", http=http, auth_path=path)
+ rec = client.submit(SHA256_EMPTY)
+ assert rec.receipt_id == "QmFresh"
+ assert http.calls[0][1].endswith("/api/timestamp")
+ assert http.calls[0][3]["Authorization"] == "Bearer still-good"
diff --git a/tests/test_server.py b/tests/test_server.py
index 09e788e..4c8e8ed 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -5,6 +5,7 @@ import base64
import pytest
from mcp import Client
+from verae_mcp_timestamping_server.global_client import GlobalClient
from verae_mcp_timestamping_server.guard import FORBIDDEN_CONTENT_KEYS
from verae_mcp_timestamping_server.ledger import SHA256_EMPTY, MemoryLedger
from verae_mcp_timestamping_server.server import build_mcp
@@ -123,3 +124,60 @@ async def test_verify_and_hash_report_tools():
report = await client.call_tool("hash_report", {"sha256": SHA256_EMPTY})
items = report.structured_content["receipts"]
assert items[0]["receipt_id"] == receipt_id
+
+
+class _ToolHttp:
+ def __init__(self):
+ self.calls = []
+
+ def request(self, method, url, payload, headers):
+ self.calls.append((method, url, payload, headers))
+
+ class Resp:
+ status_code = 200
+ text = ""
+ headers = {"Content-Type": "application/json"}
+
+ def json(self):
+ if url.endswith("/auth/login"):
+ return {
+ "token": "tool-token",
+ "expiresAt": "2099-01-01T00:00:00Z",
+ "user": {"id": "u1", "username": "admin", "role": "admin"},
+ }
+ if url.endswith("/auth/validate"):
+ return {
+ "valid": True,
+ "username": "admin",
+ "role": "admin",
+ "expiresAt": "2099-01-01T00:00:00Z",
+ }
+ return {}
+
+ return Resp()
+
+
+async def test_login_tool_does_not_return_token(tmp_path):
+ http = _ToolHttp()
+ held = GlobalClient(
+ "https://api.veraetime.net",
+ http=http,
+ auth_path=tmp_path / "auth.properties",
+ )
+ mcp = build_mcp(MemoryLedger(), global_client=held)
+ async with Client(mcp, raise_exceptions=True) as client:
+ listed = await client.list_tools()
+ login = next(tool for tool in listed.tools if tool.name == "login")
+ assert login.input_schema.get("additionalProperties") is False
+ assert "password" in login.input_schema["properties"]
+ result = await client.call_tool(
+ "login", {"username": "admin", "password": "secret-pass"}
+ )
+ data = result.structured_content
+ assert data["username"] == "admin"
+ assert data["role"] == "admin"
+ assert "token" not in data
+ assert "tool-token" not in str(data)
+ session = await client.call_tool("session", {})
+ assert session.structured_content["valid"] is True
+ assert session.structured_content["role"] == "admin"