-
Notifications
You must be signed in to change notification settings - Fork 0
feat(web): operator-facing webUX MVP (Starlette+HTMX over PlainweaveService) #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1120cd9
7f80c70
eaf5410
519b7a3
f7ea83c
6f4c41d
3facd80
004ed20
1ae3733
9bfca28
df91c4d
9b476fb
f6c3625
9065f86
73d7319
9415496
4cad359
285a6f8
ee0214e
e6a404d
8427777
71ba234
4cb22ac
150243c
7967ac2
ee21ed1
6727ac4
ce60bf6
129d430
fc1297e
4b1a399
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Optional operator-facing web tier (the plainweave[web] extra).""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from pathlib import Path | ||
| from urllib.parse import parse_qsl | ||
|
|
||
| from starlette.applications import Starlette | ||
| from starlette.middleware import Middleware | ||
| from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint | ||
| from starlette.requests import Request | ||
| from starlette.responses import PlainTextResponse, Response | ||
| from starlette.routing import Mount, Route | ||
| from starlette.staticfiles import StaticFiles | ||
| from starlette.templating import Jinja2Templates | ||
|
|
||
| from plainweave.errors import PlainweaveError | ||
| from plainweave.web.context import RequestContext, csrf_ok, new_csrf_token | ||
| from plainweave.web.errors import error_to_status | ||
|
|
||
| _HERE = Path(__file__).parent | ||
| _CSRF_COOKIE = "pw_csrf" | ||
|
|
||
|
|
||
| def create_app(*, actor: str | None, root: Path | None) -> Starlette: | ||
| templates = Jinja2Templates(directory=str(_HERE / "templates")) | ||
|
|
||
| def ctx_factory() -> RequestContext: | ||
| return RequestContext.from_root(root, actor=actor) | ||
|
|
||
| async def healthz(request: Request) -> Response: | ||
| return PlainTextResponse("ok") | ||
|
|
||
| async def on_error(request: Request, exc: Exception) -> Response: | ||
| if isinstance(exc, PlainweaveError): | ||
| status = error_to_status(exc.code) | ||
| return templates.TemplateResponse( | ||
| request, | ||
| "_partials/error.html", | ||
| {"code": exc.code.value, "message": exc.message, "hint": exc.hint}, | ||
| status_code=status, | ||
| ) | ||
| raise exc | ||
|
|
||
| async def csrf_mw(request: Request, call_next: RequestResponseEndpoint) -> Response: | ||
| cookie_token = request.cookies.get(_CSRF_COOKIE) | ||
| if request.method in {"POST", "PUT", "PATCH", "DELETE"}: | ||
| # Read via .body() so Starlette's _CachedRequest can replay the raw | ||
| # bytes to downstream handlers — calling .form() here would consume | ||
| # the body stream, leaving downstream request.form() empty. | ||
| body = await request.body() | ||
| fields = dict(parse_qsl(body.decode("utf-8"))) | ||
| if not csrf_ok(cookie_token, fields.get("_csrf")): | ||
| return PlainTextResponse("CSRF check failed", status_code=403) | ||
| # Mint the token for THIS render before calling the handler so the template | ||
| # can embed a real token even on the very first (cold) request, when there | ||
| # is no cookie yet. scope["state"] is shared into the handler via | ||
| # BaseHTTPMiddleware, making request.state.csrf_token visible there. | ||
| token = cookie_token or new_csrf_token() | ||
| request.state.csrf_token = token | ||
| response = await call_next(request) | ||
| if cookie_token is None: | ||
| response.set_cookie(_CSRF_COOKIE, token, httponly=True, samesite="strict") | ||
| return response | ||
|
|
||
| routes = [ | ||
| Route("/healthz", healthz), | ||
| Mount("/static", app=StaticFiles(directory=str(_HERE / "static")), name="static"), | ||
| ] | ||
| app = Starlette( | ||
| routes=routes, | ||
| middleware=[Middleware(BaseHTTPMiddleware, dispatch=csrf_mw)], | ||
| exception_handlers={PlainweaveError: on_error}, | ||
| ) | ||
| app.state.templates = templates | ||
| app.state.ctx_factory = ctx_factory | ||
| app.state.csrf_cookie = _CSRF_COOKIE | ||
|
|
||
| from plainweave.web.routes import register_all | ||
|
|
||
| register_all(app) | ||
| return app |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import secrets | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
|
|
||
| from plainweave.errors import ErrorCode, PlainweaveError | ||
| from plainweave.paths import plainweave_db_path | ||
| from plainweave.service import PlainweaveService | ||
|
|
||
| DEFAULT_OPERATOR_ID = "human:operator" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class OperatorIdentity: | ||
| actor_id: str | ||
| display_name: str | ||
| kind: str | ||
|
|
||
|
|
||
| class RequestContext: | ||
| def __init__(self, service: PlainweaveService, operator: OperatorIdentity) -> None: | ||
| self.service = service | ||
| self.operator = operator | ||
|
|
||
| @classmethod | ||
| def from_root(cls, root: Path | None, *, actor: str | None) -> RequestContext: | ||
| service = PlainweaveService(plainweave_db_path(root), root=root) | ||
| actor_id = actor or DEFAULT_OPERATOR_ID | ||
| display = actor_id.split(":", 1)[-1] or actor_id | ||
| operator = cls._ensure_operator(service, actor_id, display) | ||
| return cls(service, operator) | ||
|
|
||
| @staticmethod | ||
| def _ensure_operator(service: PlainweaveService, actor_id: str, display: str) -> OperatorIdentity: | ||
| # Register the operator as a human actor. At genesis (no attester yet) this | ||
| # self-registration is permitted; once an attester exists, only an existing | ||
| # attester may (re)register a human — surface that clearly rather than crashing. | ||
| try: | ||
| service.register_actor(actor_id, kind="human", display_name=display, actor=actor_id) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because Useful? React with 👍 / 👎. |
||
| except PlainweaveError as exc: | ||
| if exc.code is ErrorCode.POLICY_REQUIRED: | ||
| raise PlainweaveError( | ||
| ErrorCode.POLICY_REQUIRED, | ||
| f"operator actor {actor_id!r} is not a registered human and cannot self-register " | ||
| "(an attester already exists). Register it via the CLI before launching the web UI.", | ||
| recoverable=False, | ||
| hint="plainweave actor register --id <id> --kind human --actor <existing-attester>", | ||
| ) from exc | ||
| raise | ||
| return OperatorIdentity(actor_id=actor_id, display_name=display, kind="human") | ||
|
|
||
|
|
||
| def new_csrf_token() -> str: | ||
| return secrets.token_urlsafe(32) | ||
|
|
||
|
|
||
| def csrf_ok(cookie_token: str | None, form_token: str | None) -> bool: | ||
| if not cookie_token or not form_token: | ||
| return False | ||
| return secrets.compare_digest(cookie_token, form_token) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the web UI is launched in a fresh checkout before
plainweave init, this constructs a service for a database under a missing.plainweave/directory; the first page request then reaches actor registration and raisessqlite3.OperationalErrorinstead of aPlainweaveErrorwith recovery guidance. The new README web launch path does not mention a required init step, so the operator console fails as a 500 unless this path initializes the store or mirrors the CLI's NOT_FOUND hint.Useful? React with 👍 / 👎.