diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3f90d56 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ +# net-kingdom — Codex Instructions + +## Custodian State Hub Integration + +This project is tracked as the **netkingdom** domain in the Custodian State Hub. +Hub topic ID: `a6c6e745-bf54-4465-9340-1534a2be493e` + +The State Hub runs locally at http://127.0.0.1:8000. The MCP server (`state-hub`) +exposes tools for reading and writing state without touching the API directly. + +### Session Protocol + +**On receiving your first message — before writing any response text — orient +yourself immediately.** Do not greet, do not ask what to do. + +**At the start of every session:** +1. Read `.custodian-brief.md` — offline-safe orientation that works without MCP. +2. Call `get_state_summary()` for richer cross-domain context (skip if unreachable). + If it fails, the API is likely offline: + ``` + cd ~/the-custodian/state-hub && make api + ``` +2. Call `get_next_steps()` — surfaces contextual suggestions from recently resolved + decisions and cleared workstream dependencies. Act on these before starting new work. +3. Check whether this domain has any open workstreams in the summary. + - **If workstreams exist:** review blocking decisions before starting work. + - **If no workstreams exist:** follow the First Session Protocol below. + +**During work:** +- Use `record_decision()` for any decision that affects direction or dependencies. +- Use `add_progress_event()` for notable events (milestones, blockers, insights). +- Use `resolve_decision()` to close a decision once the choice is made — this is one + of the two sanctioned write operations in the hub. + +> **Design boundary:** The State Hub is a *read model*. Two write operations are +> permanently sanctioned: **Resolving Decisions** and **Suggesting Next Steps** (v0.2). +> The bootstrap tools (`create_workstream`, `create_task`, `update_task_status`) are +> only for First Session Protocol. Formal work structure — requirements, workplans, +> milestones, tasks — belongs in the domain repo, not managed through the hub. + +**At the end of every session:** +- Call `add_progress_event()` with a summary of what was accomplished or decided. + Include `topic_id: a6c6e745-bf54-4465-9340-1534a2be493e` and the relevant `workstream_id`. + +### First Session Protocol + +Triggered when `get_state_summary()` shows **no workstreams** for the `netkingdom` topic. +This means the project is registered but work has not yet been structured. + +**Step 1 — Understand the project (read, don't write)** +- `canon/projects/netkingdom/project_charter_v0.1.md` — purpose, scope, success criteria +- `canon/projects/netkingdom/roadmap_v0.1.md` — planned phases +- Scan the repo root: README, directory structure, any existing code or docs + +**Step 2 — Survey in-progress work** +- Look for TODOs, open branches, half-finished files, or notes +- Note what is already done vs. what is clearly started but incomplete + +**Step 3 — Propose workstreams to Bernd** +Based on what you found, propose 1–3 workstreams. Each workstream should be: +- A coherent strand of work lasting weeks to months (not a single task) +- Named clearly enough that its scope is obvious +- Anchored to a phase in the roadmap if possible + +Present the proposals and **wait for approval before creating anything**. + +**Step 4 — Create and populate (after approval)** +``` +create_workstream(topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", title="...", owner="...", description="...") +create_task(workstream_id="", title="...", priority="high|medium|low") +# repeat for each task in the workstream +``` +Aim for 3–7 tasks per workstream at this stage. Tasks should be concrete and actionable. + +**Step 5 — Record the setup** +``` +add_progress_event( + summary="First session: structured netkingdom work into N workstreams, M tasks", + event_type="milestone", + topic_id="a6c6e745-bf54-4465-9340-1534a2be493e", + detail={"workstreams": [...], "tasks_created": M} +) +``` + +### Quick Reference + +See `~/the-custodian/state-hub/mcp_server/TOOLS.md` for a compact tool reference. diff --git a/docs/LocalIdentity.md b/docs/LocalIdentity.md index 69f3b26..e89ebd1 100644 --- a/docs/LocalIdentity.md +++ b/docs/LocalIdentity.md @@ -111,6 +111,7 @@ local-identity init # derive primary user, generate test us local-identity list # list all users in the store local-identity show # display user file local-identity export # emit Keycloak-compatible JSON +local-identity bootstrap-oidc # print local OIDC client settings local-identity serve [--port P] [--ttl T] # start minimal OIDC server local-identity security-check # validate filesystem permissions local-identity revoke-token # add a token JTI to the revocation list @@ -130,6 +131,33 @@ server starts on localhost. It supports: This allows dev/test applications to use standard OIDC libraries against Local Identity without any Keycloak dependency. +To bootstrap a local app against the provider, initialise the store and emit +client settings: + +```bash +local-identity init --email bernd@example.com +local-identity bootstrap-oidc \ + --client-id local-dev \ + --redirect-uri http://127.0.0.1:3000/callback +local-identity serve +``` + +`bootstrap-oidc` persists the client settings under `oidc_clients` in +`~/.local-identity/config.yaml` and prints environment variables: + +```bash +OIDC_ISSUER=https://127.0.0.1:8443 +OIDC_DISCOVERY_URL=https://127.0.0.1:8443/.well-known/openid-configuration +OIDC_CLIENT_ID=local-dev +OIDC_REDIRECT_URI=http://127.0.0.1:3000/callback +OIDC_SCOPE='openid profile email' +OIDC_TOKEN_ENDPOINT_AUTH_METHOD=none +``` + +Redirect URIs must be loopback URLs (`127.0.0.1`, `localhost`, or `::1`). +The server intentionally trusts local clients and does not require a client +secret. + **Security note:** the OIDC server binds to `127.0.0.1` only. Never expose it on a public interface. diff --git a/local-identity/src/local_identity/cli.py b/local-identity/src/local_identity/cli.py index c4ed25e..dd8e544 100644 --- a/local-identity/src/local_identity/cli.py +++ b/local-identity/src/local_identity/cli.py @@ -11,6 +11,7 @@ Commands: export [] Export a single user as Keycloak JSON. export --all [--realm R] Bulk partial-import body (primary users only). Add --include-test to include generated users. + bootstrap-oidc Persist and print local OIDC client settings. serve [--port P] [--ttl T] Start the minimal OIDC server on 127.0.0.1. security-check Validate filesystem permissions. revoke-token Add a token JTI to the revocation list. @@ -21,7 +22,9 @@ Environment: import argparse import json +import shlex import sys +import urllib.parse from .gecos import current_username, get_gecos_fullname from .jwt_utils import JWTError, extract_unverified_payload @@ -35,6 +38,7 @@ from .security import enforce_permissions, print_security_check # Commands that must not run the startup permission check _SKIP_ENFORCE = {"init", "security-check"} +_LOCAL_OIDC_HOSTS = {"127.0.0.1", "localhost", "::1"} def _resolve_init_params(args: argparse.Namespace, config: dict) -> tuple[str, str, str]: @@ -152,6 +156,64 @@ def cmd_serve(args: argparse.Namespace) -> None: serve_mod.run_server(port=args.port, token_ttl=args.ttl) +def _validate_loopback_redirect_uri(uri: str) -> None: + parsed = urllib.parse.urlparse(uri) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("redirect URI must be an absolute http(s) URL") + if parsed.hostname not in _LOCAL_OIDC_HOSTS: + raise ValueError("redirect URI must use localhost or a loopback address") + + +def _oidc_bootstrap_payload(args: argparse.Namespace) -> dict: + _validate_loopback_redirect_uri(args.redirect_uri) + issuer = f"{args.scheme}://127.0.0.1:{args.port}" + return { + "issuer": issuer, + "discovery_url": f"{issuer}/.well-known/openid-configuration", + "client_id": args.client_id, + "redirect_uri": args.redirect_uri, + "scope": args.scope, + "token_endpoint_auth_method": "none", + } + + +def cmd_bootstrap_oidc(args: argparse.Namespace) -> None: + if not store.store_exists(): + print( + "Error: store not initialised. Run 'local-identity init' first.", + file=sys.stderr, + ) + sys.exit(1) + + try: + payload = _oidc_bootstrap_payload(args) + except ValueError as exc: + print(f"Error: {exc}.", file=sys.stderr) + sys.exit(1) + + config = store.read_config() + clients = config.setdefault("oidc_clients", {}) + clients[args.client_id] = payload + config["last_oidc_bootstrap"] = args.client_id + store.write_config(config) + + audit.log_event("bootstrap-oidc", args.client_id, "ok") + + if args.output == "json": + print(json.dumps(payload, indent=2)) + return + + for key, value in { + "OIDC_ISSUER": payload["issuer"], + "OIDC_DISCOVERY_URL": payload["discovery_url"], + "OIDC_CLIENT_ID": payload["client_id"], + "OIDC_REDIRECT_URI": payload["redirect_uri"], + "OIDC_SCOPE": payload["scope"], + "OIDC_TOKEN_ENDPOINT_AUTH_METHOD": payload["token_endpoint_auth_method"], + }.items(): + print(f"{key}={shlex.quote(value)}") + + def cmd_security_check(args: argparse.Namespace) -> None: rc = print_security_check() sys.exit(rc) @@ -256,6 +318,43 @@ def main() -> None: ) p_serve.set_defaults(func=cmd_serve) + p_bootstrap_oidc = sub.add_parser( + "bootstrap-oidc", + help="Persist and print localhost OIDC client bootstrap settings", + ) + p_bootstrap_oidc.add_argument( + "--client-id", + default="local-dev", + help="OIDC client ID to advertise (default: local-dev)", + ) + p_bootstrap_oidc.add_argument( + "--redirect-uri", + default="http://127.0.0.1:3000/callback", + help="Loopback redirect URI for the local client", + ) + p_bootstrap_oidc.add_argument( + "--port", type=int, default=8443, + help="Port used by local-identity serve (default: 8443)", + ) + p_bootstrap_oidc.add_argument( + "--scheme", + choices=["https", "http"], + default="https", + help="Issuer URL scheme (default: https)", + ) + p_bootstrap_oidc.add_argument( + "--scope", + default="openid profile email", + help="OIDC scope string (default: openid profile email)", + ) + p_bootstrap_oidc.add_argument( + "--output", + choices=["env", "json"], + default="env", + help="Output format (default: env)", + ) + p_bootstrap_oidc.set_defaults(func=cmd_bootstrap_oidc) + sub.add_parser( "security-check", help="Validate filesystem permissions of the store", diff --git a/local-identity/tests/test_cli.py b/local-identity/tests/test_cli.py index 1c3f885..50a763e 100644 --- a/local-identity/tests/test_cli.py +++ b/local-identity/tests/test_cli.py @@ -10,7 +10,12 @@ from unittest.mock import patch import pytest -from local_identity.cli import _resolve_init_params, cmd_init +from local_identity.cli import ( + _oidc_bootstrap_payload, + _resolve_init_params, + cmd_bootstrap_oidc, + cmd_init, +) from local_identity.store import init_dirs, list_users, read_config, read_user @@ -25,6 +30,25 @@ def _args(username=None, fullname=None, email=None, force=False): return ns +def _oidc_args( + client_id="local-dev", + redirect_uri="http://127.0.0.1:3000/callback", + port=8443, + scheme="https", + scope="openid profile email", + output="env", +): + ns = argparse.Namespace() + ns.client_id = client_id + ns.redirect_uri = redirect_uri + ns.port = port + ns.scheme = scheme + ns.scope = scope + ns.output = output + ns.func = cmd_bootstrap_oidc + return ns + + # ------------------------------------------------------------------ # # _resolve_init_params # # ------------------------------------------------------------------ # @@ -149,3 +173,64 @@ class TestCmdInit: cmd_init(_args(email="a@b.com")) # second call should fail assert exc_info.value.code == 1 + + +# ------------------------------------------------------------------ # +# cmd_bootstrap_oidc # +# ------------------------------------------------------------------ # + +class TestCmdBootstrapOidc: + def test_payload_uses_local_issuer_and_client_settings(self): + payload = _oidc_bootstrap_payload( + _oidc_args( + client_id="example-app", + redirect_uri="http://localhost:8080/oidc/callback", + port=9443, + ) + ) + + assert payload == { + "issuer": "https://127.0.0.1:9443", + "discovery_url": "https://127.0.0.1:9443/.well-known/openid-configuration", + "client_id": "example-app", + "redirect_uri": "http://localhost:8080/oidc/callback", + "scope": "openid profile email", + "token_endpoint_auth_method": "none", + } + + def test_rejects_non_loopback_redirect_uri(self): + with pytest.raises(ValueError, match="loopback"): + _oidc_bootstrap_payload( + _oidc_args(redirect_uri="https://example.com/callback") + ) + + def test_persists_client_bootstrap_config(self, tmp_store, capsys): + with patch("local_identity.cli.current_username", return_value="worsch"): + cmd_init(_args(username="alice", fullname="Alice Smith", email="alice@example.com")) + + cmd_bootstrap_oidc( + _oidc_args( + client_id="demo", + redirect_uri="http://127.0.0.1:5173/auth/callback", + port=9443, + ) + ) + + cfg = read_config() + assert cfg["last_oidc_bootstrap"] == "demo" + assert cfg["oidc_clients"]["demo"]["issuer"] == "https://127.0.0.1:9443" + assert cfg["oidc_clients"]["demo"]["redirect_uri"] == "http://127.0.0.1:5173/auth/callback" + + out = capsys.readouterr().out + assert "OIDC_ISSUER=https://127.0.0.1:9443" in out + assert "OIDC_TOKEN_ENDPOINT_AUTH_METHOD=none" in out + + def test_json_output(self, tmp_store, capsys): + with patch("local_identity.cli.current_username", return_value="worsch"): + cmd_init(_args(username="alice", fullname="Alice Smith", email="alice@example.com")) + + cmd_bootstrap_oidc(_oidc_args(client_id="json-app", output="json")) + + data = capsys.readouterr().out + assert '"client_id": "json-app"' in data + assert '"token_endpoint_auth_method": "none"' in data