Skip to content

Commit ccc6e73

Browse files
authored
Merge pull request #38 from ModulrCloud/dev
feat(keymaster): loopback UI shell with Core-themed fireflies
2 parents b267543 + 2106b7a commit ccc6e73

16 files changed

Lines changed: 1255 additions & 2 deletions

keymaster/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Keymaster (local)
2+
3+
Local **Ed25519** identity tool: generate **named** key profiles, **password-protect** the vault, browse via a **loopback web UI**.
4+
5+
**Full plan:** [`plan/keymaster_local_wallet.md`](../plan/keymaster_local_wallet.md)
6+
7+
**Design context:** [`docs/identity_encryption_and_org_policy.md`](../docs/identity_encryption_and_org_policy.md)
8+
9+
## Status
10+
11+
The **web UI shell** is implemented (FastAPI + Jinja + static CSS aligned with the Modulr customer shell). Background matches the Core shell **fireflies** preset (canvas + gradient). With **`prefers-reduced-motion: reduce`**, only the static gradient runs. Vault encryption, key generation, and persistence are **not** wired yet — you will see an “UI preview” banner.
12+
13+
## Run (development)
14+
15+
From the **repository root**, with your Modulr.Core venv activated:
16+
17+
```powershell
18+
pip install -e ./keymaster
19+
modulr-keymaster --reload
20+
```
21+
22+
Or from `keymaster/`:
23+
24+
```powershell
25+
pip install -e .
26+
modulr-keymaster --reload
27+
```
28+
29+
Defaults: **127.0.0.1:8765**. Open [http://127.0.0.1:8765](http://127.0.0.1:8765) (redirects to `/unlock`).
30+
31+
- **`--port`** — listen port
32+
- **`--host`** — bind address (avoid `0.0.0.0`; this tool is meant for loopback only)
33+
- **`--reload`** — auto-reload on code changes
34+
35+
## Layout
36+
37+
| Path | Screen |
38+
|------|--------|
39+
| `/unlock` | Unlock vault |
40+
| `/setup` | First-run / create vault |
41+
| `/identities` | Dashboard (mock profiles) |
42+
| `/identities/{id}` | Profile detail + copy public key |
43+
44+
Static theme files live under `src/modulr_keymaster/static/`; templates under `templates/`.

keymaster/pyproject.toml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[build-system]
2+
requires = ["hatchling>=1.24"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "modulr-keymaster"
7+
version = "0.1.0"
8+
description = "Keymaster — local Ed25519 identity vault (loopback UI)"
9+
readme = "README.md"
10+
requires-python = ">=3.11"
11+
license = { text = "BSL-1.1" }
12+
authors = [{ name = "Modulr" }]
13+
dependencies = [
14+
"fastapi>=0.115.0",
15+
"jinja2>=3.1.0",
16+
"uvicorn[standard]>=0.30.0",
17+
]
18+
19+
[project.scripts]
20+
modulr-keymaster = "modulr_keymaster.cli:main"
21+
22+
[tool.hatch.build.targets.wheel]
23+
packages = ["src/modulr_keymaster"]
24+
25+
[tool.hatch.build.targets.wheel.force-include]
26+
"src/modulr_keymaster/static" = "modulr_keymaster/static"
27+
"src/modulr_keymaster/templates" = "modulr_keymaster/templates"
28+
29+
[tool.ruff]
30+
target-version = "py311"
31+
line-length = 88
32+
src = ["src"]
33+
34+
[tool.ruff.lint]
35+
select = ["E", "F", "I", "UP"]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Local Keymaster loopback UI and (future) encrypted vault."""
2+
3+
__version__ = "0.1.0"
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""FastAPI app: themed static UI for Keymaster (mock data until vault exists)."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from fastapi import FastAPI, Request
8+
from fastapi.responses import HTMLResponse, RedirectResponse
9+
from fastapi.staticfiles import StaticFiles
10+
from fastapi.templating import Jinja2Templates
11+
12+
_PKG_DIR = Path(__file__).resolve().parent
13+
14+
MOCK_PROFILES: list[dict[str, str]] = [
15+
{
16+
"id": "personal",
17+
"display_name": "Personal",
18+
"public_key_hex": (
19+
"3f8a2c1e9b0d4f7a6e5c8b1d2f0a3948"
20+
"7e6d5c4b3a291807f6e5d4c3b2a1908f"
21+
),
22+
"created_at": "2026-03-28T14:22:00Z",
23+
},
24+
{
25+
"id": "organization",
26+
"display_name": "Organization",
27+
"public_key_hex": (
28+
"a1b2c3d4e5f60718293a4b5c6d7e8f90"
29+
"0f1e2d3c4b5a69788796a5b4c3d2e1f0"
30+
),
31+
"created_at": "2026-03-29T09:15:00Z",
32+
},
33+
]
34+
35+
36+
def _profile_by_id(profile_id: str) -> dict[str, str] | None:
37+
for p in MOCK_PROFILES:
38+
if p["id"] == profile_id:
39+
return p
40+
return None
41+
42+
43+
def create_app() -> FastAPI:
44+
app = FastAPI(title="Keymaster", version="0.1.0", docs_url=None, redoc_url=None)
45+
templates = Jinja2Templates(directory=str(_PKG_DIR / "templates"))
46+
app.mount(
47+
"/static",
48+
StaticFiles(directory=str(_PKG_DIR / "static")),
49+
name="static",
50+
)
51+
52+
def ctx(request: Request, **extra: object) -> dict[str, object]:
53+
out: dict[str, object] = {
54+
"request": request,
55+
"ui_preview": True,
56+
"nav_section": "none",
57+
}
58+
out.update(extra)
59+
return out
60+
61+
@app.get("/", response_class=RedirectResponse)
62+
async def root() -> RedirectResponse:
63+
return RedirectResponse(url="/unlock", status_code=302)
64+
65+
@app.get("/unlock", response_class=HTMLResponse)
66+
async def unlock(request: Request) -> HTMLResponse:
67+
return templates.TemplateResponse(
68+
request,
69+
"unlock.html",
70+
ctx(request, page_title="Unlock vault", nav_section="unlock"),
71+
)
72+
73+
@app.get("/setup", response_class=HTMLResponse)
74+
async def setup(request: Request) -> HTMLResponse:
75+
return templates.TemplateResponse(
76+
request,
77+
"setup.html",
78+
ctx(request, page_title="Create vault", nav_section="setup"),
79+
)
80+
81+
@app.get("/identities", response_class=HTMLResponse)
82+
async def identities(request: Request) -> HTMLResponse:
83+
return templates.TemplateResponse(
84+
request,
85+
"dashboard.html",
86+
ctx(
87+
request,
88+
page_title="Identities",
89+
profiles=MOCK_PROFILES,
90+
nav_section="identities",
91+
),
92+
)
93+
94+
@app.get("/identities/{profile_id}", response_class=HTMLResponse)
95+
async def identity_detail(request: Request, profile_id: str) -> HTMLResponse:
96+
profile = _profile_by_id(profile_id)
97+
if profile is None:
98+
return templates.TemplateResponse(
99+
request,
100+
"not_found.html",
101+
ctx(request, page_title="Not found", nav_section="none"),
102+
status_code=404,
103+
)
104+
return templates.TemplateResponse(
105+
request,
106+
"profile_detail.html",
107+
ctx(
108+
request,
109+
page_title=profile["display_name"],
110+
profile=profile,
111+
nav_section="detail",
112+
),
113+
)
114+
115+
return app
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""CLI: run Keymaster loopback server."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import sys
7+
8+
import uvicorn
9+
10+
from modulr_keymaster.app import create_app
11+
12+
13+
def main() -> None:
14+
parser = argparse.ArgumentParser(description="Keymaster local web UI")
15+
parser.add_argument(
16+
"--host",
17+
default="127.0.0.1",
18+
help="Bind address (default: 127.0.0.1)",
19+
)
20+
parser.add_argument(
21+
"--port",
22+
type=int,
23+
default=8765,
24+
help="Port (default: 8765)",
25+
)
26+
parser.add_argument(
27+
"--reload",
28+
action="store_true",
29+
help="Dev auto-reload (watch package files)",
30+
)
31+
args = parser.parse_args()
32+
33+
if args.host in ("0.0.0.0", "::"):
34+
print(
35+
"Keymaster: binding to all interfaces is discouraged; "
36+
"this tool is intended for loopback only.",
37+
file=sys.stderr,
38+
)
39+
40+
uvicorn.run(
41+
"modulr_keymaster.app:create_app",
42+
factory=True,
43+
host=args.host,
44+
port=args.port,
45+
reload=args.reload,
46+
)
47+
48+
49+
if __name__ == "__main__":
50+
main()

0 commit comments

Comments
 (0)